实现七月迭代公共技术基础
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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/service/verification"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auth"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
@@ -15,14 +17,16 @@ import (
|
||||
// Dependencies 封装所有基础依赖
|
||||
// 这些是应用启动时初始化的核心组件
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB // PostgreSQL 数据库连接
|
||||
Redis *redis.Client // Redis 客户端
|
||||
Logger *zap.Logger // 应用日志器
|
||||
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
|
||||
TokenManager *auth.TokenManager // Token 管理器(后台和H5认证)
|
||||
VerificationService *verification.Service // 验证码服务
|
||||
QueueClient *queue.Client // Asynq 任务队列客户端
|
||||
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
|
||||
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
||||
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
||||
DB *gorm.DB // PostgreSQL 数据库连接
|
||||
Redis *redis.Client // Redis 客户端
|
||||
Logger *zap.Logger // 应用日志器
|
||||
JWTManager *auth.JWTManager // JWT 管理器(个人客户认证)
|
||||
TokenManager *auth.TokenManager // Token 管理器(后台和H5认证)
|
||||
VerificationService *verification.Service // 验证码服务
|
||||
QueueClient *queue.Client // Asynq 任务队列客户端
|
||||
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
|
||||
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
||||
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
||||
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
|
||||
SystemConfigAudit systemConfigApp.AuditWriter // 统一配置变更审计 Port(可选,缺失时更新失败关闭)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
|
||||
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
|
||||
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
|
||||
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
|
||||
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
|
||||
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
|
||||
@@ -76,6 +79,20 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
deps.Redis,
|
||||
deps.Logger,
|
||||
)
|
||||
systemConfigRegistry := deps.SystemConfigRegistry
|
||||
if systemConfigRegistry == nil {
|
||||
systemConfigRegistry = systemConfigInfra.NewRegistry()
|
||||
}
|
||||
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
|
||||
var systemConfigCache systemConfigInfra.Cache
|
||||
if deps.Redis != nil {
|
||||
systemConfigCache = systemConfigInfra.NewRedisCache(deps.Redis)
|
||||
}
|
||||
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
|
||||
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
|
||||
systemConfigUpdate := systemConfigApp.NewUpdateService(
|
||||
deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
|
||||
)
|
||||
|
||||
return &Handlers{
|
||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||
@@ -150,6 +167,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
|
||||
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
|
||||
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
|
||||
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
|
||||
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ type Handlers struct {
|
||||
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
SystemConfig *admin.SystemConfigHandler
|
||||
AgentOpenAPI *openapiHandler.Handler
|
||||
}
|
||||
|
||||
|
||||
54
internal/handler/admin/system_config.go
Normal file
54
internal/handler/admin/system_config.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
configapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
configquery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// SystemConfigHandler 提供受控系统配置查询和单 Key 更新接口。
|
||||
type SystemConfigHandler struct {
|
||||
listQuery *configquery.ListQuery
|
||||
updateService *configapp.UpdateService
|
||||
}
|
||||
|
||||
// NewSystemConfigHandler 创建系统配置 Handler。
|
||||
func NewSystemConfigHandler(listQuery *configquery.ListQuery, updateService *configapp.UpdateService) *SystemConfigHandler {
|
||||
return &SystemConfigHandler{listQuery: listQuery, updateService: updateService}
|
||||
}
|
||||
|
||||
// List 查询受控系统配置列表。
|
||||
// GET /api/admin/system-configs
|
||||
func (h *SystemConfigHandler) List(c *fiber.Ctx) error {
|
||||
var request dto.SystemConfigListRequest
|
||||
if err := c.QueryParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.listQuery.Execute(c.UserContext(), request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Update 更新一个已注册且允许修改的系统配置。
|
||||
// PUT /api/admin/system-configs/:key
|
||||
func (h *SystemConfigHandler) Update(c *fiber.Ctx) error {
|
||||
key := c.Params("key")
|
||||
if key == "" {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
var request dto.UpdateSystemConfigRequest
|
||||
if err := c.BodyParser(&request); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
result, err := h.updateService.Execute(c.UserContext(), key, request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return response.Success(c, result)
|
||||
}
|
||||
117
internal/infrastructure/asynctask/store.go
Normal file
117
internal/infrastructure/asynctask/store.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// Package asynctask 提供业务自有任务表复用的 PostgreSQL 条件更新 Adapter。
|
||||
package asynctask
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
contract "github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
)
|
||||
|
||||
var identifierPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
|
||||
// Definition 描述业务自有任务表如何映射统一字段。
|
||||
type Definition struct {
|
||||
Table string
|
||||
IDColumn string
|
||||
StatusColumn string
|
||||
LeaseOwnerColumn string
|
||||
LeaseExpiresColumn string
|
||||
TotalColumn string
|
||||
SuccessColumn string
|
||||
FailedColumn string
|
||||
ProgressColumn string
|
||||
ErrorCodeColumn string
|
||||
ErrorSummaryColumn string
|
||||
StartedAtColumn string
|
||||
CompletedAtColumn string
|
||||
UpdatedAtColumn string
|
||||
}
|
||||
|
||||
// Store 对一个已注册的业务任务表执行统一条件更新。
|
||||
type Store struct {
|
||||
db *gorm.DB
|
||||
definition Definition
|
||||
}
|
||||
|
||||
// NewStore 创建业务任务表 Adapter,不创建或迁移任何万能任务表。
|
||||
func NewStore(db *gorm.DB, definition Definition) (*Store, error) {
|
||||
columns := []string{
|
||||
definition.Table, definition.IDColumn, definition.StatusColumn, definition.LeaseOwnerColumn,
|
||||
definition.LeaseExpiresColumn, definition.TotalColumn, definition.SuccessColumn,
|
||||
definition.FailedColumn, definition.ProgressColumn, definition.ErrorCodeColumn,
|
||||
definition.ErrorSummaryColumn, definition.StartedAtColumn, definition.CompletedAtColumn, definition.UpdatedAtColumn,
|
||||
}
|
||||
for _, identifier := range columns {
|
||||
if !identifierPattern.MatchString(identifier) {
|
||||
return nil, stderrors.New("异步任务表或字段标识不合法")
|
||||
}
|
||||
}
|
||||
return &Store{db: db, definition: definition}, nil
|
||||
}
|
||||
|
||||
// Claim 使用预期状态和过期租约条件领取任务。
|
||||
func (s *Store) Claim(ctx context.Context, id any, owner string, now time.Time, duration time.Duration) (bool, error) {
|
||||
if owner == "" || duration <= 0 {
|
||||
return false, stderrors.New("任务租约所有者和时长不能为空")
|
||||
}
|
||||
d := s.definition
|
||||
result := s.db.WithContext(ctx).Table(d.Table).
|
||||
Where(d.IDColumn+" = ? AND ("+d.StatusColumn+" = ? OR ("+d.StatusColumn+" = ? AND "+d.LeaseExpiresColumn+" <= ?))",
|
||||
id, contract.StatusPending, contract.StatusProcessing, now).
|
||||
Updates(map[string]any{
|
||||
d.StatusColumn: contract.StatusProcessing, d.LeaseOwnerColumn: owner,
|
||||
d.LeaseExpiresColumn: now.Add(duration), d.StartedAtColumn: gorm.Expr("COALESCE("+d.StartedAtColumn+", ?)", now),
|
||||
d.UpdatedAtColumn: now,
|
||||
})
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
// Renew 只允许当前所有者在租约有效时续期处理中任务。
|
||||
func (s *Store) Renew(ctx context.Context, id any, owner string, now time.Time, duration time.Duration) (bool, error) {
|
||||
if owner == "" || duration <= 0 {
|
||||
return false, stderrors.New("任务租约所有者和时长不能为空")
|
||||
}
|
||||
d := s.definition
|
||||
updated := s.db.WithContext(ctx).Table(d.Table).
|
||||
Where(d.IDColumn+" = ? AND "+d.StatusColumn+" = ? AND "+d.LeaseOwnerColumn+" = ? AND "+d.LeaseExpiresColumn+" > ?",
|
||||
id, contract.StatusProcessing, owner, now).
|
||||
Updates(map[string]any{d.LeaseExpiresColumn: now.Add(duration), d.UpdatedAtColumn: now})
|
||||
return updated.RowsAffected == 1, updated.Error
|
||||
}
|
||||
|
||||
// Finish 只允许有效租约所有者把处理中任务推进到统一终态。
|
||||
func (s *Store) Finish(ctx context.Context, id any, owner string, result contract.TerminalResult, now time.Time) (bool, error) {
|
||||
projection, err := contract.NewTerminalProjection(result)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
d := s.definition
|
||||
updated := s.db.WithContext(ctx).Table(d.Table).
|
||||
Where(d.IDColumn+" = ? AND "+d.StatusColumn+" = ? AND "+d.LeaseOwnerColumn+" = ? AND "+d.LeaseExpiresColumn+" > ?",
|
||||
id, contract.StatusProcessing, owner, now).
|
||||
Updates(map[string]any{
|
||||
d.StatusColumn: projection.Status, d.TotalColumn: projection.TotalCount,
|
||||
d.SuccessColumn: projection.SuccessCount, d.FailedColumn: projection.FailedCount,
|
||||
d.ProgressColumn: projection.Progress, d.ErrorCodeColumn: projection.ErrorCode,
|
||||
d.ErrorSummaryColumn: projection.ErrorSummary, d.CompletedAtColumn: now,
|
||||
d.LeaseOwnerColumn: nil, d.LeaseExpiresColumn: nil, d.UpdatedAtColumn: now,
|
||||
})
|
||||
return updated.RowsAffected == 1, updated.Error
|
||||
}
|
||||
|
||||
// Cancel 只允许业务明确支持时从待处理或处理中进入已取消。
|
||||
func (s *Store) Cancel(ctx context.Context, id any, now time.Time) (bool, error) {
|
||||
d := s.definition
|
||||
updated := s.db.WithContext(ctx).Table(d.Table).
|
||||
Where(d.IDColumn+" = ? AND "+d.StatusColumn+" IN ?", id, []int{contract.StatusPending, contract.StatusProcessing}).
|
||||
Updates(map[string]any{
|
||||
d.StatusColumn: contract.StatusCancelled, d.ProgressColumn: 100,
|
||||
d.CompletedAtColumn: now, d.LeaseOwnerColumn: nil, d.LeaseExpiresColumn: nil, d.UpdatedAtColumn: now,
|
||||
})
|
||||
return updated.RowsAffected == 1, updated.Error
|
||||
}
|
||||
130
internal/infrastructure/asynctask/store_integration_test.go
Normal file
130
internal/infrastructure/asynctask/store_integration_test.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package asynctask_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
storepkg "github.com/break/junhong_cmp_fiber/internal/infrastructure/asynctask"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
contract "github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
)
|
||||
|
||||
func TestPostgresTaskTransitionsAreConditionalAndRecoverExpiredLease(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTaskContractTable(t, db)
|
||||
store, err := storepkg.NewStore(db, taskDefinition())
|
||||
if err != nil {
|
||||
t.Fatalf("创建任务契约 Store 失败:%v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
if err := db.Exec("INSERT INTO test_async_contract_task (id, status, updated_at) VALUES (1, 1, ?)", now).Error; err != nil {
|
||||
t.Fatalf("准备待处理任务失败:%v", err)
|
||||
}
|
||||
claimed, err := store.Claim(context.Background(), 1, "worker-a", now, time.Minute)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("领取待处理任务失败:%v,领取:%v", err, claimed)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-b", now.Add(30*time.Second), time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("不得抢占有效租约:%v,领取:%v", err, claimed)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-b", now.Add(2*time.Minute), time.Minute)
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("过期任务应由新 Worker 恢复:%v,领取:%v", err, claimed)
|
||||
}
|
||||
renewed, err := store.Renew(context.Background(), 1, "worker-a", now.Add(150*time.Second), 2*time.Minute)
|
||||
if err != nil || renewed {
|
||||
t.Fatalf("旧租约所有者不得续租:%v,续租:%v", err, renewed)
|
||||
}
|
||||
renewed, err = store.Renew(context.Background(), 1, "worker-b", now.Add(150*time.Second), 2*time.Minute)
|
||||
if err != nil || !renewed {
|
||||
t.Fatalf("有效租约所有者续租失败:%v,续租:%v", err, renewed)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-c", now.Add(3*time.Minute), time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("续租后不得被其他 Worker 领取:%v,领取:%v", err, claimed)
|
||||
}
|
||||
finished, err := store.Finish(context.Background(), 1, "worker-a", contract.TerminalResult{
|
||||
TaskID: "1", Status: contract.StatusCompleted, TotalCount: 10, SuccessCount: 7, FailedCount: 3, UpdatedAt: now,
|
||||
}, now.Add(3*time.Minute))
|
||||
if err != nil || finished {
|
||||
t.Fatalf("旧租约所有者不得完成任务:%v,完成:%v", err, finished)
|
||||
}
|
||||
finished, err = store.Finish(context.Background(), 1, "worker-b", contract.TerminalResult{
|
||||
TaskID: "1", Status: contract.StatusCompleted, TotalCount: 10, SuccessCount: 7, FailedCount: 3, UpdatedAt: now,
|
||||
}, now.Add(3*time.Minute))
|
||||
if err != nil || !finished {
|
||||
t.Fatalf("当前租约所有者完成任务失败:%v,完成:%v", err, finished)
|
||||
}
|
||||
claimed, err = store.Claim(context.Background(), 1, "worker-c", now.Add(4*time.Minute), time.Minute)
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("终态重复消费必须无副作用:%v,领取:%v", err, claimed)
|
||||
}
|
||||
cancelled, err := store.Cancel(context.Background(), 1, now.Add(4*time.Minute))
|
||||
if err != nil || cancelled {
|
||||
t.Fatalf("终态任务不得再次取消:%v,取消:%v", err, cancelled)
|
||||
}
|
||||
|
||||
var row struct {
|
||||
Status int
|
||||
Total int
|
||||
Success int
|
||||
Failed int
|
||||
}
|
||||
if err := db.Table("test_async_contract_task").Select("status, total_count AS total, success_count AS success, failed_count AS failed").Where("id = 1").Scan(&row).Error; err != nil {
|
||||
t.Fatalf("读取任务终态失败:%v", err)
|
||||
}
|
||||
if row.Status != contract.StatusCompleted || row.Total != 10 || row.Success != 7 || row.Failed != 3 {
|
||||
t.Fatalf("任务终态计数错误:%+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresTaskContractSupportsWholeFailureAndCancellation(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTaskContractTable(t, db)
|
||||
store, _ := storepkg.NewStore(db, taskDefinition())
|
||||
now := time.Now().UTC()
|
||||
if err := db.Exec("INSERT INTO test_async_contract_task (id, status, updated_at) VALUES (2, 1, ?), (3, 1, ?)", now, now).Error; err != nil {
|
||||
t.Fatalf("准备任务失败:%v", err)
|
||||
}
|
||||
claimed, _ := store.Claim(context.Background(), 2, "worker", now, time.Minute)
|
||||
if !claimed {
|
||||
t.Fatal("整体失败任务领取失败")
|
||||
}
|
||||
finished, err := store.Finish(context.Background(), 2, "worker", contract.TerminalResult{
|
||||
TaskID: "2", Status: contract.StatusFailed, ErrorCode: "FILE_PARSE_FAILED", ErrorSummary: "文件无法解析", UpdatedAt: now,
|
||||
}, now)
|
||||
if err != nil || !finished {
|
||||
t.Fatalf("整体失败终态更新失败:%v", err)
|
||||
}
|
||||
cancelled, err := store.Cancel(context.Background(), 3, now)
|
||||
if err != nil || !cancelled {
|
||||
t.Fatalf("待处理任务取消失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func taskDefinition() storepkg.Definition {
|
||||
return storepkg.Definition{
|
||||
Table: "test_async_contract_task", IDColumn: "id", StatusColumn: "status",
|
||||
LeaseOwnerColumn: "lease_owner", LeaseExpiresColumn: "lease_expires_at",
|
||||
TotalColumn: "total_count", SuccessColumn: "success_count", FailedColumn: "failed_count",
|
||||
ProgressColumn: "progress", ErrorCodeColumn: "error_code", ErrorSummaryColumn: "error_summary",
|
||||
StartedAtColumn: "started_at", CompletedAtColumn: "completed_at", UpdatedAtColumn: "updated_at",
|
||||
}
|
||||
}
|
||||
|
||||
func createTaskContractTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_async_contract_task (
|
||||
id bigint PRIMARY KEY, status integer NOT NULL, total_count integer NOT NULL DEFAULT 0,
|
||||
success_count integer NOT NULL DEFAULT 0, failed_count integer NOT NULL DEFAULT 0,
|
||||
progress integer NOT NULL DEFAULT 0, error_code varchar(100) NOT NULL DEFAULT '',
|
||||
error_summary varchar(500) NOT NULL DEFAULT '', lease_owner varchar(100), lease_expires_at timestamptz,
|
||||
started_at timestamptz, completed_at timestamptz, updated_at timestamptz NOT NULL
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建任务契约测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
326
internal/infrastructure/messaging/outbox/relay.go
Normal file
326
internal/infrastructure/messaging/outbox/relay.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
// DeliveryEnvelope 是 Relay 原样传播到 Asynq 的公共结构化信封。
|
||||
type DeliveryEnvelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
PayloadVersion int `json:"payload_version"`
|
||||
AggregateType string `json:"aggregate_type"`
|
||||
AggregateID string `json:"aggregate_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
Payload sonic.NoCopyRawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// Publisher 是 Relay 的队列边界。
|
||||
type Publisher interface {
|
||||
Publish(ctx context.Context, envelope DeliveryEnvelope) error
|
||||
}
|
||||
|
||||
// PermanentError 表示重试无法修复的投递错误。
|
||||
type PermanentError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// Error 返回安全的错误文本,仅供内部日志和错误链判断使用。
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
// Unwrap 返回原始错误。
|
||||
func (e *PermanentError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
// Permanent 将不可恢复错误标记为永久失败,Relay 会直接保留最终失败事实。
|
||||
func Permanent(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return &PermanentError{err: err}
|
||||
}
|
||||
|
||||
// QueuePublisher 使用项目统一队列客户端发布结构化信封。
|
||||
type QueuePublisher struct {
|
||||
client *queue.Client
|
||||
}
|
||||
|
||||
// NewQueuePublisher 创建项目统一队列客户端 Adapter。
|
||||
func NewQueuePublisher(client *queue.Client) *QueuePublisher {
|
||||
return &QueuePublisher{client: client}
|
||||
}
|
||||
|
||||
// Publish 将公共信封作为 struct 入队,禁止调用方预序列化。
|
||||
func (p *QueuePublisher) Publish(ctx context.Context, envelope DeliveryEnvelope) error {
|
||||
return p.client.EnqueueTask(ctx, constants.TaskTypeOutboxDeliver, envelope)
|
||||
}
|
||||
|
||||
// RelayOptions 控制单个 Relay 实例的领取与重试行为。
|
||||
type RelayOptions struct {
|
||||
Owner string
|
||||
BatchSize int
|
||||
LeaseDuration time.Duration
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Relay 完成公共 Outbox 的租约领取和至少一次队列投递。
|
||||
type Relay struct {
|
||||
db *gorm.DB
|
||||
publisher Publisher
|
||||
logger *zap.Logger
|
||||
options RelayOptions
|
||||
}
|
||||
|
||||
// NewRelay 创建公共 Outbox Relay。
|
||||
func NewRelay(db *gorm.DB, publisher Publisher, logger *zap.Logger, options RelayOptions) (*Relay, error) {
|
||||
if db == nil || publisher == nil || options.Owner == "" {
|
||||
return nil, stderrors.New("Outbox Relay 依赖和租约所有者不能为空")
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = constants.OutboxDefaultBatchSize
|
||||
}
|
||||
if options.LeaseDuration <= 0 {
|
||||
options.LeaseDuration = constants.OutboxDefaultLeaseDuration
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &Relay{db: db, publisher: publisher, logger: logger, options: options}, nil
|
||||
}
|
||||
|
||||
// ProcessBatch 领取并投递一批到期事件。
|
||||
func (r *Relay) ProcessBatch(ctx context.Context) (int, error) {
|
||||
events, err := r.ClaimBatch(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
for _, event := range events {
|
||||
if err := r.publisher.Publish(ctx, deliveryEnvelope(event)); err != nil {
|
||||
var permanentError *PermanentError
|
||||
permanent := stderrors.As(err, &permanentError)
|
||||
code := "OUTBOX_ENQUEUE_FAILED"
|
||||
summary := "队列暂时不可用"
|
||||
if permanent {
|
||||
code = "OUTBOX_PERMANENT_FAILURE"
|
||||
summary = "事件无法投递,已停止自动重试"
|
||||
}
|
||||
if failErr := r.markFailed(ctx, event, code, summary, permanent); failErr != nil {
|
||||
return processed, failErr
|
||||
}
|
||||
r.logger.Warn("Outbox 事件投递失败",
|
||||
zap.String("event_id", event.EventID), zap.String("correlation_id", event.CorrelationID),
|
||||
zap.String("error_code", code), zap.Bool("permanent", permanent))
|
||||
continue
|
||||
}
|
||||
if err := r.MarkDelivered(ctx, event.ID); err != nil {
|
||||
// 入队成功但标记失败时保留租约,过期后会使用同一 event_id 再次投递。
|
||||
return processed, err
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// ClaimBatch 通过行锁跳过竞争行,并领取待投递或租约过期事件。
|
||||
func (r *Relay) ClaimBatch(ctx context.Context) ([]model.OutboxEvent, error) {
|
||||
now := r.options.Now().UTC()
|
||||
expiresAt := now.Add(r.options.LeaseDuration)
|
||||
claimed := make([]model.OutboxEvent, 0, r.options.BatchSize)
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var candidates []model.OutboxEvent
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
|
||||
Where("(status = ? AND next_attempt_at <= ?) OR (status = ? AND lease_expires_at <= ?)",
|
||||
constants.OutboxStatusPending, now, constants.OutboxStatusDelivering, now).
|
||||
Order("created_at ASC, id ASC").Limit(r.options.BatchSize).Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for index := range candidates {
|
||||
result := tx.Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND ((status = ? AND next_attempt_at <= ?) OR (status = ? AND lease_expires_at <= ?))",
|
||||
candidates[index].ID, constants.OutboxStatusPending, now, constants.OutboxStatusDelivering, now).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusDelivering, "lease_owner": r.options.Owner,
|
||||
"lease_expires_at": expiresAt, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
candidates[index].Status = constants.OutboxStatusDelivering
|
||||
candidates[index].LeaseOwner = &r.options.Owner
|
||||
candidates[index].LeaseExpiresAt = &expiresAt
|
||||
claimed = append(claimed, candidates[index])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
// RenewLease 仅允许当前租约所有者续租投递中的事件。
|
||||
func (r *Relay) RenewLease(ctx context.Context, eventID uint) (bool, error) {
|
||||
now := r.options.Now().UTC()
|
||||
result := r.db.WithContext(ctx).Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ? AND lease_expires_at > ?",
|
||||
eventID, constants.OutboxStatusDelivering, r.options.Owner, now).
|
||||
Updates(map[string]any{"lease_expires_at": now.Add(r.options.LeaseDuration), "updated_at": now})
|
||||
return result.RowsAffected == 1, result.Error
|
||||
}
|
||||
|
||||
// MarkDelivered 仅允许当前租约所有者把事件标记为已投递。
|
||||
func (r *Relay) MarkDelivered(ctx context.Context, eventID uint) error {
|
||||
now := r.options.Now().UTC()
|
||||
result := r.db.WithContext(ctx).Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ?", eventID, constants.OutboxStatusDelivering, r.options.Owner).
|
||||
Updates(map[string]any{
|
||||
"status": constants.OutboxStatusDelivered, "delivered_at": now,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "last_error_code": "",
|
||||
"last_error_summary": "", "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return stderrors.New("Outbox 投递完成条件不满足")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkFailed 记录安全错误并退避;达到上限后保留最终失败事实。
|
||||
func (r *Relay) MarkFailed(ctx context.Context, event model.OutboxEvent, code, summary string) error {
|
||||
return r.markFailed(ctx, event, code, summary, false)
|
||||
}
|
||||
|
||||
func (r *Relay) markFailed(ctx context.Context, event model.OutboxEvent, code, summary string, permanent bool) error {
|
||||
now := r.options.Now().UTC()
|
||||
retryCount := event.RetryCount + 1
|
||||
status := constants.OutboxStatusPending
|
||||
nextAttemptAt := now.Add(backoff(retryCount))
|
||||
if permanent || retryCount >= event.MaxRetries {
|
||||
status = constants.OutboxStatusFailed
|
||||
nextAttemptAt = now
|
||||
r.logger.Error("Outbox 事件停止自动重试",
|
||||
zap.String("event_id", event.EventID), zap.String("correlation_id", event.CorrelationID),
|
||||
zap.String("error_code", code), zap.Bool("permanent", permanent))
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.OutboxEvent{}).
|
||||
Where("id = ? AND status = ? AND lease_owner = ?", event.ID, constants.OutboxStatusDelivering, r.options.Owner).
|
||||
Updates(map[string]any{
|
||||
"status": status, "retry_count": retryCount, "next_attempt_at": nextAttemptAt,
|
||||
"lease_owner": nil, "lease_expires_at": nil, "last_error_code": code,
|
||||
"last_error_summary": summary, "updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return stderrors.New("Outbox 失败更新条件不满足")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func deliveryEnvelope(event model.OutboxEvent) DeliveryEnvelope {
|
||||
return DeliveryEnvelope{
|
||||
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: sonic.NoCopyRawMessage(event.Payload),
|
||||
}
|
||||
}
|
||||
|
||||
func backoff(retryCount int) time.Duration {
|
||||
delay := float64(constants.OutboxBaseRetryDelay) * math.Pow(2, float64(retryCount-1))
|
||||
if delay > float64(constants.OutboxMaxRetryDelay) {
|
||||
return constants.OutboxMaxRetryDelay
|
||||
}
|
||||
return time.Duration(delay)
|
||||
}
|
||||
|
||||
// EventConsumer 是业务消费者公开实现的事件处理边界。
|
||||
type EventConsumer interface {
|
||||
Consume(ctx context.Context, envelope DeliveryEnvelope) error
|
||||
}
|
||||
|
||||
// ConsumerRegistry 按稳定事件类型分发到业务消费者。
|
||||
type ConsumerRegistry struct {
|
||||
mu sync.RWMutex
|
||||
consumers map[string]EventConsumer
|
||||
}
|
||||
|
||||
// NewConsumerRegistry 创建空消费者注册表。
|
||||
func NewConsumerRegistry() *ConsumerRegistry {
|
||||
return &ConsumerRegistry{consumers: map[string]EventConsumer{}}
|
||||
}
|
||||
|
||||
// Register 注册一个由业务 PRD 拥有的事件消费者。
|
||||
func (r *ConsumerRegistry) Register(eventType string, consumer EventConsumer) error {
|
||||
if eventType == "" || consumer == nil {
|
||||
return stderrors.New("Outbox 消费者注册信息不完整")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, exists := r.consumers[eventType]; exists {
|
||||
return stderrors.New("Outbox 事件类型重复注册")
|
||||
}
|
||||
r.consumers[eventType] = consumer
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume 将公共信封交给对应业务消费者;公共层不实现业务副作用。
|
||||
func (r *ConsumerRegistry) Consume(ctx context.Context, envelope DeliveryEnvelope) error {
|
||||
r.mu.RLock()
|
||||
consumer := r.consumers[envelope.EventType]
|
||||
r.mu.RUnlock()
|
||||
if consumer == nil {
|
||||
return stderrors.New("Outbox 事件消费者尚未注册")
|
||||
}
|
||||
return consumer.Consume(ctx, envelope)
|
||||
}
|
||||
|
||||
// Handler 是公共 Outbox Asynq 任务 Handler。
|
||||
type Handler struct {
|
||||
consumer EventConsumer
|
||||
}
|
||||
|
||||
// NewHandler 创建公共 Outbox Asynq Handler。
|
||||
func NewHandler(consumer EventConsumer) *Handler {
|
||||
return &Handler{consumer: consumer}
|
||||
}
|
||||
|
||||
// Handle 解析结构化信封并调用公开消费者边界。
|
||||
func (h *Handler) Handle(ctx context.Context, task *asynq.Task) error {
|
||||
var envelope DeliveryEnvelope
|
||||
if err := sonic.Unmarshal(task.Payload(), &envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if envelope.EventID == "" || envelope.EventType == "" || envelope.PayloadVersion <= 0 {
|
||||
return stderrors.New("Outbox 事件信封不完整")
|
||||
}
|
||||
return h.consumer.Consume(ctx, envelope)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
)
|
||||
|
||||
type recordingPublisher struct {
|
||||
mu sync.Mutex
|
||||
envelopes []outbox.DeliveryEnvelope
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *recordingPublisher) Publish(_ context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.envelopes = append(p.envelopes, envelope)
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestExpiredLeaseRedeliversOriginalEnvelope(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-stable", "business-stable"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备 Outbox 事件失败:%v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
firstPublisher := &recordingPublisher{}
|
||||
first, err := outbox.NewRelay(db, firstPublisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-a", BatchSize: 1, LeaseDuration: time.Second, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建首个 Relay 失败:%v", err)
|
||||
}
|
||||
claimed, err := first.ClaimBatch(context.Background())
|
||||
if err != nil || len(claimed) != 1 {
|
||||
t.Fatalf("首个 Relay 领取失败:%v,数量:%d", err, len(claimed))
|
||||
}
|
||||
// 模拟入队成功但数据库标记前崩溃:公开队列信封已经可观察,但租约没有完成。
|
||||
if err := firstPublisher.Publish(context.Background(), deliveryFromModel(claimed[0])); err != nil {
|
||||
t.Fatalf("模拟首次入队失败:%v", err)
|
||||
}
|
||||
|
||||
now = now.Add(2 * time.Second)
|
||||
secondPublisher := &recordingPublisher{}
|
||||
second, err := outbox.NewRelay(db, secondPublisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-b", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建恢复 Relay 失败:%v", err)
|
||||
}
|
||||
processed, err := second.ProcessBatch(context.Background())
|
||||
if err != nil || processed != 1 {
|
||||
t.Fatalf("恢复投递失败:%v,数量:%d", err, processed)
|
||||
}
|
||||
if len(firstPublisher.envelopes) != 1 || len(secondPublisher.envelopes) != 1 {
|
||||
t.Fatalf("至少一次投递次数不正确:%d/%d", len(firstPublisher.envelopes), len(secondPublisher.envelopes))
|
||||
}
|
||||
firstEnvelope := firstPublisher.envelopes[0]
|
||||
secondEnvelope := secondPublisher.envelopes[0]
|
||||
if firstEnvelope.EventID != event.EventID || secondEnvelope.EventID != event.EventID ||
|
||||
firstEnvelope.CorrelationID != secondEnvelope.CorrelationID || string(firstEnvelope.Payload) != string(secondEnvelope.Payload) {
|
||||
t.Fatalf("恢复投递改变了事件身份或载荷:%+v / %+v", firstEnvelope, secondEnvelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayFailureBackoffAndFinalFailure(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-retry", "business-retry"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备 Outbox 事件失败:%v", err)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Update("max_retries", 2).Error; err != nil {
|
||||
t.Fatalf("设置最大重试次数失败:%v", err)
|
||||
}
|
||||
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
publisher := &recordingPublisher{err: stderrors.New("测试队列失败")}
|
||||
relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-retry", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 Relay 失败:%v", err)
|
||||
}
|
||||
if _, err := relay.ProcessBatch(context.Background()); err != nil {
|
||||
t.Fatalf("记录首次失败失败:%v", err)
|
||||
}
|
||||
var afterFirst model.OutboxEvent
|
||||
if err := db.First(&afterFirst, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取首次失败事实失败:%v", err)
|
||||
}
|
||||
if afterFirst.Status != constants.OutboxStatusPending || afterFirst.RetryCount != 1 || !afterFirst.NextAttemptAt.After(now) {
|
||||
t.Fatalf("首次失败未按退避重试:%+v", afterFirst)
|
||||
}
|
||||
|
||||
now = afterFirst.NextAttemptAt
|
||||
if _, err := relay.ProcessBatch(context.Background()); err != nil {
|
||||
t.Fatalf("记录最终失败失败:%v", err)
|
||||
}
|
||||
var final model.OutboxEvent
|
||||
if err := db.First(&final, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取最终失败事实失败:%v", err)
|
||||
}
|
||||
if final.Status != constants.OutboxStatusFailed || final.RetryCount != 2 || final.LastErrorSummary != "队列暂时不可用" {
|
||||
t.Fatalf("最终失败事实不正确:%+v", final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayPermanentFailureStopsRetryImmediately(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-permanent", "business-permanent"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备永久失败事件失败:%v", err)
|
||||
}
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
publisher := &recordingPublisher{err: outbox.Permanent(stderrors.New("载荷版本不受支持"))}
|
||||
relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{
|
||||
Owner: "relay-permanent", BatchSize: 1, LeaseDuration: time.Minute, Now: func() time.Time { return now },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("创建 Relay 失败:%v", err)
|
||||
}
|
||||
if _, err := relay.ProcessBatch(context.Background()); err != nil {
|
||||
t.Fatalf("记录永久失败事实失败:%v", err)
|
||||
}
|
||||
var failed model.OutboxEvent
|
||||
if err := db.First(&failed, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取永久失败事实失败:%v", err)
|
||||
}
|
||||
if failed.Status != constants.OutboxStatusFailed || failed.RetryCount != 1 || failed.LastErrorCode != "OUTBOX_PERMANENT_FAILURE" {
|
||||
t.Fatalf("永久失败未立即停止重试:%+v", failed)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingConsumer struct {
|
||||
envelope outbox.DeliveryEnvelope
|
||||
}
|
||||
|
||||
func (c *recordingConsumer) Consume(_ context.Context, envelope outbox.DeliveryEnvelope) error {
|
||||
c.envelope = envelope
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPublicAsynqHandlerObservesStructuredEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
envelope := outbox.DeliveryEnvelope{
|
||||
EventID: "event-handler", EventType: "foundation.example.created", PayloadVersion: 1,
|
||||
CorrelationID: "correlation-handler", Payload: sonic.NoCopyRawMessage(`{"visible":true}`),
|
||||
}
|
||||
payload, err := sonic.Marshal(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("序列化公开信封失败:%v", err)
|
||||
}
|
||||
consumer := &recordingConsumer{}
|
||||
handler := outbox.NewHandler(consumer)
|
||||
if err := handler.Handle(context.Background(), asynq.NewTask(constants.TaskTypeOutboxDeliver, payload)); err != nil {
|
||||
t.Fatalf("公开 Handler 处理失败:%v", err)
|
||||
}
|
||||
if consumer.envelope.EventID != envelope.EventID || consumer.envelope.CorrelationID != envelope.CorrelationID {
|
||||
t.Fatalf("公开 Handler 未原样传播身份:%+v", consumer.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresRelayRedisAsynqAndPublicHandlerChain(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
redisClient := testutil.NewRedisClient(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
|
||||
repository := outbox.NewRepository()
|
||||
event, err := repository.Append(context.Background(), db, newEnvelope("event-real-chain", "business-real-chain"))
|
||||
if err != nil {
|
||||
t.Fatalf("准备真实链路事件失败:%v", err)
|
||||
}
|
||||
queueClient := queue.NewClient(redisClient, zap.NewNop())
|
||||
t.Cleanup(func() { _ = queueClient.Close() })
|
||||
publisher := outbox.NewQueuePublisher(queueClient)
|
||||
relay, err := outbox.NewRelay(db, publisher, zap.NewNop(), outbox.RelayOptions{Owner: "relay-real-chain", BatchSize: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("创建真实链路 Relay 失败:%v", err)
|
||||
}
|
||||
processed, err := relay.ProcessBatch(context.Background())
|
||||
if err != nil || processed != 1 {
|
||||
t.Fatalf("真实链路投递失败:%v,数量:%d", err, processed)
|
||||
}
|
||||
|
||||
options := redisClient.Options()
|
||||
inspector := asynq.NewInspector(asynq.RedisClientOpt{Addr: options.Addr, Password: options.Password, DB: options.DB})
|
||||
t.Cleanup(func() { _ = inspector.Close() })
|
||||
tasks, err := inspector.ListPendingTasks(constants.QueueOutboxDeliver, asynq.PageSize(1000))
|
||||
if err != nil {
|
||||
t.Fatalf("检查 Asynq 待处理任务失败:%v", err)
|
||||
}
|
||||
var matched *asynq.TaskInfo
|
||||
for _, info := range tasks {
|
||||
var queued outbox.DeliveryEnvelope
|
||||
if sonic.Unmarshal(info.Payload, &queued) == nil && queued.EventID == event.EventID {
|
||||
matched = info
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
t.Fatal("真实 Asynq 队列中未找到本次公共事件")
|
||||
}
|
||||
t.Cleanup(func() { _ = inspector.DeleteTask(matched.Queue, matched.ID) })
|
||||
|
||||
consumer := &recordingConsumer{}
|
||||
handler := outbox.NewHandler(consumer)
|
||||
if err := handler.Handle(context.Background(), asynq.NewTask(matched.Type, matched.Payload)); err != nil {
|
||||
t.Fatalf("公开 Handler 处理真实队列载荷失败:%v", err)
|
||||
}
|
||||
if consumer.envelope.EventID != event.EventID || consumer.envelope.CorrelationID != event.CorrelationID {
|
||||
t.Fatalf("真实链路未原样传播事件身份:%+v", consumer.envelope)
|
||||
}
|
||||
|
||||
var delivered model.OutboxEvent
|
||||
if err := db.First(&delivered, event.ID).Error; err != nil {
|
||||
t.Fatalf("读取已投递事件失败:%v", err)
|
||||
}
|
||||
if delivered.Status != constants.OutboxStatusDelivered || delivered.DeliveredAt == nil {
|
||||
t.Fatalf("真实链路未完成 Outbox 状态:%+v", delivered)
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryFromModel(event model.OutboxEvent) outbox.DeliveryEnvelope {
|
||||
return outbox.DeliveryEnvelope{
|
||||
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
|
||||
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
|
||||
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
|
||||
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
|
||||
Payload: sonic.NoCopyRawMessage(event.Payload),
|
||||
}
|
||||
}
|
||||
78
internal/infrastructure/messaging/outbox/repository.go
Normal file
78
internal/infrastructure/messaging/outbox/repository.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Package outbox 实现公共 Outbox 的持久化与投递基础设施。
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// Envelope 是业务 UseCase 在事务内追加的公共事件信封。
|
||||
type Envelope struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
PayloadVersion int `json:"payload_version"`
|
||||
AggregateType string `json:"aggregate_type"`
|
||||
AggregateID string `json:"aggregate_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
BusinessKey string `json:"business_key,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
Payload any `json:"payload"`
|
||||
}
|
||||
|
||||
// Repository 通过调用方显式传入的 GORM 事务句柄追加事件。
|
||||
type Repository struct{}
|
||||
|
||||
// NewRepository 创建公共 Outbox Repository。
|
||||
func NewRepository() *Repository {
|
||||
return &Repository{}
|
||||
}
|
||||
|
||||
// Append 在业务事务中持久化稳定事件身份和必要快照。
|
||||
func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
|
||||
if tx == nil {
|
||||
return nil, stderrors.New("Outbox 追加必须传入 GORM 事务句柄")
|
||||
}
|
||||
if strings.TrimSpace(envelope.EventType) == "" || strings.TrimSpace(envelope.AggregateType) == "" ||
|
||||
strings.TrimSpace(envelope.AggregateID) == "" || strings.TrimSpace(envelope.ResourceType) == "" ||
|
||||
strings.TrimSpace(envelope.ResourceID) == "" {
|
||||
return nil, stderrors.New("Outbox 事件类型和资源定位不能为空")
|
||||
}
|
||||
if err := asynctask.ValidatePayload(envelope.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload, err := sonic.Marshal(envelope.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if envelope.EventID == "" {
|
||||
envelope.EventID = uuid.NewString()
|
||||
}
|
||||
if envelope.PayloadVersion <= 0 {
|
||||
envelope.PayloadVersion = 1
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
event := &model.OutboxEvent{
|
||||
EventID: envelope.EventID, EventType: envelope.EventType, PayloadVersion: envelope.PayloadVersion,
|
||||
AggregateType: envelope.AggregateType, AggregateID: envelope.AggregateID,
|
||||
ResourceType: envelope.ResourceType, ResourceID: envelope.ResourceID, BusinessKey: envelope.BusinessKey,
|
||||
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
|
||||
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(event).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
)
|
||||
|
||||
func TestBusinessFactAndOutboxCommitAndRollbackTogether(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "fact-success").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := repository.Append(context.Background(), tx, newEnvelope("event-success", "fact-success"))
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("提交业务事实和 Outbox 失败:%v", err)
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 1)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "fact-rollback").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := repository.Append(context.Background(), tx, newEnvelope("event-rollback", "fact-rollback")); err != nil {
|
||||
return err
|
||||
}
|
||||
return stderrors.New("注入业务失败")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("注入业务失败时事务应回滚")
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 1)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
}
|
||||
|
||||
func TestOutboxUniqueFailureRollsBackBusinessFact(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryOutboxTables(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
_, err := repository.Append(context.Background(), tx, newEnvelope("event-duplicate", "first"))
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("准备重复事件失败:%v", err)
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("INSERT INTO test_foundation_business_fact (business_key) VALUES (?)", "must-rollback").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_, appendErr := repository.Append(context.Background(), tx, newEnvelope("event-duplicate", "second"))
|
||||
return appendErr
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("重复事件 ID 必须导致 Outbox 写入失败")
|
||||
}
|
||||
assertTableCount(t, db, "test_foundation_business_fact", 0)
|
||||
assertTableCount(t, db, "tb_outbox_event", 1)
|
||||
}
|
||||
|
||||
func newEnvelope(eventID, businessKey string) outbox.Envelope {
|
||||
return outbox.Envelope{
|
||||
EventID: eventID, EventType: "foundation.example.created", PayloadVersion: 1,
|
||||
AggregateType: "example", AggregateID: businessKey,
|
||||
ResourceType: "example", ResourceID: businessKey, BusinessKey: businessKey,
|
||||
RequestID: "request-1", CorrelationID: "correlation-1",
|
||||
Payload: struct {
|
||||
BusinessKey string `json:"business_key"`
|
||||
}{BusinessKey: businessKey},
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryOutboxTables(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE test_foundation_business_fact (
|
||||
id bigserial PRIMARY KEY, business_key varchar(100) NOT NULL UNIQUE
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建业务事实测试表失败:%v", err)
|
||||
}
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
}
|
||||
|
||||
func assertTableCount(t *testing.T, db *gorm.DB, table string, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Table(table).Count(&count).Error; err != nil {
|
||||
t.Fatalf("统计表 %s 失败:%v", table, err)
|
||||
}
|
||||
if count != expected {
|
||||
t.Fatalf("表 %s 行数错误:得到 %d,期望 %d", table, count, expected)
|
||||
}
|
||||
}
|
||||
324
internal/infrastructure/releasegate/checker.go
Normal file
324
internal/infrastructure/releasegate/checker.go
Normal file
@@ -0,0 +1,324 @@
|
||||
// Package releasegate 提供公共基础数据库对象的发布检查门禁。
|
||||
package releasegate
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// PhasePre 表示迁移或发布前检查。
|
||||
PhasePre = "pre"
|
||||
// PhasePost 表示迁移后的定义与冒烟检查。
|
||||
PhasePost = "post"
|
||||
)
|
||||
|
||||
// Severity 表示门禁检查结果级别。
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityInfo 表示安全的计数或对象标识。
|
||||
SeverityInfo Severity = "info"
|
||||
// SeverityBlock 表示必须阻断发布的问题。
|
||||
SeverityBlock Severity = "block"
|
||||
)
|
||||
|
||||
// Finding 是不包含业务敏感值的门禁检查结果。
|
||||
type Finding struct {
|
||||
Code string `json:"code"`
|
||||
Severity Severity `json:"severity"`
|
||||
Object string `json:"object"`
|
||||
Count int64 `json:"count"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Report 是可重复执行的公共基础门禁报告。
|
||||
type Report struct {
|
||||
Phase string `json:"phase"`
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// Passed 判断报告是否允许继续发布。
|
||||
func (r Report) Passed() bool {
|
||||
for _, finding := range r.Findings {
|
||||
if finding.Severity == SeverityBlock {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Checker 使用 PostgreSQL 事实检查公共对象定义与异常数据。
|
||||
type Checker struct {
|
||||
db *gorm.DB
|
||||
schema string
|
||||
}
|
||||
|
||||
// NewChecker 创建公共发布门禁检查器。
|
||||
func NewChecker(db *gorm.DB) *Checker {
|
||||
return &Checker{db: db, schema: "public"}
|
||||
}
|
||||
|
||||
// NewCheckerWithSchema 创建隔离 schema 的迁移验收检查器。
|
||||
func NewCheckerWithSchema(db *gorm.DB, schema string) *Checker {
|
||||
if strings.TrimSpace(schema) == "" {
|
||||
schema = "public"
|
||||
}
|
||||
return &Checker{db: db, schema: schema}
|
||||
}
|
||||
|
||||
type tableContract struct {
|
||||
name string
|
||||
columns map[string]string
|
||||
}
|
||||
|
||||
var publicContracts = []tableContract{
|
||||
{name: "tb_outbox_event", columns: map[string]string{
|
||||
"event_id": "character varying", "event_type": "character varying", "payload": "jsonb",
|
||||
"status": "integer", "retry_count": "integer", "next_attempt_at": "timestamp with time zone",
|
||||
"lease_owner": "character varying", "lease_expires_at": "timestamp with time zone",
|
||||
}},
|
||||
{name: "tb_system_config", columns: map[string]string{
|
||||
"config_key": "character varying", "config_value": "text", "value_type": "character varying",
|
||||
"module": "character varying", "is_readonly": "boolean", "is_sensitive": "boolean",
|
||||
}},
|
||||
}
|
||||
|
||||
// Run 执行指定阶段检查;检查只读且可重复运行。
|
||||
func (c *Checker) Run(ctx context.Context, phase string) (Report, error) {
|
||||
report := Report{Phase: phase, Findings: make([]Finding, 0)}
|
||||
if phase != PhasePre && phase != PhasePost {
|
||||
return report, stderrors.New("公共发布门禁阶段不受支持")
|
||||
}
|
||||
version, dirty, err := c.migrationVersion(ctx)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
minimumVersion := uint(164)
|
||||
if phase == PhasePost {
|
||||
minimumVersion = 166
|
||||
}
|
||||
if dirty || version < minimumVersion {
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "FOUNDATION_DEPENDENCY_VERSION", Severity: SeverityBlock,
|
||||
Object: "schema_migrations", Count: int64(version), Summary: "迁移依赖版本未满足或数据库处于脏状态",
|
||||
})
|
||||
}
|
||||
|
||||
for _, contract := range publicContracts {
|
||||
exists, err := c.tableExists(ctx, contract.name)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if !exists {
|
||||
severity := SeverityInfo
|
||||
if phase == PhasePost {
|
||||
severity = SeverityBlock
|
||||
}
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "FOUNDATION_OBJECT_MISSING", Severity: severity, Object: contract.name,
|
||||
Summary: "公共对象尚不存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
definitionFindings, err := c.checkColumns(ctx, contract)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Findings = append(report.Findings, definitionFindings...)
|
||||
}
|
||||
if phase == PhasePost {
|
||||
objectFindings, err := c.checkIndexesAndConstraints(ctx)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Findings = append(report.Findings, objectFindings...)
|
||||
if err := c.smokeWrite(ctx); err != nil {
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "FOUNDATION_READ_WRITE_SMOKE_FAILED", Severity: SeverityBlock,
|
||||
Object: "tech-public-foundation", Count: 1, Summary: "公共对象读写冒烟失败",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
anomalyFindings, err := c.checkAnomalies(ctx)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Findings = append(report.Findings, anomalyFindings...)
|
||||
sort.Slice(report.Findings, func(i, j int) bool {
|
||||
if report.Findings[i].Object == report.Findings[j].Object {
|
||||
return report.Findings[i].Code < report.Findings[j].Code
|
||||
}
|
||||
return report.Findings[i].Object < report.Findings[j].Object
|
||||
})
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (c *Checker) migrationVersion(ctx context.Context) (uint, bool, error) {
|
||||
var result struct {
|
||||
Version uint `gorm:"column:version"`
|
||||
Dirty bool `gorm:"column:dirty"`
|
||||
}
|
||||
err := c.db.WithContext(ctx).Raw("SELECT version, dirty FROM schema_migrations LIMIT 1").Scan(&result).Error
|
||||
return result.Version, result.Dirty, err
|
||||
}
|
||||
|
||||
func (c *Checker) tableExists(ctx context.Context, table string) (bool, error) {
|
||||
var exists bool
|
||||
err := c.db.WithContext(ctx).Raw("SELECT to_regclass(?) IS NOT NULL", c.schema+"."+table).Scan(&exists).Error
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (c *Checker) checkColumns(ctx context.Context, contract tableContract) ([]Finding, error) {
|
||||
var rows []struct {
|
||||
Name string `gorm:"column:column_name"`
|
||||
Type string `gorm:"column:data_type"`
|
||||
}
|
||||
err := c.db.WithContext(ctx).Raw(`
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = ? AND table_name = ?`, c.schema, contract.name).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual := make(map[string]string, len(rows))
|
||||
for _, row := range rows {
|
||||
actual[row.Name] = row.Type
|
||||
}
|
||||
findings := make([]Finding, 0)
|
||||
for column, expectedType := range contract.columns {
|
||||
actualType, exists := actual[column]
|
||||
if !exists || !strings.EqualFold(actualType, expectedType) {
|
||||
findings = append(findings, Finding{
|
||||
Code: "FOUNDATION_DEFINITION_MISMATCH", Severity: SeverityBlock,
|
||||
Object: contract.name + "." + column, Count: 1, Summary: "公共对象字段定义不符合契约",
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (c *Checker) checkAnomalies(ctx context.Context) ([]Finding, error) {
|
||||
checks := []struct {
|
||||
table string
|
||||
code string
|
||||
summary string
|
||||
query string
|
||||
}{
|
||||
{"tb_outbox_event", "OUTBOX_DUPLICATE_EVENT_ID", "存在重复事件 ID", `SELECT COUNT(*) FROM (SELECT event_id FROM tb_outbox_event GROUP BY event_id HAVING COUNT(*) > 1) AS conflicts`},
|
||||
{"tb_outbox_event", "OUTBOX_INVALID_REQUIRED_FIELD", "存在必填字段空值", `SELECT COUNT(*) FROM tb_outbox_event WHERE event_id IS NULL OR event_type IS NULL OR payload IS NULL`},
|
||||
{"tb_outbox_event", "OUTBOX_INVALID_STATUS", "存在非法投递状态", `SELECT COUNT(*) FROM tb_outbox_event WHERE status NOT IN (1,2,3,4)`},
|
||||
{"tb_outbox_event", "OUTBOX_UNDELIVERED", "存在未投递事件", `SELECT COUNT(*) FROM tb_outbox_event WHERE status IN (1,2,4)`},
|
||||
{"tb_outbox_event", "OUTBOX_EXPIRED_LEASE", "存在过期租约", `SELECT COUNT(*) FROM tb_outbox_event WHERE status = 2 AND lease_expires_at < NOW()`},
|
||||
{"tb_system_config", "SYSTEM_CONFIG_DUPLICATE_KEY", "存在重复配置 Key", `SELECT COUNT(*) FROM (SELECT config_key FROM tb_system_config GROUP BY config_key HAVING COUNT(*) > 1) AS conflicts`},
|
||||
{"tb_system_config", "SYSTEM_CONFIG_INVALID_REQUIRED_FIELD", "存在配置必填字段空值", `SELECT COUNT(*) FROM tb_system_config WHERE config_key IS NULL OR config_value IS NULL OR value_type IS NULL OR module IS NULL`},
|
||||
{"tb_system_config", "SYSTEM_CONFIG_INVALID_TYPE", "存在非法配置类型", `SELECT COUNT(*) FROM tb_system_config WHERE value_type NOT IN ('string','int','bool','json')`},
|
||||
}
|
||||
findings := make([]Finding, 0, len(checks))
|
||||
for _, check := range checks {
|
||||
exists, err := c.tableExists(ctx, check.table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Raw(check.query).Scan(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: check.code, Severity: SeverityBlock, Object: check.table,
|
||||
Count: count, Summary: check.summary,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"tb_export_task", "tb_iot_card_import_task", "tb_device_import_task", "tb_order_package_invalidate_task"} {
|
||||
exists, err := c.tableExists(ctx, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Table(table).Where("status IN ?", []int{1, 2}).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
findings = append(findings, Finding{
|
||||
Code: "ASYNC_TASK_UNFINISHED", Severity: SeverityBlock, Object: table,
|
||||
Count: count, Summary: "存在待处理或处理中任务",
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (c *Checker) checkIndexesAndConstraints(ctx context.Context) ([]Finding, error) {
|
||||
expectedConstraints := map[string][]string{
|
||||
"tb_outbox_event": {"uq_outbox_event_id", "ck_outbox_event_status", "ck_outbox_event_payload_version", "ck_outbox_event_retry_count"},
|
||||
"tb_system_config": {"uq_system_config_key", "ck_system_config_value_type"},
|
||||
}
|
||||
expectedIndexes := map[string][]string{
|
||||
"tb_outbox_event": {"idx_outbox_event_claim", "idx_outbox_event_type_status", "idx_outbox_event_aggregate", "idx_outbox_event_resource", "idx_outbox_event_request_id", "idx_outbox_event_correlation_id"},
|
||||
"tb_system_config": {"idx_system_config_module_key"},
|
||||
}
|
||||
findings := make([]Finding, 0)
|
||||
for table, names := range expectedConstraints {
|
||||
for _, name := range names {
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema = ? AND table_name = ? AND constraint_name = ?`, c.schema, table, name).Scan(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
findings = append(findings, Finding{Code: "FOUNDATION_CONSTRAINT_MISSING", Severity: SeverityBlock, Object: table + "." + name, Count: count, Summary: "公共约束缺失或重复"})
|
||||
}
|
||||
}
|
||||
}
|
||||
for table, names := range expectedIndexes {
|
||||
for _, name := range names {
|
||||
var count int64
|
||||
if err := c.db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM pg_indexes
|
||||
WHERE schemaname = ? AND tablename = ? AND indexname = ?`, c.schema, table, name).Scan(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count != 1 {
|
||||
findings = append(findings, Finding{Code: "FOUNDATION_INDEX_MISSING", Severity: SeverityBlock, Object: table + "." + name, Count: count, Summary: "公共索引缺失或重复"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
var errSmokeRollback = stderrors.New("公共对象冒烟回滚")
|
||||
|
||||
func (c *Checker) smokeWrite(ctx context.Context) error {
|
||||
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
eventID := "smoke-" + uuid.NewString()
|
||||
if err := tx.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload)
|
||||
VALUES (?, 'foundation.smoke', 1, 'foundation', 'smoke', 'foundation', 'smoke', '{}'::jsonb)`, eventID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
configKey := "foundation.smoke." + uuid.NewString()
|
||||
if err := tx.Exec(`INSERT INTO tb_system_config
|
||||
(config_key, config_value, value_type, module, description)
|
||||
VALUES (?, 'true', 'bool', 'foundation', '公共对象读写冒烟')`, configKey).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return errSmokeRollback
|
||||
})
|
||||
if stderrors.Is(err, errSmokeRollback) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
20
internal/infrastructure/releasegate/checker_test.go
Normal file
20
internal/infrastructure/releasegate/checker_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package releasegate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestReportBlocksOnlyOnBlockingFindings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
report := Report{Phase: PhasePre, Findings: []Finding{{
|
||||
Code: "FOUNDATION_OBJECT_MISSING", Severity: SeverityInfo, Object: "tb_outbox_event",
|
||||
}}}
|
||||
if !report.Passed() {
|
||||
t.Fatal("迁移前公共对象尚未创建不应单独阻断发布")
|
||||
}
|
||||
report.Findings = append(report.Findings, Finding{
|
||||
Code: "OUTBOX_UNDELIVERED", Severity: SeverityBlock, Object: "tb_outbox_event", Count: 1,
|
||||
})
|
||||
if report.Passed() {
|
||||
t.Fatal("存在未投递事件时必须阻断发布")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package releasegate_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/releasegate"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
)
|
||||
|
||||
func TestPublicFoundationMigrationsOnEmptyAndCompatibleDatabase(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 164)
|
||||
checker := releasegate.NewCheckerWithSchema(db, schema)
|
||||
|
||||
pre, err := checker.Run(context.Background(), releasegate.PhasePre)
|
||||
if err != nil {
|
||||
t.Fatalf("执行迁移前检查失败:%v", err)
|
||||
}
|
||||
if !pre.Passed() {
|
||||
t.Fatalf("空数据库迁移前检查不应阻断:%+v", pre.Findings)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE compatible_existing_data (id bigint PRIMARY KEY, value text NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("准备兼容存量表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO compatible_existing_data (id, value) VALUES (1, 'keep')").Error; err != nil {
|
||||
t.Fatalf("准备兼容存量数据失败:%v", err)
|
||||
}
|
||||
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec("UPDATE schema_migrations SET version = 166, dirty = false").Error; err != nil {
|
||||
t.Fatalf("更新隔离迁移版本失败:%v", err)
|
||||
}
|
||||
|
||||
post, err := checker.Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil {
|
||||
t.Fatalf("执行迁移后检查失败:%v", err)
|
||||
}
|
||||
if !post.Passed() {
|
||||
t.Fatalf("迁移后检查应通过:%+v", post.Findings)
|
||||
}
|
||||
postAgain, err := checker.Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil || !postAgain.Passed() {
|
||||
t.Fatalf("迁移后检查必须可重复执行:%v,结果:%+v", err, postAgain.Findings)
|
||||
}
|
||||
var compatibleCount int64
|
||||
if err := db.Table("compatible_existing_data").Count(&compatibleCount).Error; err != nil || compatibleCount != 1 {
|
||||
t.Fatalf("迁移破坏了兼容存量数据:%v,数量:%d", err, compatibleCount)
|
||||
}
|
||||
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return executeMigrationWithError(tx, "000165_create_public_outbox.up.sql")
|
||||
}); err == nil {
|
||||
t.Fatal("公共对象已创建时不得通过重复 DDL 静默掩盖定义")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationGateBlocksAnomaliesWithoutDestructiveWrites(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload)
|
||||
VALUES ('blocked-event', 'foundation.blocked', 1, 'example', '1', 'example', '1', '{}'::jsonb)`).Error; err != nil {
|
||||
t.Fatalf("准备未投递异常失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE tb_outbox_event DROP CONSTRAINT ck_outbox_event_status").Error; err != nil {
|
||||
t.Fatalf("准备非法状态定义失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(`INSERT INTO tb_outbox_event
|
||||
(event_id, event_type, payload_version, aggregate_type, aggregate_id, resource_type, resource_id, payload, status)
|
||||
VALUES ('invalid-status', 'foundation.invalid', 1, 'example', '2', 'example', '2', '{}'::jsonb, 9)`).Error; err != nil {
|
||||
t.Fatalf("准备非法状态数据失败:%v", err)
|
||||
}
|
||||
|
||||
report, err := releasegate.NewCheckerWithSchema(db, schema).Run(context.Background(), releasegate.PhasePost)
|
||||
if err != nil {
|
||||
t.Fatalf("执行异常门禁失败:%v", err)
|
||||
}
|
||||
if report.Passed() || !hasFinding(report, "OUTBOX_UNDELIVERED") || !hasFinding(report, "OUTBOX_INVALID_STATUS") || !hasFinding(report, "FOUNDATION_CONSTRAINT_MISSING") {
|
||||
t.Fatalf("异常数据和定义未被完整阻断:%+v", report.Findings)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tb_outbox_event").Count(&count).Error; err != nil || count != 2 {
|
||||
t.Fatalf("只读门禁修改了异常数据:%v,数量:%d", err, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownMigrationAllowsEmptyStructuresAndRejectsExistingFacts(t *testing.T) {
|
||||
t.Run("空结构可回滚", func(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
schema := prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.down.sql")
|
||||
executeMigration(t, db, "000165_create_public_outbox.down.sql")
|
||||
for _, table := range []string{"tb_outbox_event", "tb_system_config"} {
|
||||
var exists bool
|
||||
if err := db.Raw("SELECT to_regclass(?) IS NOT NULL", schema+"."+table).Scan(&exists).Error; err != nil || exists {
|
||||
t.Fatalf("空结构回滚失败:%s,错误:%v", table, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("已有事实拒绝删表", func(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
_ = prepareMigrationSchema(t, db, 166)
|
||||
executeMigration(t, db, "000165_create_public_outbox.up.sql")
|
||||
executeMigration(t, db, "000166_create_system_config.up.sql")
|
||||
if err := db.Exec(`INSERT INTO tb_system_config
|
||||
(config_key, config_value, value_type, module, description)
|
||||
VALUES ('foundation.test.fact', 'true', 'bool', 'foundation', '回滚保护测试')`).Error; err != nil {
|
||||
t.Fatalf("准备配置事实失败:%v", err)
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
return executeMigrationWithError(tx, "000166_create_system_config.down.sql")
|
||||
}); err == nil || !strings.Contains(err.Error(), "禁止删表回滚") {
|
||||
t.Fatalf("已有事实时 down 迁移必须明确拒绝:%v", err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tb_system_config").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("拒绝回滚后配置事实丢失:%v,数量:%d", err, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func prepareMigrationSchema(t *testing.T, db *gorm.DB, version uint) string {
|
||||
t.Helper()
|
||||
schema := "foundation_test_" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
if err := db.Exec(fmt.Sprintf(`CREATE SCHEMA %s`, schema)).Error; err != nil {
|
||||
t.Fatalf("创建隔离迁移 schema 失败:%v", err)
|
||||
}
|
||||
if err := db.Exec(fmt.Sprintf(`SET LOCAL search_path TO %s`, schema)).Error; err != nil {
|
||||
t.Fatalf("切换隔离迁移 schema 失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("CREATE TABLE schema_migrations (version bigint NOT NULL, dirty boolean NOT NULL)").Error; err != nil {
|
||||
t.Fatalf("创建隔离迁移版本表失败:%v", err)
|
||||
}
|
||||
if err := db.Exec("INSERT INTO schema_migrations (version, dirty) VALUES (?, false)", version).Error; err != nil {
|
||||
t.Fatalf("写入隔离迁移版本失败:%v", err)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
func executeMigration(t *testing.T, db *gorm.DB, name string) {
|
||||
t.Helper()
|
||||
if err := executeMigrationWithError(db, name); err != nil {
|
||||
t.Fatalf("执行迁移 %s 失败:%v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeMigrationWithError(db *gorm.DB, name string) error {
|
||||
path := filepath.Join("..", "..", "..", "migrations", name)
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range splitSQLStatements(string(content)) {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatements(content string) []string {
|
||||
statements := make([]string, 0)
|
||||
start := 0
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
inDollarBlock := false
|
||||
for index := 0; index < len(content); index++ {
|
||||
if index+1 < len(content) && content[index:index+2] == "$$" && !inSingleQuote && !inDoubleQuote {
|
||||
inDollarBlock = !inDollarBlock
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if inDollarBlock {
|
||||
continue
|
||||
}
|
||||
switch content[index] {
|
||||
case '\'':
|
||||
if !inDoubleQuote {
|
||||
inSingleQuote = !inSingleQuote
|
||||
}
|
||||
case '"':
|
||||
if !inSingleQuote {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
}
|
||||
case ';':
|
||||
if !inSingleQuote && !inDoubleQuote {
|
||||
statement := strings.TrimSpace(content[start : index+1])
|
||||
if statement != "" {
|
||||
statements = append(statements, statement)
|
||||
}
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if tail := strings.TrimSpace(content[start:]); tail != "" {
|
||||
statements = append(statements, tail)
|
||||
}
|
||||
return statements
|
||||
}
|
||||
|
||||
func hasFinding(report releasegate.Report, code string) bool {
|
||||
for _, finding := range report.Findings {
|
||||
if finding.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
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 必须被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ type ListExportTaskRequest struct {
|
||||
// ExportTaskItem 导出任务列表项。
|
||||
type ExportTaskItem struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskID uint `json:"task_id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Scene string `json:"scene" description:"导出场景 (device:设备, iot_card:IoT卡, order:订单)"`
|
||||
Format string `json:"format" description:"导出格式 (xlsx:Excel, csv:CSV)"`
|
||||
@@ -42,10 +43,16 @@ type ExportTaskItem struct {
|
||||
TotalShards int `json:"total_shards" description:"总分片数"`
|
||||
SuccessShards int `json:"success_shards" description:"成功分片数"`
|
||||
FailedShards int `json:"failed_shards" description:"失败分片数"`
|
||||
TotalCount int `json:"total_count" description:"统一任务总数"`
|
||||
SuccessCount int `json:"success_count" description:"统一任务成功数"`
|
||||
FailedCount int `json:"failed_count" description:"统一任务失败数"`
|
||||
CancelRequested bool `json:"cancel_requested" description:"是否已请求取消"`
|
||||
FileKey string `json:"file_key,omitempty" description:"导出文件Key"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
|
||||
ErrorCode string `json:"error_code,omitempty" description:"安全错误码"`
|
||||
ErrorSummary string `json:"error_summary,omitempty" description:"安全失败摘要"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
CreatorUserID uint `json:"creator_user_id" description:"创建人用户ID"`
|
||||
|
||||
46
internal/model/dto/system_config_dto.go
Normal file
46
internal/model/dto/system_config_dto.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemConfigListRequest 是系统配置分页查询参数。
|
||||
type SystemConfigListRequest struct {
|
||||
Module string `json:"module" query:"module" validate:"omitempty,max=100" description:"模块筛选"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"`
|
||||
}
|
||||
|
||||
// SystemConfigItem 是超级管理员可见的受控配置投影。
|
||||
type SystemConfigItem struct {
|
||||
ConfigKey string `json:"config_key" description:"稳定配置 Key"`
|
||||
Value string `json:"value" description:"配置值;敏感值按注册策略脱敏"`
|
||||
ValueType string `json:"value_type" description:"值类型 (string:字符串, int:整数, bool:布尔, json:JSON)"`
|
||||
Module string `json:"module" description:"所属模块"`
|
||||
Description string `json:"description" description:"中文说明"`
|
||||
Readonly bool `json:"readonly" description:"是否只读"`
|
||||
Sensitive bool `json:"sensitive" description:"是否敏感"`
|
||||
Registered bool `json:"registered" description:"是否已在代码注册"`
|
||||
Control string `json:"control" description:"前端控件提示"`
|
||||
EnumValues []string `json:"enum_values,omitempty" description:"允许的枚举值"`
|
||||
Min *int64 `json:"min,omitempty" description:"整数最小值"`
|
||||
Max *int64 `json:"max,omitempty" description:"整数最大值"`
|
||||
UpdatedAt *time.Time `json:"updated_at,omitempty" description:"最近更新时间"`
|
||||
}
|
||||
|
||||
// SystemConfigListResponse 是系统配置分页结果。
|
||||
type SystemConfigListResponse struct {
|
||||
List []SystemConfigItem `json:"list" description:"配置列表"`
|
||||
Total int64 `json:"total" description:"总数量"`
|
||||
Page int `json:"page" description:"页码"`
|
||||
PageSize int `json:"page_size" description:"每页数量"`
|
||||
}
|
||||
|
||||
// UpdateSystemConfigRequest 是单 Key 更新请求。
|
||||
type UpdateSystemConfigRequest struct {
|
||||
Value string `json:"value" validate:"required" required:"true" description:"字符串化配置值"`
|
||||
}
|
||||
|
||||
// UpdateSystemConfigParams 组合路径和请求体文档参数。
|
||||
type UpdateSystemConfigParams struct {
|
||||
Key string `json:"key" path:"key" validate:"required" required:"true" description:"稳定配置 Key"`
|
||||
UpdateSystemConfigRequest
|
||||
}
|
||||
39
internal/model/outbox_event.go
Normal file
39
internal/model/outbox_event.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// OutboxEvent 是跨进程可靠事件的权威公共持久化模型。
|
||||
type OutboxEvent struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_outbox_event_id" json:"event_id"`
|
||||
EventType string `gorm:"column:event_type;type:varchar(150);not null;index:idx_outbox_event_type_status,priority:1" json:"event_type"`
|
||||
PayloadVersion int `gorm:"column:payload_version;type:int;not null;default:1" json:"payload_version"`
|
||||
AggregateType string `gorm:"column:aggregate_type;type:varchar(100);not null" json:"aggregate_type"`
|
||||
AggregateID string `gorm:"column:aggregate_id;type:varchar(100);not null" json:"aggregate_id"`
|
||||
ResourceType string `gorm:"column:resource_type;type:varchar(100);not null" json:"resource_type"`
|
||||
ResourceID string `gorm:"column:resource_id;type:varchar(100);not null" json:"resource_id"`
|
||||
BusinessKey string `gorm:"column:business_key;type:varchar(150);not null;default:''" json:"business_key,omitempty"`
|
||||
RequestID string `gorm:"column:request_id;type:varchar(100);not null;default:'';index" json:"request_id,omitempty"`
|
||||
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;default:'';index" json:"correlation_id,omitempty"`
|
||||
Payload datatypes.JSON `gorm:"column:payload;type:jsonb;not null" json:"payload"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_outbox_event_type_status,priority:2" json:"status"`
|
||||
RetryCount int `gorm:"column:retry_count;type:int;not null;default:0" json:"retry_count"`
|
||||
MaxRetries int `gorm:"column:max_retries;type:int;not null;default:10" json:"max_retries"`
|
||||
NextAttemptAt time.Time `gorm:"column:next_attempt_at;type:timestamptz;not null;index:idx_outbox_event_claim,priority:2" json:"next_attempt_at"`
|
||||
LeaseOwner *string `gorm:"column:lease_owner;type:varchar(100)" json:"lease_owner,omitempty"`
|
||||
LeaseExpiresAt *time.Time `gorm:"column:lease_expires_at;type:timestamptz;index:idx_outbox_event_claim,priority:3" json:"lease_expires_at,omitempty"`
|
||||
LastErrorCode string `gorm:"column:last_error_code;type:varchar(100);not null;default:''" json:"last_error_code,omitempty"`
|
||||
LastErrorSummary string `gorm:"column:last_error_summary;type:varchar(500);not null;default:''" json:"last_error_summary,omitempty"`
|
||||
DeliveredAt *time.Time `gorm:"column:delivered_at;type:timestamptz" json:"delivered_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime;index:idx_outbox_event_claim,priority:4" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 返回公共 Outbox 表名。
|
||||
func (OutboxEvent) TableName() string {
|
||||
return "tb_outbox_event"
|
||||
}
|
||||
24
internal/model/system_config.go
Normal file
24
internal/model/system_config.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemConfig 是受控系统配置的 PostgreSQL 持久化模型。
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
ConfigKey string `gorm:"column:config_key;type:varchar(150);not null;uniqueIndex:uq_system_config_key" json:"config_key"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null" json:"config_value"`
|
||||
ValueType string `gorm:"column:value_type;type:varchar(20);not null" json:"value_type"`
|
||||
Module string `gorm:"column:module;type:varchar(100);not null;index:idx_system_config_module_key,priority:1" json:"module"`
|
||||
Description string `gorm:"column:description;type:varchar(500);not null" json:"description"`
|
||||
IsReadonly bool `gorm:"column:is_readonly;type:boolean;not null;default:false" json:"is_readonly"`
|
||||
IsSensitive bool `gorm:"column:is_sensitive;type:boolean;not null;default:false" json:"is_sensitive"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 返回受控系统配置表名。
|
||||
func (SystemConfig) TableName() string {
|
||||
return "tb_system_config"
|
||||
}
|
||||
143
internal/query/outbox/metrics.go
Normal file
143
internal/query/outbox/metrics.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// Package outbox 提供公共 Outbox 的只读运行状态投影。
|
||||
package outbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
// EventTypeBacklog 表示某事件类型的未投递积压。
|
||||
type EventTypeBacklog struct {
|
||||
EventType string `json:"event_type"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// RetryBucket 表示某重试次数上的事件数量。
|
||||
type RetryBucket struct {
|
||||
RetryCount int `json:"retry_count"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// Metrics 是 Outbox 运维公开指标投影。
|
||||
type Metrics struct {
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
OldestPendingAgeSecs int64 `json:"oldest_pending_age_seconds"`
|
||||
DeliveringCount int64 `json:"delivering_count"`
|
||||
ExpiredLeaseCount int64 `json:"expired_lease_count"`
|
||||
DeliveredInWindow int64 `json:"delivered_in_window"`
|
||||
FinalFailedCount int64 `json:"final_failed_count"`
|
||||
DeliverySuccessRate float64 `json:"delivery_success_rate"`
|
||||
RetryDistribution []RetryBucket `json:"retry_distribution"`
|
||||
BacklogByEventType []EventTypeBacklog `json:"backlog_by_event_type"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
}
|
||||
|
||||
// Query 查询 PostgreSQL 中的 Outbox 运行事实。
|
||||
type Query struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewQuery 创建 Outbox 指标查询。
|
||||
func NewQuery(db *gorm.DB, now func() time.Time) *Query {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Query{db: db, now: now}
|
||||
}
|
||||
|
||||
// GetMetrics 查询指定统计窗口的积压、成功率、重试和最终失败。
|
||||
func (q *Query) GetMetrics(ctx context.Context, window time.Duration) (Metrics, error) {
|
||||
now := q.now().UTC()
|
||||
if window <= 0 {
|
||||
window = time.Hour
|
||||
}
|
||||
metrics := Metrics{ObservedAt: now, RetryDistribution: []RetryBucket{}, BacklogByEventType: []EventTypeBacklog{}}
|
||||
base := func() *gorm.DB { return q.db.WithContext(ctx).Model(&model.OutboxEvent{}) }
|
||||
if err := base().Where("status = ?", constants.OutboxStatusPending).Count(&metrics.PendingCount).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
if err := base().Where("status = ?", constants.OutboxStatusDelivering).Count(&metrics.DeliveringCount).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
if err := base().Where("status = ? AND lease_expires_at <= ?", constants.OutboxStatusDelivering, now).
|
||||
Count(&metrics.ExpiredLeaseCount).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
if err := base().Where("status = ?", constants.OutboxStatusFailed).Count(&metrics.FinalFailedCount).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
var oldestEvent model.OutboxEvent
|
||||
err := base().Select("created_at").Where("status = ?", constants.OutboxStatusPending).
|
||||
Order("created_at ASC").Take(&oldestEvent).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return metrics, err
|
||||
}
|
||||
if err == nil && oldestEvent.CreatedAt.Before(now) {
|
||||
metrics.OldestPendingAgeSecs = int64(now.Sub(oldestEvent.CreatedAt).Seconds())
|
||||
}
|
||||
windowStart := now.Add(-window)
|
||||
if err := base().Where("status = ? AND delivered_at >= ?", constants.OutboxStatusDelivered, windowStart).
|
||||
Count(&metrics.DeliveredInWindow).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
var failedInWindow int64
|
||||
if err := base().Where("status = ? AND updated_at >= ?", constants.OutboxStatusFailed, windowStart).
|
||||
Count(&failedInWindow).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
totalTerminal := metrics.DeliveredInWindow + failedInWindow
|
||||
if totalTerminal > 0 {
|
||||
metrics.DeliverySuccessRate = float64(metrics.DeliveredInWindow) / float64(totalTerminal)
|
||||
}
|
||||
if err := base().Select("retry_count, COUNT(*) AS count").Where("retry_count > 0").
|
||||
Group("retry_count").Order("retry_count ASC").Scan(&metrics.RetryDistribution).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
if err := base().Select("event_type, COUNT(*) AS count").Where("status IN ?", []int{
|
||||
constants.OutboxStatusPending, constants.OutboxStatusDelivering, constants.OutboxStatusFailed,
|
||||
}).Group("event_type").Order("event_type ASC").Scan(&metrics.BacklogByEventType).Error; err != nil {
|
||||
return metrics, err
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// Thresholds 是不包含高基数字段的 Outbox 告警阈值。
|
||||
type Thresholds struct {
|
||||
PendingCount int64
|
||||
OldestPendingAge time.Duration
|
||||
ExpiredLeaseCount int64
|
||||
FinalFailedCount int64
|
||||
}
|
||||
|
||||
// Alert 是可交给现有告警通道的中文安全摘要。
|
||||
type Alert struct {
|
||||
Code string `json:"code"`
|
||||
Component string `json:"component"`
|
||||
Count int64 `json:"count"`
|
||||
Summary string `json:"summary"`
|
||||
Window string `json:"window"`
|
||||
}
|
||||
|
||||
// EvaluateAlerts 根据聚合指标生成低基数告警,不包含 payload 或敏感配置。
|
||||
func EvaluateAlerts(metrics Metrics, thresholds Thresholds) []Alert {
|
||||
alerts := make([]Alert, 0, 4)
|
||||
if thresholds.PendingCount > 0 && metrics.PendingCount >= thresholds.PendingCount {
|
||||
alerts = append(alerts, Alert{Code: "OUTBOX_PENDING_HIGH", Component: "outbox", Count: metrics.PendingCount, Summary: "Outbox 待投递事件持续积压", Window: "当前快照"})
|
||||
}
|
||||
if thresholds.OldestPendingAge > 0 && metrics.OldestPendingAgeSecs >= int64(thresholds.OldestPendingAge.Seconds()) {
|
||||
alerts = append(alerts, Alert{Code: "OUTBOX_OLDEST_PENDING_HIGH", Component: "outbox", Count: metrics.OldestPendingAgeSecs, Summary: "Outbox 最老待投递事件超过阈值", Window: "秒"})
|
||||
}
|
||||
if thresholds.ExpiredLeaseCount > 0 && metrics.ExpiredLeaseCount >= thresholds.ExpiredLeaseCount {
|
||||
alerts = append(alerts, Alert{Code: "OUTBOX_EXPIRED_LEASE_HIGH", Component: "outbox", Count: metrics.ExpiredLeaseCount, Summary: "Outbox 过期租约数量超过阈值", Window: "当前快照"})
|
||||
}
|
||||
if thresholds.FinalFailedCount > 0 && metrics.FinalFailedCount >= thresholds.FinalFailedCount {
|
||||
alerts = append(alerts, Alert{Code: "OUTBOX_FINAL_FAILED_HIGH", Component: "outbox", Count: metrics.FinalFailedCount, Summary: "Outbox 最终失败事件需要人工处理", Window: "当前快照"})
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
73
internal/query/outbox/metrics_integration_test.go
Normal file
73
internal/query/outbox/metrics_integration_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package outbox_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
outboxquery "github.com/break/junhong_cmp_fiber/internal/query/outbox"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestMetricsCoverBacklogRetriesFailuresAndThresholdAlerts(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
testutil.CreateTemporaryOutboxTable(t, db)
|
||||
repository := outbox.NewRepository()
|
||||
now := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
states := []struct {
|
||||
id string
|
||||
eventType string
|
||||
status int
|
||||
retries int
|
||||
}{
|
||||
{id: "metrics-pending", eventType: "example.a", status: constants.OutboxStatusPending},
|
||||
{id: "metrics-delivering", eventType: "example.a", status: constants.OutboxStatusDelivering, retries: 1},
|
||||
{id: "metrics-delivered", eventType: "example.b", status: constants.OutboxStatusDelivered},
|
||||
{id: "metrics-failed", eventType: "example.b", status: constants.OutboxStatusFailed, retries: 3},
|
||||
}
|
||||
for _, state := range states {
|
||||
event, err := repository.Append(context.Background(), db, outbox.Envelope{
|
||||
EventID: state.id, EventType: state.eventType, AggregateType: "example", AggregateID: state.id,
|
||||
ResourceType: "example", ResourceID: state.id, Payload: struct {
|
||||
Visible bool `json:"visible"`
|
||||
}{Visible: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备指标事件失败:%v", err)
|
||||
}
|
||||
updates := map[string]any{"status": state.status, "retry_count": state.retries, "created_at": now.Add(-2 * time.Minute), "updated_at": now}
|
||||
if state.status == constants.OutboxStatusDelivering {
|
||||
updates["lease_owner"] = "dead-worker"
|
||||
updates["lease_expires_at"] = now.Add(-time.Minute)
|
||||
}
|
||||
if state.status == constants.OutboxStatusDelivered {
|
||||
updates["delivered_at"] = now.Add(-time.Minute)
|
||||
}
|
||||
if err := db.Model(&model.OutboxEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil {
|
||||
t.Fatalf("设置指标事件状态失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
query := outboxquery.NewQuery(db, func() time.Time { return now })
|
||||
metrics, err := query.GetMetrics(context.Background(), time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("查询 Outbox 指标失败:%v", err)
|
||||
}
|
||||
if metrics.PendingCount != 1 || metrics.DeliveringCount != 1 || metrics.ExpiredLeaseCount != 1 ||
|
||||
metrics.DeliveredInWindow != 1 || metrics.FinalFailedCount != 1 || metrics.OldestPendingAgeSecs != 120 {
|
||||
t.Fatalf("Outbox 指标不完整:%+v", metrics)
|
||||
}
|
||||
if len(metrics.RetryDistribution) != 2 || len(metrics.BacklogByEventType) != 2 {
|
||||
t.Fatalf("重试分布或事件类型积压不完整:%+v", metrics)
|
||||
}
|
||||
alerts := outboxquery.EvaluateAlerts(metrics, outboxquery.Thresholds{
|
||||
PendingCount: 1, OldestPendingAge: time.Minute, ExpiredLeaseCount: 1, FinalFailedCount: 1,
|
||||
})
|
||||
if len(alerts) != 4 {
|
||||
t.Fatalf("阈值告警数量错误:%+v", alerts)
|
||||
}
|
||||
}
|
||||
73
internal/query/systemconfig/list.go
Normal file
73
internal/query/systemconfig/list.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package systemconfig 提供受控系统配置的超级管理员读取投影。
|
||||
package systemconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ListQuery 按模块分页查询代码注册配置和未注册遗留记录。
|
||||
type ListQuery struct {
|
||||
reader *systemconfig.Reader
|
||||
}
|
||||
|
||||
// NewListQuery 创建系统配置列表查询。
|
||||
func NewListQuery(reader *systemconfig.Reader) *ListQuery {
|
||||
return &ListQuery{reader: reader}
|
||||
}
|
||||
|
||||
// Execute 执行超级管理员系统配置查询。
|
||||
func (q *ListQuery) Execute(ctx context.Context, request dto.SystemConfigListRequest) (*dto.SystemConfigListResponse, error) {
|
||||
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
|
||||
return nil, errors.New(errors.CodeForbidden)
|
||||
}
|
||||
page := request.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
pageSize := request.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = constants.DefaultPageSize
|
||||
}
|
||||
if pageSize > constants.MaxPageSize {
|
||||
pageSize = constants.MaxPageSize
|
||||
}
|
||||
items, err := q.reader.List(ctx, request.Module)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询系统配置失败")
|
||||
}
|
||||
total := len(items)
|
||||
start := (page - 1) * pageSize
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
result := make([]dto.SystemConfigItem, 0, end-start)
|
||||
for _, item := range items[start:end] {
|
||||
value := item.Value
|
||||
if item.Definition.Sensitive && value != "" {
|
||||
value = "[已配置]"
|
||||
}
|
||||
projection := dto.SystemConfigItem{
|
||||
ConfigKey: item.Definition.Key, Value: value, ValueType: item.Definition.ValueType,
|
||||
Module: item.Definition.Module, Description: item.Definition.Description,
|
||||
Readonly: item.Definition.Readonly || !item.Registered, Sensitive: item.Definition.Sensitive,
|
||||
Registered: item.Registered, Control: item.Definition.Control,
|
||||
EnumValues: item.Definition.EnumValues, Min: item.Definition.Min, Max: item.Definition.Max,
|
||||
}
|
||||
if item.Record != nil {
|
||||
updatedAt := item.Record.UpdatedAt
|
||||
projection.UpdatedAt = &updatedAt
|
||||
}
|
||||
result = append(result, projection)
|
||||
}
|
||||
return &dto.SystemConfigListResponse{List: result, Total: int64(total), Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -279,6 +279,7 @@ func (s *Service) CancelTask(ctx context.Context, id uint) (*dto.CancelExportTas
|
||||
func toTaskItemDTO(task *model.ExportTask) *dto.ExportTaskItem {
|
||||
return &dto.ExportTaskItem{
|
||||
ID: task.ID,
|
||||
TaskID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Scene: task.Scene,
|
||||
Format: task.Format,
|
||||
@@ -290,10 +291,16 @@ func toTaskItemDTO(task *model.ExportTask) *dto.ExportTaskItem {
|
||||
TotalShards: task.TotalShards,
|
||||
SuccessShards: task.SuccessShards,
|
||||
FailedShards: task.FailedShards,
|
||||
TotalCount: task.TotalShards,
|
||||
SuccessCount: task.SuccessShards,
|
||||
FailedCount: task.FailedShards,
|
||||
CancelRequested: task.CancelRequested,
|
||||
FileKey: task.FileKey,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
ErrorCode: exportTaskErrorCode(task),
|
||||
ErrorSummary: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
StartedAt: task.StartedAt,
|
||||
CompletedAt: task.CompletedAt,
|
||||
CreatorUserID: task.CreatorUserID,
|
||||
@@ -302,3 +309,10 @@ func toTaskItemDTO(task *model.ExportTask) *dto.ExportTaskItem {
|
||||
CreatorEnterpriseID: task.CreatorEnterpriseID,
|
||||
}
|
||||
}
|
||||
|
||||
func exportTaskErrorCode(task *model.ExportTask) string {
|
||||
if task.ErrorMessage == "" {
|
||||
return ""
|
||||
}
|
||||
return "EXPORT_TASK_FAILED"
|
||||
}
|
||||
|
||||
27
internal/testutil/outbox.go
Normal file
27
internal/testutil/outbox.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateTemporaryOutboxTable 创建事务内自动回滚的公共 Outbox 测试表。
|
||||
func CreateTemporaryOutboxTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE tb_outbox_event (
|
||||
id bigserial PRIMARY KEY, event_id varchar(64) NOT NULL UNIQUE,
|
||||
event_type varchar(150) NOT NULL, payload_version integer NOT NULL,
|
||||
aggregate_type varchar(100) NOT NULL, aggregate_id varchar(100) NOT NULL,
|
||||
resource_type varchar(100) NOT NULL, resource_id varchar(100) NOT NULL,
|
||||
business_key varchar(150) NOT NULL DEFAULT '', request_id varchar(100) NOT NULL DEFAULT '',
|
||||
correlation_id varchar(100) NOT NULL DEFAULT '', payload jsonb NOT NULL,
|
||||
status integer NOT NULL, retry_count integer NOT NULL DEFAULT 0,
|
||||
max_retries integer NOT NULL, next_attempt_at timestamptz NOT NULL,
|
||||
lease_owner varchar(100), lease_expires_at timestamptz,
|
||||
last_error_code varchar(100) NOT NULL DEFAULT '', last_error_summary varchar(500) NOT NULL DEFAULT '',
|
||||
delivered_at timestamptz, created_at timestamptz NOT NULL DEFAULT NOW(), updated_at timestamptz NOT NULL DEFAULT NOW()
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建 Outbox 测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
22
internal/testutil/system_config.go
Normal file
22
internal/testutil/system_config.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateTemporarySystemConfigTable 创建事务内自动回滚的系统配置测试表。
|
||||
func CreateTemporarySystemConfigTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP 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()
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建系统配置测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user