186 lines
6.6 KiB
Go
186 lines
6.6 KiB
Go
package walletnotice
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"time"
|
|
|
|
"hyapp/pkg/logx"
|
|
"hyapp/pkg/tencentim"
|
|
"hyapp/pkg/walletmq"
|
|
)
|
|
|
|
// Repository 是 notice-service 需要的持久化能力。
|
|
type Repository interface {
|
|
ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error)
|
|
ClaimWalletBalanceMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (WalletBalanceEvent, bool, 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
|
|
}
|
|
|
|
// ProcessWalletOutboxMessage handles one wallet_outbox MQ message. Unknown
|
|
// event types are acknowledged by returning handled=false.
|
|
func (s *Service) ProcessWalletOutboxMessage(ctx context.Context, message walletmq.WalletOutboxMessage, options WalletNoticeWorkerOptions) (bool, error) {
|
|
if !isWalletPrivateNoticeEvent(message.EventType) {
|
|
return false, nil
|
|
}
|
|
options = normalizeWalletNoticeWorkerOptions(options, s.cfg.NodeID)
|
|
if s.repository == nil {
|
|
return true, fmt.Errorf("notice repository is not configured")
|
|
}
|
|
if s.publisher == nil {
|
|
return true, fmt.Errorf("notice publisher is not configured")
|
|
}
|
|
event, claimed, err := s.repository.ClaimWalletBalanceMessage(ctx, options.WorkerID, message, options.LockTTL)
|
|
if err != nil || !claimed {
|
|
return true, err
|
|
}
|
|
return true, s.publishWalletBalanceEvent(ctx, event, options)
|
|
}
|
|
|
|
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: walletNoticeExt(event.EventType),
|
|
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 walletNoticeExt(eventType string) string {
|
|
if eventType == eventVIPActivated {
|
|
return "vip_notice"
|
|
}
|
|
return "wallet_notice"
|
|
}
|
|
|
|
func isWalletPrivateNoticeEvent(eventType string) bool {
|
|
for _, expected := range walletPrivateNoticeEventTypes() {
|
|
if eventType == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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)
|
|
}
|