2026-05-13 16:23:05 +08:00

148 lines
5.3 KiB
Go

package walletnotice
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"time"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim"
)
// Repository 是 notice-service 需要的持久化能力。
type Repository interface {
ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error)
MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error
MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error
}
// UserMessagePublisher 把私有 notice 投递到用户个人实时通道。
type UserMessagePublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
}
// Config 保存 notice-service 领域配置。
type Config struct {
NodeID string
}
// Service 编排 notice 投递和消费位点,不拥有钱包账务状态。
type Service struct {
cfg Config
repository Repository
publisher UserMessagePublisher
}
// New 创建 notice 领域服务。
func New(cfg Config, repository Repository, publisher UserMessagePublisher) *Service {
return &Service{cfg: cfg, repository: repository, publisher: publisher}
}
// ProcessWalletBalanceNotices 处理一批钱包余额变更私有通知。
func (s *Service) ProcessWalletBalanceNotices(ctx context.Context, options WalletNoticeWorkerOptions) (int, error) {
options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID)
if s.repository == nil {
return 0, fmt.Errorf("notice repository is not configured")
}
if s.publisher == nil {
return 0, fmt.Errorf("notice publisher is not configured")
}
events, err := s.repository.ClaimWalletBalanceEvents(ctx, options.WorkerID, options.BatchSize, options.LockTTL)
if err != nil {
return 0, err
}
processed := 0
for _, event := range events {
if err := s.publishWalletBalanceEvent(ctx, event, options); err != nil {
return processed, err
}
processed++
}
return processed, nil
}
func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions) error {
payload, err := walletBalanceNoticePayload(event)
if err != nil {
return s.markFailed(ctx, event, options, err)
}
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(event.UserID),
EventID: event.EventID,
Desc: event.EventType,
Ext: "wallet_notice",
PayloadJSON: payload,
})
nowMs := time.Now().UnixMilli()
if err == nil {
if markErr := s.repository.MarkWalletBalanceDelivered(ctx, event, payload, nowMs); markErr != nil {
return markErr
}
logx.Info(ctx, "notice_wallet_balance_delivered",
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.Int64("user_id", event.UserID),
slog.String("asset_type", event.AssetType),
)
return nil
}
return s.markFailed(ctx, event, options, err)
}
func (s *Service) markFailed(ctx context.Context, event WalletBalanceEvent, options WalletNoticeWorkerOptions, cause error) error {
nowMs := time.Now().UnixMilli()
nextRetryCount := event.RetryCount + 1
if nextRetryCount >= options.MaxRetryCount {
if err := s.repository.MarkWalletBalanceFailed(ctx, event, nextRetryCount, cause.Error(), nowMs); err != nil {
return err
}
logx.Error(ctx, "notice_wallet_balance_dead_letter", cause,
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.Int64("user_id", event.UserID),
slog.Int("retry_count", nextRetryCount),
)
return nil
}
nextRetryAtMS := nowMs + walletNoticeBackoff(nextRetryCount, options).Milliseconds()
if err := s.repository.MarkWalletBalanceRetryable(ctx, event, nextRetryCount, nextRetryAtMS, cause.Error(), nowMs); err != nil {
return err
}
logx.Error(ctx, "notice_wallet_balance_retryable", cause,
slog.String("event_id", event.EventID),
slog.String("app_code", event.AppCode),
slog.Int64("user_id", event.UserID),
slog.Int("retry_count", nextRetryCount),
slog.Int64("next_retry_at_ms", nextRetryAtMS),
)
return nil
}
func walletBalanceNoticePayload(event WalletBalanceEvent) (json.RawMessage, error) {
var payload map[string]any
if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil {
return nil, fmt.Errorf("invalid wallet balance payload: %w", err)
}
if payload == nil {
payload = make(map[string]any)
}
// 钱包事件 payload 是客户端更新余额的私有事实;这里补齐 outbox 表中的稳定索引字段,避免客户端依赖 metadata 结构。
payload["event_type"] = event.EventType
payload["event_id"] = event.EventID
payload["app_code"] = event.AppCode
payload["transaction_id"] = event.TransactionID
payload["command_id"] = event.CommandID
payload["user_id"] = strconv.FormatInt(event.UserID, 10)
payload["asset_type"] = event.AssetType
payload["available_delta"] = event.AvailableDelta
payload["frozen_delta"] = event.FrozenDelta
payload["source_created_at_ms"] = event.CreatedAtMS
return json.Marshal(payload)
}