fix: consume wallet projections from mq

This commit is contained in:
zhx 2026-06-01 15:01:23 +08:00
parent bd0ed78546
commit 0aef70ecd7
9 changed files with 359 additions and 113 deletions

View File

@ -22,6 +22,8 @@ projection_worker:
poll_interval: "60s" poll_interval: "60s"
batch_size: 10 batch_size: 10
lock_ttl: "30s" lock_ttl: "30s"
consumer_group: "hyapp-wallet-projection"
consumer_max_reconsume_times: 16
rocketmq: rocketmq:
# Docker 本地使用 compose RocketMQ 发布 wallet_outbox 事实。 # Docker 本地使用 compose RocketMQ 发布 wallet_outbox 事实。
enabled: true enabled: true

View File

@ -22,6 +22,8 @@ projection_worker:
poll_interval: "60s" poll_interval: "60s"
batch_size: 10 batch_size: 10
lock_ttl: "30s" lock_ttl: "30s"
consumer_group: "hyapp-wallet-projection"
consumer_max_reconsume_times: 16
rocketmq: rocketmq:
enabled: true enabled: true
name_servers: name_servers:

View File

@ -18,10 +18,12 @@ red_packet_expiry_worker:
poll_interval: "5s" poll_interval: "5s"
batch_size: 50 batch_size: 50
projection_worker: projection_worker:
enabled: true enabled: false
poll_interval: "60s" poll_interval: "60s"
batch_size: 10 batch_size: 10
lock_ttl: "30s" lock_ttl: "30s"
consumer_group: "hyapp-wallet-projection"
consumer_max_reconsume_times: 16
rocketmq: rocketmq:
# 本地默认关闭;线上由 wallet-service outbox worker 投递账务事实到 MQ。 # 本地默认关闭;线上由 wallet-service outbox worker 投递账务事实到 MQ。
enabled: false enabled: false

View File

@ -39,6 +39,7 @@ type App struct {
walletSvc *walletservice.Service walletSvc *walletservice.Service
activityConn *grpc.ClientConn activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer outboxProducer *rocketmqx.Producer
projectionConsumer *rocketmqx.Consumer
outboxWorkerCfg config.OutboxWorkerConfig outboxWorkerCfg config.OutboxWorkerConfig
projectionWorkerCfg config.ProjectionWorkerConfig projectionWorkerCfg config.ProjectionWorkerConfig
walletOutboxTopic string walletOutboxTopic string
@ -114,6 +115,16 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close() _ = repository.Close()
return nil, err return nil, err
} }
projectionConsumer, err := newWalletProjectionConsumer(cfg, svc)
if err != nil {
if outboxProducer != nil {
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
return &App{ return &App{
server: server, server: server,
@ -124,6 +135,7 @@ func New(cfg config.Config) (*App, error) {
walletSvc: svc, walletSvc: svc,
activityConn: activityConn, activityConn: activityConn,
outboxProducer: outboxProducer, outboxProducer: outboxProducer,
projectionConsumer: projectionConsumer,
outboxWorkerCfg: cfg.OutboxWorker, outboxWorkerCfg: cfg.OutboxWorker,
projectionWorkerCfg: cfg.ProjectionWorker, projectionWorkerCfg: cfg.ProjectionWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic, walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
@ -139,11 +151,11 @@ func (a *App) Run() error {
a.health.MarkStopped() a.health.MarkStopped()
return err return err
} }
a.runGiftWallProjectionWorker() a.runBackgroundWorkers()
a.health.MarkServing() a.health.MarkServing()
err := a.server.Serve(a.listener) err := a.server.Serve(a.listener)
a.health.MarkStopped() a.health.MarkStopped()
a.closeGiftWallProjectionWorker() a.closeBackgroundWorkers()
a.shutdownMQ() a.shutdownMQ()
if errors.Is(err, grpc.ErrServerStopped) { if errors.Is(err, grpc.ErrServerStopped) {
return nil return nil
@ -156,7 +168,7 @@ func (a *App) Run() error {
func (a *App) Close() { func (a *App) Close() {
a.closeOnce.Do(func() { a.closeOnce.Do(func() {
a.health.MarkDraining() a.health.MarkDraining()
a.closeGiftWallProjectionWorker() a.closeBackgroundWorkers()
grpcshutdown.GracefulStop(a.server, 15*time.Second) grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP() a.closeHealthHTTP()
if a.activityConn != nil { if a.activityConn != nil {
@ -170,61 +182,12 @@ func (a *App) Close() {
}) })
} }
func (a *App) runGiftWallProjectionWorker() { func (a *App) runBackgroundWorkers() {
if a.walletSvc == nil || !a.projectionWorkerCfg.Enabled { if a.walletSvc == nil {
return return
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
a.stopWorker = cancel a.stopWorker = cancel
giftWallWorkerID := "gift-wall-" + a.nodeID
pollInterval := a.projectionWorkerCfg.PollInterval
batchSize := a.projectionWorkerCfg.BatchSize
lockTTL := a.projectionWorkerCfg.LockTTL
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
// 礼物墙是展示读模型worker 常驻在 wallet-service 内消费账务 outbox。
// 服务重启会回滚未提交的投影事务;已完成事件由 projection done 状态防重。
processed, err := a.walletSvc.ProjectPendingGiftWallEvents(ctx, giftWallWorkerID, batchSize, lockTTL)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "gift_wall_projection_failed", err, slog.String("worker_id", giftWallWorkerID))
}
if processed >= batchSize {
// 批次打满说明还有积压,立即继续 drain避免固定 tick 放大展示延迟。
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
badgeWorkerID := "badge-grant-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for {
// 徽章展示槽位属于 activity-service 读模型wallet 只 relay 自己 outbox 中的资源赠送事实。
processed, err := a.walletSvc.ProjectPendingBadgeGrantEvents(ctx, badgeWorkerID, batchSize, lockTTL)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "badge_grant_projection_failed", err, slog.String("worker_id", badgeWorkerID))
}
if processed >= batchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil { if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil {
outboxWorkerID := "wallet-outbox-" + a.nodeID outboxWorkerID := "wallet-outbox-" + a.nodeID
a.workers.Add(1) a.workers.Add(1)
@ -373,7 +336,7 @@ func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time
return backoff return backoff
} }
func (a *App) closeGiftWallProjectionWorker() { func (a *App) closeBackgroundWorkers() {
if a.stopWorker != nil { if a.stopWorker != nil {
a.stopWorker() a.stopWorker()
} }
@ -381,18 +344,72 @@ func (a *App) closeGiftWallProjectionWorker() {
} }
func (a *App) startMQ() error { func (a *App) startMQ() error {
if a.outboxProducer == nil { if a.outboxProducer != nil {
return nil if err := a.outboxProducer.Start(); err != nil {
return err
}
} }
return a.outboxProducer.Start() if a.projectionConsumer != nil {
if err := a.projectionConsumer.Start(); err != nil {
if a.outboxProducer != nil {
_ = a.outboxProducer.Shutdown()
}
return err
}
}
return nil
} }
func (a *App) shutdownMQ() { func (a *App) shutdownMQ() {
if a.outboxProducer == nil { if a.projectionConsumer != nil {
return if err := a.projectionConsumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_projection_consumer_shutdown_failed", slog.String("error", err.Error()))
}
} }
if err := a.outboxProducer.Shutdown(); err != nil { if a.outboxProducer != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) if err := a.outboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
}
func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) {
if svc == nil || !cfg.ProjectionWorker.Enabled {
return nil, nil
}
consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker))
if err != nil {
return nil, err
}
workerID := "wallet-projection-" + cfg.NodeID
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = svc.ProcessWalletProjectionMessage(appcode.WithContext(ctx, walletMessage.AppCode), workerID, walletMessage, cfg.ProjectionWorker.LockTTL)
return err
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection config.ProjectionWorkerConfig) rocketmqx.ConsumerConfig {
return rocketmqx.ConsumerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: projection.ConsumerGroup,
MaxReconsumeTimes: projection.ConsumerMaxReconsumeTimes,
ConsumePullBatch: int32(projection.BatchSize),
} }
} }

View File

@ -29,7 +29,7 @@ type Config struct {
GooglePlay GooglePlayConfig `yaml:"google_play"` GooglePlay GooglePlayConfig `yaml:"google_play"`
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。 // OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// ProjectionWorker 控制 wallet_outbox 到本服务读模型的投影补偿扫描 // ProjectionWorker 控制 wallet_outbox MQ 到本服务读模型的投影消费
ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"` ProjectionWorker ProjectionWorkerConfig `yaml:"projection_worker"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
@ -77,12 +77,14 @@ type OutboxWorkerConfig struct {
MaxBackoff time.Duration `yaml:"max_backoff"` MaxBackoff time.Duration `yaml:"max_backoff"`
} }
// ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox 补偿投影的节奏。 // ProjectionWorkerConfig 控制礼物墙和 badge 展示读模型从 wallet_outbox MQ 投影的节奏。
type ProjectionWorkerConfig struct { type ProjectionWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"` // Deprecated: runtime projection is MQ-driven.
BatchSize int `yaml:"batch_size"` BatchSize int `yaml:"batch_size"`
LockTTL time.Duration `yaml:"lock_ttl"` LockTTL time.Duration `yaml:"lock_ttl"`
ConsumerGroup string `yaml:"consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
} }
// GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。 // GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。
@ -131,10 +133,12 @@ func Default() Config {
MaxBackoff: 5 * time.Minute, MaxBackoff: 5 * time.Minute,
}, },
ProjectionWorker: ProjectionWorkerConfig{ ProjectionWorker: ProjectionWorkerConfig{
Enabled: true, Enabled: false,
PollInterval: 60 * time.Second, PollInterval: 60 * time.Second,
BatchSize: 10, BatchSize: 10,
LockTTL: 30 * time.Second, LockTTL: 30 * time.Second,
ConsumerGroup: "hyapp-wallet-projection",
ConsumerMaxReconsumeTimes: 16,
}, },
MySQLAutoMigrate: false, MySQLAutoMigrate: false,
Log: logx.Config{ Log: logx.Config{
@ -262,6 +266,15 @@ func Load(path string) (Config, error) {
if cfg.ProjectionWorker.LockTTL <= 0 { if cfg.ProjectionWorker.LockTTL <= 0 {
cfg.ProjectionWorker.LockTTL = Default().ProjectionWorker.LockTTL cfg.ProjectionWorker.LockTTL = Default().ProjectionWorker.LockTTL
} }
if cfg.ProjectionWorker.ConsumerGroup = strings.TrimSpace(cfg.ProjectionWorker.ConsumerGroup); cfg.ProjectionWorker.ConsumerGroup == "" {
cfg.ProjectionWorker.ConsumerGroup = Default().ProjectionWorker.ConsumerGroup
}
if cfg.ProjectionWorker.ConsumerMaxReconsumeTimes <= 0 {
cfg.ProjectionWorker.ConsumerMaxReconsumeTimes = Default().ProjectionWorker.ConsumerMaxReconsumeTimes
}
if cfg.ProjectionWorker.Enabled && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("projection_worker requires rocketmq.wallet_outbox.enabled")
}
return cfg, nil return cfg, nil
} }

View File

@ -8,6 +8,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
@ -81,7 +82,9 @@ type Repository interface {
SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error) SetResourceShopItemStatus(ctx context.Context, command resourcedomain.ResourceShopItemStatusCommand) (resourcedomain.ResourceShopItem, error)
PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error) PurchaseResourceShopItem(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand) (resourcedomain.ResourceShopPurchaseReceipt, error)
ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) ProjectPendingGiftWallEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error)
ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error)
ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error) ClaimPendingBadgeGrantEvents(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]resourcedomain.BadgeGrantOutbox, error)
ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error)
MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error MarkBadgeGrantEventDelivered(ctx context.Context, event resourcedomain.BadgeGrantOutbox, nowMS int64) error
MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error MarkBadgeGrantEventFailed(ctx context.Context, event resourcedomain.BadgeGrantOutbox, failureReason string, nowMS int64) error
} }
@ -426,6 +429,30 @@ func (s *Service) ProjectPendingGiftWallEvents(ctx context.Context, workerID str
return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL) return s.repository.ProjectPendingGiftWallEvents(ctx, workerID, limit, lockTTL)
} }
// ProcessWalletProjectionMessage consumes one wallet_outbox MQ fact for wallet-owned projections.
func (s *Service) ProcessWalletProjectionMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if s.repository == nil {
return false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, message.AppCode)
handled := false
projected, err := s.repository.ProjectGiftWallMessage(ctx, workerID+"-gift-wall", message, lockTTL)
if err != nil {
return handled, err
}
handled = handled || projected
projected, err = s.projectBadgeGrantMessage(ctx, workerID+"-badge-grant", message, lockTTL)
if err != nil {
return handled, err
}
handled = handled || projected
return handled, nil
}
// ProjectPendingBadgeGrantEvents relays wallet resource grant outbox facts to activity badge display projection. // ProjectPendingBadgeGrantEvents relays wallet resource grant outbox facts to activity badge display projection.
func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) { func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) (int, error) {
workerID = strings.TrimSpace(workerID) workerID = strings.TrimSpace(workerID)
@ -452,41 +479,69 @@ func (s *Service) ProjectPendingBadgeGrantEvents(ctx context.Context, workerID s
processed := 0 processed := 0
for _, event := range events { for _, event := range events {
processed++ processed++
reqCtx := appcode.WithContext(ctx, event.AppCode) if err := s.deliverBadgeGrantProjection(ctx, event); err != nil {
resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{
Meta: &activityv1.RequestMeta{
Caller: "wallet-service",
AppCode: event.AppCode,
SentAtMs: s.now().UnixMilli(),
},
EventId: event.EventID,
EventType: event.EventType,
SourceService: "wallet-service",
UserId: event.UserID,
MetricType: walletBadgeGrantMetric,
Value: 1,
OccurredAtMs: event.CreatedAtMS,
DimensionsJson: event.PayloadJSON,
})
if consumeErr != nil {
if markErr := s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli()); markErr != nil {
return processed, markErr
}
continue
}
if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) {
if markErr := s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli()); markErr != nil {
return processed, markErr
}
continue
}
if err := s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli()); err != nil {
return processed, err return processed, err
} }
} }
return processed, nil return processed, nil
} }
func (s *Service) projectBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
if !isBadgeGrantWalletProjectionMessage(message) {
return false, nil
}
if s.activity == nil {
return false, xerr.New(xerr.Unavailable, "activity badge client is not configured")
}
nowMS := s.now().UnixMilli()
event, claimed, err := s.repository.ClaimBadgeGrantMessage(ctx, workerID, message, nowMS, lockTTL)
if err != nil || !claimed {
return claimed, err
}
if err := s.deliverBadgeGrantProjection(ctx, event); err != nil {
return true, err
}
return true, nil
}
func (s *Service) deliverBadgeGrantProjection(ctx context.Context, event resourcedomain.BadgeGrantOutbox) error {
reqCtx := appcode.WithContext(ctx, event.AppCode)
resp, consumeErr := s.activity.ConsumeAchievementEvent(reqCtx, &activityv1.ConsumeAchievementEventRequest{
Meta: &activityv1.RequestMeta{
Caller: "wallet-service",
AppCode: event.AppCode,
SentAtMs: s.now().UnixMilli(),
},
EventId: event.EventID,
EventType: event.EventType,
SourceService: "wallet-service",
UserId: event.UserID,
MetricType: walletBadgeGrantMetric,
Value: 1,
OccurredAtMs: event.CreatedAtMS,
DimensionsJson: event.PayloadJSON,
})
if consumeErr != nil {
return s.repository.MarkBadgeGrantEventFailed(ctx, event, xerr.MessageOf(consumeErr), s.now().UnixMilli())
}
if !isBadgeGrantProjectionStatusTerminal(resp.GetStatus()) {
return s.repository.MarkBadgeGrantEventFailed(ctx, event, "unexpected activity status: "+resp.GetStatus(), s.now().UnixMilli())
}
return s.repository.MarkBadgeGrantEventDelivered(ctx, event, s.now().UnixMilli())
}
func isBadgeGrantWalletProjectionMessage(message walletmq.WalletOutboxMessage) bool {
if strings.TrimSpace(message.AssetType) != "RESOURCE" {
return false
}
switch message.EventType {
case "ResourceGranted", "ResourceGroupGranted":
return true
default:
return false
}
}
func isBadgeGrantProjectionStatusTerminal(status string) bool { func isBadgeGrantProjectionStatusTerminal(status string) bool {
switch status { switch status {
case "consumed", "skipped", "duplicate": case "consumed", "skipped", "duplicate":

View File

@ -9,13 +9,31 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
walletservice "hyapp/services/wallet-service/internal/service/wallet" walletservice "hyapp/services/wallet-service/internal/service/wallet"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
"hyapp/services/wallet-service/internal/testutil/mysqltest" "hyapp/services/wallet-service/internal/testutil/mysqltest"
) )
func walletOutboxMessageFromRecord(record mysqlstorage.WalletOutboxRecord) walletmq.WalletOutboxMessage {
return walletmq.WalletOutboxMessage{
AppCode: record.AppCode,
EventID: record.EventID,
EventType: record.EventType,
TransactionID: record.TransactionID,
CommandID: record.CommandID,
UserID: record.UserID,
AssetType: record.AssetType,
AvailableDelta: record.AvailableDelta,
FrozenDelta: record.FrozenDelta,
PayloadJSON: record.PayloadJSON,
OccurredAtMS: record.CreatedAtMS,
}
}
// TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。 // TestDebitGiftUsesServerPriceAndCreditsGiftPoint 验证送礼只信服务端价格并产生双边分录。
func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) { func TestDebitGiftUsesServerPriceAndCreditsGiftPoint(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
@ -756,16 +774,32 @@ func TestGetUserGiftWallAggregatesSettledGifts(t *testing.T) {
if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 { if got := repository.CountRows("user_gift_wall", "user_id = ?", 10002); got != 0 {
t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got) t.Fatalf("gift wall must not be updated inside DebitGift transaction, got %d rows", got)
} }
processed, err := svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute) records, err := repository.ClaimPendingWalletOutbox(context.Background(), "gift-wall-mq-test", 20, time.Now().Add(time.Minute).UnixMilli())
if err != nil { if err != nil {
t.Fatalf("ProjectPendingGiftWallEvents failed: %v", err) t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
}
processed := 0
for _, record := range records {
handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage failed: %v", err)
}
if handled {
processed++
}
} }
if processed != 3 { if processed != 3 {
t.Fatalf("projection should process three gift events, got %d", processed) t.Fatalf("projection should process three gift events, got %d", processed)
} }
processed, err = svc.ProjectPendingGiftWallEvents(context.Background(), "test-worker", 10, time.Minute) processed = 0
if err != nil { for _, record := range records {
t.Fatalf("ProjectPendingGiftWallEvents retry failed: %v", err) handled, err := svc.ProcessWalletProjectionMessage(context.Background(), "test-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err)
}
if handled {
processed++
}
} }
if processed != 0 { if processed != 0 {
t.Fatalf("projection must be idempotent after done events, got %d", processed) t.Fatalf("projection must be idempotent after done events, got %d", processed)
@ -2041,9 +2075,19 @@ func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) {
t.Fatalf("GrantResource failed: %v", err) t.Fatalf("GrantResource failed: %v", err)
} }
processed, err := svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute) records, err := repository.ClaimPendingWalletOutbox(ctx, "badge-mq-test", 20, time.Now().Add(time.Minute).UnixMilli())
if err != nil { if err != nil {
t.Fatalf("ProjectPendingBadgeGrantEvents failed: %v", err) t.Fatalf("ClaimPendingWalletOutbox failed: %v", err)
}
processed := 0
for _, record := range records {
handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage failed: %v", err)
}
if handled {
processed++
}
} }
if processed != 1 || len(activity.requests) != 1 { if processed != 1 || len(activity.requests) != 1 {
t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests)) t.Fatalf("badge projection relay mismatch: processed=%d requests=%d", processed, len(activity.requests))
@ -2074,9 +2118,18 @@ func TestProjectPendingBadgeGrantEventsRelaysWalletOutbox(t *testing.T) {
if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 { if got := repository.CountRows("wallet_projection_events", "projection_name = ? AND status = ?", "activity_badge_display", "done"); got != 1 {
t.Fatalf("badge projection should mark event done, got %d", got) t.Fatalf("badge projection should mark event done, got %d", got)
} }
processed, err = svc.ProjectPendingBadgeGrantEvents(ctx, "test-badge-worker", 10, time.Minute) processed = 0
if err != nil || processed != 0 || len(activity.requests) != 1 { for _, record := range records {
t.Fatalf("badge projection must be idempotent: processed=%d requests=%d err=%v", processed, len(activity.requests), err) handled, err := svc.ProcessWalletProjectionMessage(ctx, "test-badge-worker", walletOutboxMessageFromRecord(record), time.Minute)
if err != nil {
t.Fatalf("ProcessWalletProjectionMessage retry failed: %v", err)
}
if handled {
processed++
}
}
if processed != 0 || len(activity.requests) != 1 {
t.Fatalf("badge projection must be idempotent: processed=%d requests=%d", processed, len(activity.requests))
} }
} }

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource" resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
) )
@ -75,6 +76,47 @@ func (r *Repository) ClaimPendingBadgeGrantEvents(ctx context.Context, workerID
return events, nil return events, nil
} }
// ClaimBadgeGrantMessage claims one wallet_outbox MQ fact for badge display projection.
// Runtime projection consumes MQ and does not scan wallet_outbox for candidates.
func (r *Repository) ClaimBadgeGrantMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) {
if r == nil || r.db == nil {
return resourcedomain.BadgeGrantOutbox{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if !isBadgeGrantWalletMessage(message) {
return resourcedomain.BadgeGrantOutbox{}, false, nil
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return resourcedomain.BadgeGrantOutbox{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if nowMS <= 0 {
nowMS = time.Now().UnixMilli()
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
event := resourcedomain.BadgeGrantOutbox{
AppCode: appcode.Normalize(message.AppCode),
EventID: strings.TrimSpace(message.EventID),
EventType: strings.TrimSpace(message.EventType),
CommandID: strings.TrimSpace(message.CommandID),
UserID: message.UserID,
PayloadJSON: strings.TrimSpace(message.PayloadJSON),
CreatedAtMS: message.OccurredAtMS,
UpdatedAtMS: message.OccurredAtMS,
}
if event.PayloadJSON == "" {
event.PayloadJSON = "{}"
}
ctx = appcode.WithContext(ctx, event.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return resourcedomain.BadgeGrantOutbox{}, false, err
}
defer func() { _ = tx.Rollback() }()
return r.claimBadgeGrantEventInTx(ctx, tx, event, workerID, nowMS, lockTTL)
}
func (r *Repository) listBadgeGrantProjectionCandidates(ctx context.Context, nowMS int64, limit int) ([]badgeGrantProjectionCandidate, error) { func (r *Repository) listBadgeGrantProjectionCandidates(ctx context.Context, nowMS int64, limit int) ([]badgeGrantProjectionCandidate, error) {
appCode := appcode.FromContext(ctx) appCode := appcode.FromContext(ctx)
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
@ -145,6 +187,18 @@ func (r *Repository) listBadgeGrantProjectionCandidates(ctx context.Context, now
return candidates, nil return candidates, nil
} }
func isBadgeGrantWalletMessage(message walletmq.WalletOutboxMessage) bool {
if strings.TrimSpace(message.AssetType) != resourceOutboxAsset {
return false
}
switch message.EventType {
case walletResourceGrantedEvent, walletResourceGroupGrantedEvent:
return true
default:
return false
}
}
func scanBadgeGrantProjectionCandidates(rows *sql.Rows, limit int) ([]badgeGrantProjectionCandidate, error) { func scanBadgeGrantProjectionCandidates(rows *sql.Rows, limit int) ([]badgeGrantProjectionCandidate, error) {
defer rows.Close() defer rows.Close()
candidates := make([]badgeGrantProjectionCandidate, 0, limit) candidates := make([]badgeGrantProjectionCandidate, 0, limit)
@ -159,7 +213,6 @@ func scanBadgeGrantProjectionCandidates(rows *sql.Rows, limit int) ([]badgeGrant
} }
func (r *Repository) claimBadgeGrantEvent(ctx context.Context, candidate badgeGrantProjectionCandidate, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) { func (r *Repository) claimBadgeGrantEvent(ctx context.Context, candidate badgeGrantProjectionCandidate, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) {
lockUntilMS := time.UnixMilli(nowMS).Add(lockTTL).UnixMilli()
ctx = appcode.WithContext(ctx, candidate.AppCode) ctx = appcode.WithContext(ctx, candidate.AppCode)
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
@ -175,6 +228,12 @@ func (r *Repository) claimBadgeGrantEvent(ctx context.Context, candidate badgeGr
} }
return resourcedomain.BadgeGrantOutbox{}, false, err return resourcedomain.BadgeGrantOutbox{}, false, err
} }
return r.claimBadgeGrantEventInTx(ctx, tx, event, workerID, nowMS, lockTTL)
}
func (r *Repository) claimBadgeGrantEventInTx(ctx context.Context, tx *sql.Tx, event resourcedomain.BadgeGrantOutbox, workerID string, nowMS int64, lockTTL time.Duration) (resourcedomain.BadgeGrantOutbox, bool, error) {
lockUntilMS := time.UnixMilli(nowMS).Add(lockTTL).UnixMilli()
claimed, err := r.claimBadgeProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMS) claimed, err := r.claimBadgeProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMS)
if err != nil || !claimed { if err != nil || !claimed {
return resourcedomain.BadgeGrantOutbox{}, false, err return resourcedomain.BadgeGrantOutbox{}, false, err

View File

@ -10,6 +10,7 @@ import (
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
) )
@ -79,6 +80,44 @@ func (r *Repository) ProjectPendingGiftWallEvents(ctx context.Context, workerID
return processed, nil return processed, nil
} }
// ProjectGiftWallMessage projects one wallet_outbox MQ fact into user_gift_wall.
// Runtime projection consumes MQ and does not scan wallet_outbox for candidates.
func (r *Repository) ProjectGiftWallMessage(ctx context.Context, workerID string, message walletmq.WalletOutboxMessage, lockTTL time.Duration) (bool, error) {
if r == nil || r.db == nil {
return false, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
if message.EventType != walletGiftDebitedEvent {
return false, nil
}
workerID = strings.TrimSpace(workerID)
if workerID == "" {
return false, xerr.New(xerr.InvalidArgument, "worker_id is required")
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
nowMs := time.Now().UnixMilli()
lockUntilMS := time.Now().Add(lockTTL).UnixMilli()
event := giftWallProjectionEvent{
AppCode: appcode.Normalize(message.AppCode),
EventID: strings.TrimSpace(message.EventID),
TransactionID: strings.TrimSpace(message.TransactionID),
CommandID: strings.TrimSpace(message.CommandID),
PayloadJSON: strings.TrimSpace(message.PayloadJSON),
CreatedAtMS: message.OccurredAtMS,
}
if event.PayloadJSON == "" {
event.PayloadJSON = "{}"
}
ctx = appcode.WithContext(ctx, event.AppCode)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return false, err
}
defer func() { _ = tx.Rollback() }()
return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
}
func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) { func (r *Repository) listGiftWallProjectionCandidates(ctx context.Context, limit int) ([]giftWallProjectionCandidate, error) {
nowMs := time.Now().UnixMilli() nowMs := time.Now().UnixMilli()
appCode := appcode.FromContext(ctx) appCode := appcode.FromContext(ctx)
@ -176,6 +215,10 @@ func (r *Repository) projectGiftWallEvent(ctx context.Context, candidate giftWal
} }
return false, err return false, err
} }
return r.projectGiftWallLockedEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
}
func (r *Repository) projectGiftWallLockedEvent(ctx context.Context, tx *sql.Tx, event giftWallProjectionEvent, workerID string, lockUntilMS int64, nowMs int64) (bool, error) {
claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) claimed, err := r.claimProjectionEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
if err != nil || !claimed { if err != nil || !claimed {
return false, err return false, err