219 lines
8.0 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 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)
}