实现七月迭代公共技术基础
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,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
}

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