2026-07-13 23:38:32 +08:00

169 lines
6.5 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/outboxreplay"
"hyapp/pkg/rocketmqx"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/walletmq"
"hyapp/services/wallet-service/internal/config"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
)
// ReplayWalletOutboxOnce publishes at most scope.Limit claimable facts from
// exactly one App partition, then closes both MySQL and MQ. Each iteration uses
// the production claim/publish/mark path with batch size one, so a process
// timeout cannot leave a claimed tail waiting for lease expiry.
func ReplayWalletOutboxOnce(ctx context.Context, cfg config.Config, scope outboxreplay.Scope, eventTypes []string) (outboxreplay.Result, error) {
validatedScope, err := outboxreplay.NewScope(scope.AppCode, scope.Limit, 500)
if err != nil {
return outboxreplay.Result{}, err
}
includeEventTypes, err := ValidateWalletReplayEventTypes(cfg, eventTypes)
if err != nil {
return outboxreplay.Result{}, err
}
if !cfg.RocketMQ.WalletOutbox.Enabled {
return outboxreplay.Result{}, errors.New("wallet RocketMQ outbox is not enabled")
}
repository, err := mysqlstorage.Open(ctx, cfg.MySQLDSN)
if err != nil {
return outboxreplay.Result{}, err
}
defer func() { _ = repository.Close() }()
producer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.WalletOutbox.ProducerGroup))
if err != nil {
return outboxreplay.Result{}, err
}
if err := servicemq.StartProducers([]*rocketmqx.Producer{producer}); err != nil {
return outboxreplay.Result{}, err
}
defer servicemq.ShutdownProducers([]*rocketmqx.Producer{producer})
workerOptions := cfg.OutboxWorker
workerOptions.BatchSize = 1
workerOptions.Concurrency = 1
replayApp := &App{
mysqlRepo: repository,
walletEventTagMode: cfg.RocketMQ.WalletEventTagMode,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
outboxWorkerCfg: workerOptions,
}
workerID := fmt.Sprintf("ops-wallet-outbox-%s-%d", validatedScope.AppCode, time.Now().UTC().UnixMilli())
return outboxreplay.Run(ctx, validatedScope, func(runCtx context.Context) (int, error) {
return replayApp.replayOneWalletOutboxRecord(runCtx, workerID, workerOptions, producer, cfg.RocketMQ.WalletOutbox.Topic, validatedScope.AppCode, includeEventTypes)
})
}
func (a *App) replayOneWalletOutboxRecord(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, targetAppCode string, includeEventTypes []string) (int, error) {
if options.PublishTimeout <= 0 {
options.PublishTimeout = 3 * time.Second
}
claimCtx := appcode.WithContext(ctx, targetAppCode)
records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(
claimCtx,
workerID,
1,
time.Now().UTC().Add(walletOutboxSingleRecordLease(options.PublishTimeout)).UnixMilli(),
mysqlstorage.WalletOutboxClaimFilter{IncludeEventTypes: includeEventTypes},
)
if err != nil || len(records) == 0 {
return 0, err
}
record := records[0]
if record.RetryCount >= options.MaxRetryCount {
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record))
cancel()
if markErr != nil {
return 1, markErr
}
return 1, fmt.Errorf("wallet outbox event %s reached max retry count and was marked dead", record.EventID)
}
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
publishErr := a.publishWalletOutboxRecord(publishCtx, producer, topic, record)
cancel()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
defer markCancel()
if publishErr != nil {
nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli()
if markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, publishErr.Error(), nextRetryAtMS); markErr != nil {
return 1, errors.Join(publishErr, markErr)
}
// The row was safely released for a later retry, but this explicit run must
// fail instead of reporting a merely claimed row as a delivered replay.
return 1, publishErr
}
if err := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID); err != nil {
return 1, err
}
return 1, nil
}
// NormalizeWalletReplayEventTypes validates the exact MQ tags before an
// operator dry-run is promoted to an executing run.
func NormalizeWalletReplayEventTypes(values []string) ([]string, error) {
if len(values) > 32 {
return nil, errors.New("wallet event-type count cannot exceed 32")
}
normalized := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
eventType, err := walletmq.EventTypeTag(strings.TrimSpace(value))
if err != nil {
return nil, err
}
if _, exists := seen[eventType]; exists {
continue
}
seen[eventType] = struct{}{}
normalized = append(normalized, eventType)
}
return normalized, nil
}
// ValidateWalletReplayEventTypes applies the lane boundary in both dry-run and
// execute modes. The normal-topic repair must reject realtime event types even
// while that worker or topic is temporarily disabled; pausing a lane does not
// transfer ownership of its durable rows to another topic.
func ValidateWalletReplayEventTypes(cfg config.Config, values []string) ([]string, error) {
normalized, err := NormalizeWalletReplayEventTypes(values)
if err != nil {
return nil, err
}
if len(normalized) == 0 {
// Wallet fanout has both high-volume balance notices and low-volume config
// facts. Requiring an explicit set prevents a small operational command
// from accidentally mixing stages merely because their timestamps overlap.
return nil, errors.New("at least one wallet event-type is required")
}
if err := rejectRealtimeReplayEventTypes(normalized, cfg.RealtimeOutboxWorker.EventTypes); err != nil {
return nil, err
}
return normalized, nil
}
func rejectRealtimeReplayEventTypes(included []string, realtime []string) error {
realtimeSet := make(map[string]struct{}, len(realtime))
for _, eventType := range realtime {
eventType = strings.TrimSpace(eventType)
if eventType != "" {
realtimeSet[eventType] = struct{}{}
}
}
for _, eventType := range included {
if _, exists := realtimeSet[eventType]; exists {
return fmt.Errorf("wallet event-type %s belongs to the realtime outbox lane", eventType)
}
}
return nil
}