package growth_test import ( "context" "fmt" "testing" "time" "google.golang.org/grpc" "google.golang.org/protobuf/proto" activityevents "hyapp.local/api/proto/events/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" domain "hyapp/services/activity-service/internal/domain/growth" growthservice "hyapp/services/activity-service/internal/service/growth" "hyapp/services/activity-service/internal/testutil/mysqltest" ) func TestGrowthLevelEventRewardFlow(t *testing.T) { svc, wallet := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") seedGrowthRules(t, ctx, svc, domain.TrackWealth) result, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{ EventID: "level-event-1", SourceEventID: "room-event-1", SourceService: "room-service", SourceEventType: "RoomGiftSent", UserID: 10001, Track: domain.TrackWealth, MetricType: domain.MetricGiftSpendCoin, ValueDelta: 250, OccurredAtMS: fixedGrowthNow().UnixMilli(), DimensionsJSON: `{"room_id":"room-1"}`, }) if err != nil { t.Fatalf("ConsumeLevelEvent failed: %v", err) } if !result.LevelChanged || result.PreviousLevel != 0 || result.NewLevel != 2 || result.RewardJobCount != 2 { t.Fatalf("level result mismatch: %+v", result) } duplicate, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{ EventID: "level-event-1", SourceEventID: "room-event-1", SourceService: "room-service", SourceEventType: "RoomGiftSent", UserID: 10001, Track: domain.TrackWealth, MetricType: domain.MetricGiftSpendCoin, ValueDelta: 250, OccurredAtMS: fixedGrowthNow().UnixMilli(), DimensionsJSON: `{"room_id":"room-1"}`, }) if err != nil || duplicate.Status != domain.EventStatusDuplicate { t.Fatalf("duplicate event mismatch: result=%+v err=%v", duplicate, err) } overview, err := svc.GetMyLevelOverview(ctx, 10001) if err != nil { t.Fatalf("GetMyLevelOverview failed: %v", err) } wealth := findTrackOverview(overview.Tracks, domain.TrackWealth) if wealth.Level != 2 || wealth.TierID == 0 || wealth.TotalValue != 250 || wealth.DisplayAvatarFrameResourceID != 502 || wealth.DisplayBadgeResourceID != 702 || wealth.DisplayBadgeSourceLevel != 2 || wealth.RewardPendingCount != 2 { t.Fatalf("wealth overview mismatch: %+v", wealth) } profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10001}) if err != nil { t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err) } if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.BadgeResourceID != 702 || profiles.Profiles[0].Wealth.BadgeSourceLevel != 2 { t.Fatalf("display profile should use highest configured rule badge: %+v", profiles.Profiles) } claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-1", "worker-1", 10, time.Second) if err != nil { t.Fatalf("ProcessLevelRewardBatch failed: %v", err) } if claimed != 2 || processed != 2 || success != 2 || failure != 0 || hasMore || len(wallet.grants) != 2 { t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v grants=%d", claimed, processed, success, failure, hasMore, len(wallet.grants)) } rewards, total, err := svc.ListLevelRewards(ctx, domain.RewardQuery{UserID: 10001, Status: domain.RewardStatusGranted}) if err != nil { t.Fatalf("ListLevelRewards failed: %v", err) } if total != 2 || len(rewards) != 2 { t.Fatalf("granted rewards mismatch: total=%d rewards=%+v", total, rewards) } } func TestGrowthLevelEventConcurrentSameUserTrack(t *testing.T) { svc, _ := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") seedGrowthRules(t, ctx, svc, domain.TrackWealth) // 先创建账户,确保并发波次命中生产故障里的“已有热点账户”路径,而不是只覆盖首次插入。 if _, err := svc.ConsumeLevelEvent(ctx, growthValueEvent("growth-hot-account-seed", 41001, 1)); err != nil { t.Fatalf("seed hot growth account: %v", err) } const writers = 40 start := make(chan struct{}) errs := make(chan error, writers) for index := 0; index < writers; index++ { go func(index int) { <-start _, err := svc.ConsumeLevelEvent(ctx, growthValueEvent(fmt.Sprintf("growth-hot-account-%d", index), 41001, 1)) errs <- err }(index) } close(start) for index := 0; index < writers; index++ { if err := <-errs; err != nil { t.Fatalf("concurrent growth event %d failed: %v", index, err) } } // 同一幂等键并发只能有一个事务增加累计值,其余事务必须在首个提交后稳定返回 duplicate。 const duplicateWriters = 10 duplicateStart := make(chan struct{}) type consumeResult struct { status string err error } duplicateResults := make(chan consumeResult, duplicateWriters) for index := 0; index < duplicateWriters; index++ { go func() { <-duplicateStart result, err := svc.ConsumeLevelEvent(ctx, growthValueEvent("growth-hot-account-duplicate", 41001, 1)) duplicateResults <- consumeResult{status: result.Status, err: err} }() } close(duplicateStart) consumed := 0 duplicates := 0 for index := 0; index < duplicateWriters; index++ { result := <-duplicateResults if result.err != nil { t.Fatalf("duplicate concurrent growth event %d failed: %v", index, result.err) } switch result.status { case domain.EventStatusConsumed: consumed++ case domain.EventStatusDuplicate: duplicates++ default: t.Fatalf("unexpected duplicate concurrent status %q", result.status) } } if consumed != 1 || duplicates != duplicateWriters-1 { t.Fatalf("duplicate concurrency mismatch: consumed=%d duplicates=%d", consumed, duplicates) } overview, err := svc.GetMyLevelOverview(ctx, 41001) if err != nil { t.Fatalf("get concurrent growth overview: %v", err) } wealth := findTrackOverview(overview.Tracks, domain.TrackWealth) if want := int64(1 + writers + 1); wealth.TotalValue != want { t.Fatalf("concurrent total value=%d want=%d", wealth.TotalValue, want) } } func growthValueEvent(eventID string, userID int64, delta int64) domain.ValueEvent { return domain.ValueEvent{ EventID: eventID, SourceEventID: eventID, SourceService: "room-service", SourceEventType: "RoomGiftSent", UserID: userID, Track: domain.TrackWealth, MetricType: domain.MetricGiftSpendCoin, ValueDelta: delta, OccurredAtMS: fixedGrowthNow().UnixMilli(), DimensionsJSON: `{"room_id":"room-concurrency"}`, } } func TestGrowthLevelRewardGrantsLevelResources(t *testing.T) { svc, wallet := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{ Track: domain.TrackWealth, Level: 1, RequiredValue: 100, Name: "wealth 1", Status: domain.StatusActive, SortOrder: 1, DisplayConfigJSON: `{"avatar_frame_resource_id":801,"short_badge_resource_id":901}`, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed level resource rule failed: created=%v err=%v", created, err) } result, err := svc.ConsumeLevelEvent(ctx, domain.ValueEvent{ EventID: "level-avatar-frame-event-1", SourceEventID: "room-event-avatar-frame-1", SourceService: "room-service", SourceEventType: "RoomGiftSent", UserID: 10002, Track: domain.TrackWealth, MetricType: domain.MetricGiftSpendCoin, ValueDelta: 100, OccurredAtMS: fixedGrowthNow().UnixMilli(), DimensionsJSON: `{"room_id":"room-1"}`, }) if err != nil { t.Fatalf("ConsumeLevelEvent failed: %v", err) } if result.RewardJobCount != 1 { t.Fatalf("level resources should create one reward job: %+v", result) } overview, err := svc.GetMyLevelOverview(ctx, 10002) if err != nil { t.Fatalf("GetMyLevelOverview failed: %v", err) } wealth := findTrackOverview(overview.Tracks, domain.TrackWealth) if wealth.DisplayAvatarFrameResourceID != 801 { t.Fatalf("level avatar frame should override display profile: %+v", wealth) } profiles, err := svc.BatchGetUserLevelDisplayProfiles(ctx, []int64{10002}) if err != nil { t.Fatalf("BatchGetUserLevelDisplayProfiles failed: %v", err) } if len(profiles.Profiles) != 1 || profiles.Profiles[0].Wealth.AvatarFrameResourceID != 801 { t.Fatalf("room display profile should use level avatar frame: %+v", profiles.Profiles) } claimed, processed, success, failure, hasMore, err := svc.ProcessLevelRewardBatch(ctx, "run-avatar-frame", "worker-1", 10, time.Second) if err != nil { t.Fatalf("ProcessLevelRewardBatch failed: %v", err) } if claimed != 1 || processed != 1 || success != 1 || failure != 0 || hasMore { t.Fatalf("reward batch mismatch: claimed=%d processed=%d success=%d failure=%d has_more=%v", claimed, processed, success, failure, hasMore) } if len(wallet.grants) != 0 || len(wallet.resourceGrants) != 2 { t.Fatalf("level resource reward should grant two resources only: groups=%d resources=%d", len(wallet.grants), len(wallet.resourceGrants)) } grantedResources := make(map[int64]*walletv1.GrantResourceRequest, len(wallet.resourceGrants)) for _, req := range wallet.resourceGrants { grantedResources[req.GetResourceId()] = req } for _, resourceID := range []int64{801, 901} { req := grantedResources[resourceID] if req == nil || req.GetDurationMs() != 9999*24*60*60*1000 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel { t.Fatalf("level resource grant mismatch: id=%d grants=%+v", resourceID, wallet.resourceGrants) } } } func TestGrowthRoomGiftEventFeedsWealthAndCharm(t *testing.T) { svc, _ := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") seedGrowthRules(t, ctx, svc, domain.TrackWealth) seedGrowthRules(t, ctx, svc, domain.TrackCharm) body, err := proto.Marshal(&activityevents.RoomGiftSent{ SenderUserId: 20001, TargetUserId: 20002, GiftId: "gift-1", GiftCount: 1, GiftValue: 150, BillingReceiptId: "bill-1", VisibleRegionId: 86, CommandId: "cmd-gift-1", }) if err != nil { t.Fatalf("marshal room gift failed: %v", err) } consumed, err := svc.HandleRoomEvent(ctx, &activityevents.EventEnvelope{ EventId: "room-gift-event-1", RoomId: "room-1", EventType: "RoomGiftSent", RoomVersion: 7, OccurredAtMs: fixedGrowthNow().UnixMilli(), Body: body, AppCode: "lalu", }) if err != nil { t.Fatalf("HandleRoomEvent failed: %v", err) } if consumed != 2 { t.Fatalf("room gift should create wealth and charm events, consumed=%d", consumed) } sender, err := svc.GetLevelTrack(ctx, 20001, domain.TrackWealth) if err != nil { t.Fatalf("GetLevelTrack sender failed: %v", err) } target, err := svc.GetLevelTrack(ctx, 20002, domain.TrackCharm) if err != nil { t.Fatalf("GetLevelTrack target failed: %v", err) } if sender.Overview.Level != 1 || sender.Overview.TotalValue != 150 { t.Fatalf("sender wealth mismatch: %+v", sender.Overview) } if target.Overview.Level != 1 || target.Overview.TotalValue != 150 { t.Fatalf("target charm mismatch: %+v", target.Overview) } } func TestGrowthRoomGiftEventSkipsZeroGiftValue(t *testing.T) { svc, _ := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") body, err := proto.Marshal(&activityevents.RoomGiftSent{ SenderUserId: 20001, TargetUserId: 20002, GiftId: "gift-zero-heat", GiftCount: 1, GiftValue: 0, BillingReceiptId: "bill-zero-heat", VisibleRegionId: 86, CommandId: "cmd-gift-zero-heat", }) if err != nil { t.Fatalf("marshal zero heat room gift failed: %v", err) } consumed, err := svc.HandleRoomEvent(ctx, &activityevents.EventEnvelope{ EventId: "room-gift-event-zero-heat", RoomId: "room-1", EventType: "RoomGiftSent", RoomVersion: 8, OccurredAtMs: fixedGrowthNow().UnixMilli(), Body: body, AppCode: "lalu", }) if err != nil { t.Fatalf("zero heat RoomGiftSent should be accepted and skipped: %v", err) } if consumed != 0 { t.Fatalf("zero heat RoomGiftSent should not create growth events, consumed=%d", consumed) } } func TestListLevelConfigReturnsAdminRulesAndTiers(t *testing.T) { svc, _ := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") seedGrowthRules(t, ctx, svc, domain.TrackGame) config, err := svc.ListLevelConfig(ctx, domain.ConfigQuery{Track: domain.TrackGame}) if err != nil { t.Fatalf("ListLevelConfig failed: %v", err) } if len(config.Tracks) != 1 || config.Tracks[0].Track != domain.TrackGame { t.Fatalf("track config mismatch: %+v", config.Tracks) } if len(config.Rules) != 2 || config.Rules[0].Level != 1 || config.Rules[1].RequiredValue != 200 { t.Fatalf("rule config mismatch: %+v", config.Rules) } if len(config.Tiers) != 2 || config.Tiers[1].DisplayBadgeResourceID != 602 { t.Fatalf("tier config mismatch: %+v", config.Tiers) } if config.ServerTimeMS != fixedGrowthNow().UnixMilli() { t.Fatalf("server time mismatch: %d", config.ServerTimeMS) } } func TestZeroLevelRuleAndTierAreValid(t *testing.T) { svc, _ := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{ Track: domain.TrackWealth, Level: 0, RequiredValue: 0, Name: "wealth 0", Status: domain.StatusActive, SortOrder: 0, DisplayConfigJSON: `{"long_badge_resource_id":700}`, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed level rule 0 failed: created=%v err=%v", created, err) } if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{ Track: domain.TrackWealth, MinLevel: 0, MaxLevel: 9, Name: "wealth 0-9", Status: domain.StatusActive, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed tier 0 failed: created=%v err=%v", created, err) } detail, err := svc.GetLevelTrack(ctx, 30001, domain.TrackWealth) if err != nil { t.Fatalf("GetLevelTrack failed: %v", err) } if detail.Overview.Level != 0 || detail.Overview.CurrentLevelRequiredValue != 0 || detail.Overview.DisplayBadgeResourceID != 700 || detail.Overview.DisplayBadgeSourceLevel != 0 { t.Fatalf("zero level overview mismatch: %+v", detail.Overview) } } func TestIssueRegistrationLevelBadgesGrantsLevelOneBadges(t *testing.T) { svc, wallet := newGrowthService(t) ctx := appcode.WithContext(context.Background(), "lalu") seedGrowthRules(t, ctx, svc, domain.TrackWealth) seedGrowthRules(t, ctx, svc, domain.TrackGame) seedGrowthRules(t, ctx, svc, domain.TrackCharm) grants, err := svc.IssueRegistrationLevelBadges(ctx, 30001, "registration_level_badges:lalu:30001") if err != nil { t.Fatalf("IssueRegistrationLevelBadges failed: %v", err) } if len(grants.Grants) != 3 || len(wallet.resourceGrants) != 3 { t.Fatalf("registration level badge grants mismatch: grants=%+v wallet=%d", grants.Grants, len(wallet.resourceGrants)) } for _, req := range wallet.resourceGrants { if req.GetTargetUserId() != 30001 || req.GetResourceId() != 701 || req.GetQuantity() != 1 || req.GetGrantSource() != domain.GrantSourceGrowthLevel { t.Fatalf("wallet grant request mismatch: %+v", req) } } } func newGrowthService(t *testing.T) (*growthservice.Service, *fakeGrowthWallet) { t.Helper() repository := mysqltest.NewRepository(t) wallet := &fakeGrowthWallet{} svc := growthservice.New(repository, wallet) svc.SetClock(fixedGrowthNow) return svc, wallet } func seedGrowthRules(t *testing.T, ctx context.Context, svc *growthservice.Service, track string) { t.Helper() if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{ Track: track, Level: 1, RequiredValue: 100, Name: track + " 1", Status: domain.StatusActive, RewardResourceGroupID: 0, SortOrder: 1, DisplayConfigJSON: `{"long_badge_resource_id":701}`, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed level rule 1 failed: created=%v err=%v", created, err) } if _, created, err := svc.UpsertLevelRule(ctx, domain.RuleCommand{ Track: track, Level: 2, RequiredValue: 200, Name: track + " 2", Status: domain.StatusActive, RewardResourceGroupID: 9002, SortOrder: 2, DisplayConfigJSON: `{"long_badge_resource_id":702}`, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed level rule 2 failed: created=%v err=%v", created, err) } if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{ Track: track, MinLevel: 1, MaxLevel: 1, Name: track + " 1-1", DisplayAvatarFrameResourceID: 501, DisplayBadgeResourceID: 601, RewardResourceGroupID: 9001, Status: domain.StatusActive, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed tier 1 failed: created=%v err=%v", created, err) } if _, created, err := svc.UpsertLevelTier(ctx, domain.TierCommand{ Track: track, MinLevel: 2, MaxLevel: 10, Name: track + " 2-10", DisplayAvatarFrameResourceID: 502, DisplayBadgeResourceID: 602, RewardResourceGroupID: 9003, Status: domain.StatusActive, OperatorAdminID: 90001, }); err != nil || !created { t.Fatalf("seed tier 2 failed: created=%v err=%v", created, err) } } func fixedGrowthNow() time.Time { return time.Date(2026, 5, 14, 4, 0, 0, 0, time.UTC) } func findTrackOverview(items []domain.TrackOverview, track string) domain.TrackOverview { for _, item := range items { if item.Track == track { return item } } return domain.TrackOverview{} } type fakeGrowthWallet struct { grants []*walletv1.GrantResourceGroupRequest resourceGrants []*walletv1.GrantResourceRequest } func (f *fakeGrowthWallet) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { f.resourceGrants = append(f.resourceGrants, req) return &walletv1.ResourceGrantResponse{ Grant: &walletv1.ResourceGrant{ GrantId: fmt.Sprintf("resource-grant-%d", len(f.resourceGrants)), Status: "succeeded", }, }, nil } func (f *fakeGrowthWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { f.grants = append(f.grants, req) return &walletv1.ResourceGrantResponse{ Grant: &walletv1.ResourceGrant{ GrantId: fmt.Sprintf("grant-%d", len(f.grants)), Status: "succeeded", }, }, nil }