实现七月迭代公共技术基础
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m20s

This commit is contained in:
2026-07-23 17:52:48 +09:00
parent f7c42252c0
commit 17782d5f8e
76 changed files with 5246 additions and 158 deletions

View 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
}

View 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)
}
}

View 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)
}

View File

@@ -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),
}
}

View 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
}

View File

@@ -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)
}
}

View 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
}

View 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("存在未投递事件时必须阻断发布")
}
}

View File

@@ -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
}

View 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
}

View 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)
}
}

View 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()
}

View 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 必须被拒绝")
}
}