219 lines
7.6 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 wheel
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/wheel"
)
// RewardWorkerOptions 定义补偿 worker 的租户、锁和重试边界。
type RewardWorkerOptions struct {
WorkerID string
AppCodes []string
PollInterval time.Duration
BatchSize int
LockTTL time.Duration
MaxRetry int
}
// RewardWorkerBatchResult 只统计本轮 claim 后的终态;失败项已经释放为 retryable/failed不会静默丢失。
type RewardWorkerBatchResult struct {
Claimed int
Delivered int
Failed int
}
type wheelRewardOutboxPayload struct {
AppCode string `json:"app_code"`
DrawID string `json:"draw_id"`
DrawIDs []string `json:"draw_ids"`
CommandID string `json:"command_id"`
UserID int64 `json:"user_id"`
WheelID string `json:"wheel_id"`
SelectedTierID string `json:"selected_tier_id"`
RewardType string `json:"reward_type"`
RewardID string `json:"reward_id"`
RewardCount int64 `json:"reward_count"`
RewardCoins int64 `json:"reward_coins"`
Rewards []domain.DrawReward `json:"rewards"`
VisibleRegionID int64 `json:"visible_region_id"`
}
// RunRewardWorker 持续逐 App 分区补偿 WheelRewardSettlement。
// AppCode 必须写进 context 后再 claim 和调用钱包,避免后台根 context 被 appcode 默认值悄悄收敛成 lalu。
func (s *Service) RunRewardWorker(ctx context.Context, options RewardWorkerOptions) {
options = normalizeRewardWorkerOptions(options)
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
if _, err := s.ProcessPendingRewards(ctx, options); err != nil && ctx.Err() == nil {
logx.Error(ctx, "wheel_reward_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// ProcessPendingRewards 执行一轮有界补偿。
// 钱包 RPC 使用 draw_id 派生的稳定 command_id钱包 owner 会在同一事务内先查既有交易/权益及 request hash命中则返回原收据未命中才创建因此 activity 只在收齐所有收据后标 delivered。
func (s *Service) ProcessPendingRewards(ctx context.Context, options RewardWorkerOptions) (RewardWorkerBatchResult, error) {
if err := s.requireRepository(); err != nil {
return RewardWorkerBatchResult{}, err
}
options = normalizeRewardWorkerOptions(options)
var result RewardWorkerBatchResult
var batchErr error
for _, appCodeValue := range options.AppCodes {
if ctx.Err() != nil {
return result, errors.Join(batchErr, ctx.Err())
}
appCtx := appcode.WithContext(ctx, appCodeValue)
nowMS := s.now().UTC().UnixMilli()
records, err := s.repository.ClaimWheelRewardOutbox(appCtx, options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), options.BatchSize, options.MaxRetry)
if err != nil {
batchErr = errors.Join(batchErr, fmt.Errorf("claim app=%s: %w", appCodeValue, err))
continue
}
result.Claimed += len(records)
for _, record := range records {
if err := s.processRewardOutboxRecord(appCtx, record, options); err != nil {
result.Failed++
batchErr = errors.Join(batchErr, fmt.Errorf("process app=%s outbox=%s: %w", appCodeValue, record.OutboxID, err))
continue
}
result.Delivered++
}
}
return result, batchErr
}
func (s *Service) processRewardOutboxRecord(ctx context.Context, record domain.RewardOutboxRecord, options RewardWorkerOptions) error {
payload, err := decodeWheelRewardOutboxPayload(record)
if err == nil {
var fulfilled domain.DrawResult
fulfilled, err = s.fulfillWheelReward(ctx, domain.DrawCommand{
CommandID: payload.CommandID,
WheelID: payload.WheelID,
UserID: payload.UserID,
VisibleRegionID: payload.VisibleRegionID,
}, domain.DrawResult{
DrawID: payload.DrawID,
DrawIDs: payload.DrawIDs,
CommandID: payload.CommandID,
WheelID: payload.WheelID,
SelectedTierID: payload.SelectedTierID,
RewardType: payload.RewardType,
RewardID: payload.RewardID,
RewardCount: payload.RewardCount,
RewardCoins: payload.RewardCoins,
RewardStatus: domain.StatusPending,
Rewards: payload.Rewards,
})
if err == nil {
drawIDs := payload.DrawIDs
if len(drawIDs) == 0 {
drawIDs = []string{payload.DrawID}
}
// draw、统计和 outbox 的最终提交带 worker fencing token若 lease 已被接管,这里会回滚而不是覆盖新 worker。
return s.repository.MarkWheelRewardOutboxDelivered(ctx, record.OutboxID, options.WorkerID, drawIDs, fulfilled.WalletTransactionID, s.now().UTC().UnixMilli())
}
}
nowMS := s.now().UTC().UnixMilli()
nextRetryAtMS := nowMS + wheelRewardRetryDelay(record.RetryCount+1).Milliseconds()
markErr := s.repository.MarkWheelRewardOutboxFailed(ctx, record.OutboxID, options.WorkerID, nowMS, nextRetryAtMS, options.MaxRetry, err.Error())
if markErr != nil {
return errors.Join(err, fmt.Errorf("mark wheel reward retryable: %w", markErr))
}
return err
}
func decodeWheelRewardOutboxPayload(record domain.RewardOutboxRecord) (wheelRewardOutboxPayload, error) {
var payload wheelRewardOutboxPayload
if err := json.Unmarshal(record.Payload, &payload); err != nil {
return payload, fmt.Errorf("decode wheel reward payload: %w", err)
}
recordApp := appcode.Normalize(record.AppCode)
payloadApp := appcode.Normalize(payload.AppCode)
if payload.AppCode != "" && payloadApp != recordApp {
return payload, xerr.New(xerr.Conflict, "wheel reward payload app_code does not match outbox partition")
}
payload.AppCode = recordApp
payload.DrawID = strings.TrimSpace(payload.DrawID)
payload.CommandID = strings.TrimSpace(payload.CommandID)
payload.WheelID = strings.TrimSpace(payload.WheelID)
if payload.DrawID == "" || payload.CommandID == "" || payload.WheelID == "" || payload.UserID <= 0 {
return payload, xerr.New(xerr.InvalidArgument, "wheel reward payload identity is incomplete")
}
if len(payload.DrawIDs) == 0 {
payload.DrawIDs = []string{payload.DrawID}
}
for index := range payload.DrawIDs {
payload.DrawIDs[index] = strings.TrimSpace(payload.DrawIDs[index])
if payload.DrawIDs[index] == "" {
return payload, xerr.New(xerr.InvalidArgument, "wheel reward payload contains empty draw_id")
}
}
return payload, nil
}
func normalizeRewardWorkerOptions(options RewardWorkerOptions) RewardWorkerOptions {
options.WorkerID = strings.TrimSpace(options.WorkerID)
if options.WorkerID == "" {
options.WorkerID = "activity-wheel-reward"
}
seen := make(map[string]struct{}, len(options.AppCodes))
appCodes := make([]string, 0, len(options.AppCodes))
for _, value := range options.AppCodes {
value = appcode.Normalize(value)
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
appCodes = append(appCodes, value)
}
if len(appCodes) == 0 {
appCodes = []string{"lalu", "fami", "huwaa"}
}
options.AppCodes = appCodes
if options.PollInterval <= 0 {
options.PollInterval = time.Second
}
if options.BatchSize <= 0 {
options.BatchSize = 50
}
if options.LockTTL <= 0 {
options.LockTTL = 30 * time.Second
}
if options.MaxRetry <= 0 {
options.MaxRetry = 16
}
return options
}
func wheelRewardRetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > 7 {
attempt = 7
}
delay := time.Second * time.Duration(1<<(attempt-1))
if delay > time.Minute {
return time.Minute
}
return delay
}