实现七月迭代公共技术基础
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
|
||||
}
|
||||
Reference in New Issue
Block a user