219 lines
8.0 KiB
Go
219 lines
8.0 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 walletPrivateNoticeSuppressed(event.AssetType, event.PayloadJSON) {
|
||
// owner 事件已经固化“不投私信”时,把本地 delivery 位点收敛为 delivered;
|
||
// 这条终态既阻止 pull fallback 反复认领,也不需要 notice 反查 user-service 猜账号类型。
|
||
if err := s.repository.MarkWalletBalanceDelivered(ctx, event, json.RawMessage(event.PayloadJSON), time.Now().UnixMilli()); err != nil {
|
||
return processed, err
|
||
}
|
||
processed++
|
||
continue
|
||
}
|
||
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
|
||
}
|
||
if walletPrivateNoticeSuppressed(message.AssetType, message.PayloadJSON) {
|
||
// MQ 主链路在写 notice delivery 前确认 owner 快照;ROBOT_COIN 是历史兼容条件,
|
||
// suppress_private_notice 才是不依赖资产类型的显式契约。
|
||
return true, 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 isRobotCoinWalletNotice(assetType string) bool {
|
||
return assetType == robotCoinAssetType
|
||
}
|
||
|
||
func walletPrivateNoticeSuppressed(assetType string, payloadJSON string) bool {
|
||
if isRobotCoinWalletNotice(assetType) {
|
||
return true
|
||
}
|
||
var control struct {
|
||
SuppressPrivateNotice bool `json:"suppress_private_notice"`
|
||
}
|
||
// 旧事件没有控制字段时保持原投递语义;非法 payload 也不能在这里被静默确认,
|
||
// 后续正常 payload 校验会把它收敛到 retry/failed,保留可观测性。
|
||
if err := json.Unmarshal([]byte(payloadJSON), &control); err != nil {
|
||
return false
|
||
}
|
||
return control.SuppressPrivateNotice
|
||
}
|
||
|
||
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)
|
||
}
|