Files
lijiaoqiao/supply-api/internal/repository/outbox.go
2026-04-11 09:25:31 +08:00

344 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// OutboxStatus Outbox事件状态
type OutboxStatus string
const (
OutboxStatusPending OutboxStatus = "pending"
OutboxStatusProcessing OutboxStatus = "processing"
OutboxStatusCompleted OutboxStatus = "completed"
OutboxStatusFailed OutboxStatus = "failed"
OutboxStatusDeadLetter OutboxStatus = "dead_letter"
)
// OutboxEvent Outbox事件
type OutboxEvent struct {
ID int64 `json:"id"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
EventType string `json:"event_type"`
EventID string `json:"event_id"`
Payload json.RawMessage `json:"payload"`
Status OutboxStatus `json:"status"`
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
ErrorMessage string `json:"error_message,omitempty"`
CreatedAt time.Time `json:"created_at"`
ProcessedAt *time.Time `json:"processed_at,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
DeadLetterReason string `json:"dead_letter_reason,omitempty"`
Version int64 `json:"version"`
}
// OutboxDeadLetter 死信记录
type OutboxDeadLetter struct {
ID int64 `json:"id"`
OriginalEventID string `json:"original_event_id"`
OriginalAggregateType string `json:"original_aggregate_type"`
OriginalAggregateID string `json:"original_aggregate_id"`
EventType string `json:"event_type"`
Payload json.RawMessage `json:"payload"`
ErrorMessage string `json:"error_message,omitempty"`
RetryCount int `json:"retry_count"`
FirstFailedAt time.Time `json:"first_failed_at"`
DeadLetterAt time.Time `json:"dead_letter_at"`
Handled bool `json:"handled"`
HandledAt *time.Time `json:"handled_at,omitempty"`
HandlerNotes string `json:"handler_notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// OutboxRepository Outbox仓储
type OutboxRepository struct {
db outboxDB
}
type outboxDB interface {
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Begin(ctx context.Context) (pgx.Tx, error)
}
// NewOutboxRepository 创建Outbox仓储
func NewOutboxRepository(pool *pgxpool.Pool) *OutboxRepository {
return &OutboxRepository{db: pool}
}
// Create 创建Outbox事件
func (r *OutboxRepository) Create(ctx context.Context, event *OutboxEvent) error {
query := `
INSERT INTO supply_outbox (
aggregate_type, aggregate_id, event_type, event_id,
payload, status, retry_count, max_retries
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at
`
err := r.db.QueryRow(ctx, query,
event.AggregateType, event.AggregateID, event.EventType, event.EventID,
event.Payload, event.Status, event.RetryCount, event.MaxRetries,
).Scan(&event.ID, &event.CreatedAt)
if err != nil {
return fmt.Errorf("failed to create outbox event: %w", err)
}
return nil
}
// FetchAndLock 获取并锁定待处理事件使用FOR UPDATE SKIP LOCKED实现分布式锁
func (r *OutboxRepository) FetchAndLock(ctx context.Context, limit int) ([]*OutboxEvent, error) {
query := `
WITH claimed AS (
SELECT id
FROM supply_outbox
WHERE status IN ('pending', 'failed')
AND (next_retry_at IS NULL OR next_retry_at <= CURRENT_TIMESTAMP)
ORDER BY created_at ASC
LIMIT $1
FOR UPDATE SKIP LOCKED
)
UPDATE supply_outbox AS o
SET status = 'processing',
version = o.version + 1
FROM claimed
WHERE o.id = claimed.id
RETURNING o.id, o.aggregate_type, o.aggregate_id, o.event_type, o.event_id,
o.payload, o.status, o.retry_count, o.max_retries, o.error_message,
o.created_at, o.processed_at, o.next_retry_at, o.version
`
rows, err := r.db.Query(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("failed to fetch outbox events: %w", err)
}
defer rows.Close()
var events []*OutboxEvent
for rows.Next() {
event := &OutboxEvent{}
err := rows.Scan(
&event.ID, &event.AggregateType, &event.AggregateID, &event.EventType,
&event.EventID, &event.Payload, &event.Status, &event.RetryCount,
&event.MaxRetries, &event.ErrorMessage, &event.CreatedAt,
&event.ProcessedAt, &event.NextRetryAt, &event.Version,
)
if err != nil {
return nil, fmt.Errorf("failed to scan outbox event: %w", err)
}
events = append(events, event)
}
return events, nil
}
// MarkCompleted 标记事件为已完成
func (r *OutboxRepository) MarkCompleted(ctx context.Context, eventID string) error {
query := `
UPDATE supply_outbox SET
status = 'completed',
processed_at = CURRENT_TIMESTAMP,
version = version + 1
WHERE event_id = $1 AND status = 'processing'
`
result, err := r.db.Exec(ctx, query, eventID)
if err != nil {
return fmt.Errorf("failed to mark outbox event completed: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("event not found or not in processing status")
}
return nil
}
// MarkFailed 标记事件为失败(并更新重试信息)
func (r *OutboxRepository) MarkFailed(ctx context.Context, eventID string, errorMsg string, nextRetryAt *time.Time) error {
query := `
UPDATE supply_outbox SET
status = 'failed',
error_message = $2,
next_retry_at = $3,
version = version + 1
WHERE event_id = $1 AND status = 'processing'
`
result, err := r.db.Exec(ctx, query, eventID, errorMsg, nextRetryAt)
if err != nil {
return fmt.Errorf("failed to mark outbox event failed: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("event not found or not in processing status")
}
return nil
}
// MoveToDeadLetter 将事件移入死信队列
func (r *OutboxRepository) MoveToDeadLetter(ctx context.Context, event *OutboxEvent, errorMsg string) error {
tx, err := r.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
// 获取事件详情
selectQuery := `
SELECT id, aggregate_type, aggregate_id, event_type, event_id,
payload, retry_count, created_at
FROM supply_outbox
WHERE event_id = $1
FOR UPDATE
`
var originalEvent OutboxEvent
err = tx.QueryRow(ctx, selectQuery, event.EventID).Scan(
&originalEvent.ID, &originalEvent.AggregateType, &originalEvent.AggregateID,
&originalEvent.EventType, &originalEvent.EventID, &originalEvent.Payload,
&originalEvent.RetryCount, &originalEvent.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to get original event: %w", err)
}
// 插入死信记录
insertQuery := `
INSERT INTO supply_outbox_dead_letter (
original_event_id, original_aggregate_type, original_aggregate_id,
event_type, payload, error_message, retry_count, first_failed_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
_, err = tx.Exec(ctx, insertQuery,
originalEvent.EventID, originalEvent.AggregateType, originalEvent.AggregateID,
originalEvent.EventType, originalEvent.Payload, errorMsg,
originalEvent.RetryCount, originalEvent.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to insert dead letter: %w", err)
}
// 删除原始事件
deleteQuery := `DELETE FROM supply_outbox WHERE event_id = $1`
_, err = tx.Exec(ctx, deleteQuery, event.EventID)
if err != nil {
return fmt.Errorf("failed to delete original event: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
// GetDeadLetterByEventID 根据原始事件ID获取死信记录
func (r *OutboxRepository) GetDeadLetterByEventID(ctx context.Context, originalEventID string) (*OutboxDeadLetter, error) {
query := `
SELECT id, original_event_id, original_aggregate_type, original_aggregate_id,
event_type, payload, error_message, retry_count, first_failed_at,
dead_letter_at, handled, handled_at, handler_notes, created_at
FROM supply_outbox_dead_letter
WHERE original_event_id = $1
`
dl := &OutboxDeadLetter{}
err := r.db.QueryRow(ctx, query, originalEventID).Scan(
&dl.ID, &dl.OriginalEventID, &dl.OriginalAggregateType, &dl.OriginalAggregateID,
&dl.EventType, &dl.Payload, &dl.ErrorMessage, &dl.RetryCount,
&dl.FirstFailedAt, &dl.DeadLetterAt, &dl.Handled, &dl.HandledAt,
&dl.HandlerNotes, &dl.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get dead letter: %w", err)
}
return dl, nil
}
// ListUnhandledDeadLetters 列出未处理的死信记录
func (r *OutboxRepository) ListUnhandledDeadLetters(ctx context.Context, limit int) ([]*OutboxDeadLetter, error) {
query := `
SELECT id, original_event_id, original_aggregate_type, original_aggregate_id,
event_type, payload, error_message, retry_count, first_failed_at,
dead_letter_at, handled, handled_at, handler_notes, created_at
FROM supply_outbox_dead_letter
WHERE handled = FALSE
ORDER BY dead_letter_at ASC
LIMIT $1
`
rows, err := r.db.Query(ctx, query, limit)
if err != nil {
return nil, fmt.Errorf("failed to list dead letters: %w", err)
}
defer rows.Close()
var dls []*OutboxDeadLetter
for rows.Next() {
dl := &OutboxDeadLetter{}
err := rows.Scan(
&dl.ID, &dl.OriginalEventID, &dl.OriginalAggregateType, &dl.OriginalAggregateID,
&dl.EventType, &dl.Payload, &dl.ErrorMessage, &dl.RetryCount,
&dl.FirstFailedAt, &dl.DeadLetterAt, &dl.Handled, &dl.HandledAt,
&dl.HandlerNotes, &dl.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan dead letter: %w", err)
}
dls = append(dls, dl)
}
return dls, nil
}
// MarkDeadLetterHandled 标记死信已处理
func (r *OutboxRepository) MarkDeadLetterHandled(ctx context.Context, id int64, notes string) error {
query := `
UPDATE supply_outbox_dead_letter SET
handled = TRUE,
handled_at = CURRENT_TIMESTAMP,
handler_notes = $2
WHERE id = $1
`
_, err := r.db.Exec(ctx, query, id, notes)
if err != nil {
return fmt.Errorf("failed to mark dead letter handled: %w", err)
}
return nil
}
// DeleteCompleted 删除已完成的旧事件(定时清理)
func (r *OutboxRepository) DeleteCompleted(ctx context.Context, before time.Time) (int64, error) {
query := `DELETE FROM supply_outbox WHERE status = 'completed' AND processed_at < $1`
result, err := r.db.Exec(ctx, query, before)
if err != nil {
return 0, fmt.Errorf("failed to delete completed events: %w", err)
}
return result.RowsAffected(), nil
}