// 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" "gorm.io/gorm/clause" "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) { return r.append(ctx, tx, envelope, false) } // AppendIdempotent 在稳定 EventID 重投时保持原事件,不重复创建事实。 func (r *Repository) AppendIdempotent(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) { return r.append(ctx, tx, envelope, true) } func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope, idempotent bool) (*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, } create := tx.WithContext(ctx) if idempotent { create = create.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true}) } if err := create.Create(event).Error; err != nil { return nil, err } return event, nil }