diff --git a/services/activity-service/internal/service/achievement/service.go b/services/activity-service/internal/service/achievement/service.go index 1cfe0208..5d88704b 100644 --- a/services/activity-service/internal/service/achievement/service.go +++ b/services/activity-service/internal/service/achievement/service.go @@ -22,6 +22,7 @@ type Repository interface { ConsumeAchievementEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error) ConsumeWalletBadgeGrantEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error) ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error) + DeleteBadgeDisplayItems(ctx context.Context, userID int64, items []domain.BadgeDisplayItem) error SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, nowMS int64) (domain.BadgeProfile, error) UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand, nowMS int64) (domain.Definition, bool, error) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, error) @@ -30,9 +31,10 @@ type Repository interface { MarkAchievementRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error } -// WalletClient grants achievement reward resource groups. +// WalletClient grants achievement rewards and validates entitlement-backed badge display slots. type WalletClient interface { GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) + ListUserResources(ctx context.Context, req *walletv1.ListUserResourcesRequest, opts ...grpc.CallOption) (*walletv1.ListUserResourcesResponse, error) } // Service handles achievement reads, fact consumption, badge display and reward retries. @@ -112,6 +114,11 @@ func (s *Service) ConsumeAchievementEvent(ctx context.Context, event domain.Even event.OccurredAtMS = s.now().UnixMilli() } if isWalletBadgeGrantEvent(event) { + // 先按 wallet 当前权益回收旧展示槽,再投影本次发放。否则已到期的体验卡徽章仍会占用 + // profile_strip 的 6 个位置,使后续新体验卡的有效徽章被永久跳过。 + if _, err := s.reconciledBadgeProfile(ctx, event.UserID); err != nil { + return domain.EventResult{}, err + } return s.repository.ConsumeWalletBadgeGrantEvent(ctx, event, s.now().UnixMilli()) } return s.repository.ConsumeAchievementEvent(ctx, event, s.now().UnixMilli()) @@ -124,7 +131,7 @@ func (s *Service) ListBadgeProfile(ctx context.Context, userID int64) (domain.Ba if userID <= 0 { return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "user_id is required") } - return s.repository.ListBadgeProfile(ctx, userID, s.now().UnixMilli()) + return s.reconciledBadgeProfile(ctx, userID) } func (s *Service) SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand) (domain.BadgeProfile, error) { @@ -139,6 +146,12 @@ func (s *Service) SetBadgeDisplay(ctx context.Context, command domain.DisplayCom if command.Slot != domain.BadgeSlotProfileTile { return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "only profile_tile can be changed by user") } + // 用户选择前先移除失效 entitlement 对应的旧槽,避免 repository 仅凭历史投影放行已过期徽章。 + availableProfile, err := s.reconciledBadgeProfile(ctx, command.UserID) + if err != nil { + return domain.BadgeProfile{}, err + } + activeEntitlements := badgeDisplayEntitlementsByResource(availableProfile) if len(command.Items) > 6 { return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "too many display badges") } @@ -157,8 +170,104 @@ func (s *Service) SetBadgeDisplay(ctx context.Context, command domain.DisplayCom if item.ResourceID <= 0 { return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "badge resource_id is required") } + // entitlement_id 是 wallet 事实,不能接受客户端回传的旧实例。成就型徽章没有 + // entitlement_id,会继续保持空值并由 achievement 解锁事实授权。 + item.EntitlementID = activeEntitlements[item.ResourceID] } - return s.repository.SetBadgeDisplay(ctx, command, s.now().UnixMilli()) + if _, err := s.repository.SetBadgeDisplay(ctx, command, s.now().UnixMilli()); err != nil { + return domain.BadgeProfile{}, err + } + // 写入和返回之间权益仍可能刚好到期;最终响应继续以 wallet 当前有效事实收口。 + return s.reconciledBadgeProfile(ctx, command.UserID) +} + +func (s *Service) reconciledBadgeProfile(ctx context.Context, userID int64) (domain.BadgeProfile, error) { + profile, err := s.repository.ListBadgeProfile(ctx, userID, s.now().UnixMilli()) + if err != nil { + return domain.BadgeProfile{}, err + } + if !badgeProfileHasEntitlements(profile) { + // 成就、等级等计算型展示没有 entitlement_id,不需要为纯投影额外访问 wallet。 + return profile, nil + } + if s.wallet == nil { + // 无法确认权益时失败关闭,不能继续把可能已过期的 VIP/系统赠送徽章返回客户端。 + return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "wallet client is not configured") + } + active, err := s.wallet.ListUserResources(ctx, &walletv1.ListUserResourcesRequest{ + AppCode: appcode.FromContext(ctx), + UserId: userID, + ResourceType: "badge", + ActiveOnly: true, + }) + if err != nil { + return domain.BadgeProfile{}, err + } + // 同一素材续期可能生成新的 entitlement_id;展示资格按当前有效 resource_id 判断,同时把 + // 返回值替换成最新有效实例,避免客户端继续拿到上一张体验卡的过期 entitlement_id。 + activeEntitlementByResource := make(map[int64]string, len(active.GetResources())) + for _, item := range active.GetResources() { + if item.GetResourceId() <= 0 || item.GetEntitlementId() == "" { + continue + } + if _, exists := activeEntitlementByResource[item.GetResourceId()]; !exists { + activeEntitlementByResource[item.GetResourceId()] = item.GetEntitlementId() + } + } + + stale := make([]domain.BadgeDisplayItem, 0) + profile.StripBadges, stale = filterActiveBadgeDisplayItems(profile.StripBadges, activeEntitlementByResource, stale) + profile.ProfileTileBadges, stale = filterActiveBadgeDisplayItems(profile.ProfileTileBadges, activeEntitlementByResource, stale) + profile.HonorBadges, stale = filterActiveBadgeDisplayItems(profile.HonorBadges, activeEntitlementByResource, stale) + if len(stale) > 0 { + // 删除条件包含用户、槽位、位置、资源和旧 entitlement,和并发的新投影不相互覆盖。 + if err := s.repository.DeleteBadgeDisplayItems(ctx, userID, stale); err != nil { + return domain.BadgeProfile{}, err + } + } + return profile, nil +} + +func badgeProfileHasEntitlements(profile domain.BadgeProfile) bool { + for _, items := range [][]domain.BadgeDisplayItem{profile.StripBadges, profile.ProfileTileBadges, profile.HonorBadges} { + for _, item := range items { + if item.EntitlementID != "" { + return true + } + } + } + return false +} + +func badgeDisplayEntitlementsByResource(profile domain.BadgeProfile) map[int64]string { + result := make(map[int64]string) + for _, items := range [][]domain.BadgeDisplayItem{profile.ProfileTileBadges, profile.HonorBadges} { + for _, item := range items { + if item.ResourceID <= 0 || item.EntitlementID == "" { + continue + } + result[item.ResourceID] = item.EntitlementID + } + } + return result +} + +func filterActiveBadgeDisplayItems(items []domain.BadgeDisplayItem, active map[int64]string, stale []domain.BadgeDisplayItem) ([]domain.BadgeDisplayItem, []domain.BadgeDisplayItem) { + filtered := make([]domain.BadgeDisplayItem, 0, len(items)) + for _, item := range items { + if item.EntitlementID == "" { + filtered = append(filtered, item) + continue + } + entitlementID, ok := active[item.ResourceID] + if !ok { + stale = append(stale, item) + continue + } + item.EntitlementID = entitlementID + filtered = append(filtered, item) + } + return filtered, stale } func (s *Service) UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand) (domain.Definition, bool, error) { diff --git a/services/activity-service/internal/service/achievement/service_test.go b/services/activity-service/internal/service/achievement/service_test.go index 4e7f50c7..c99b7fde 100644 --- a/services/activity-service/internal/service/achievement/service_test.go +++ b/services/activity-service/internal/service/achievement/service_test.go @@ -122,8 +122,12 @@ func TestAchievementUnlockBadgeAndRewardFlow(t *testing.T) { } func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) { - svc, _ := newAchievementService(t) + svc, wallet := newAchievementService(t) ctx := appcode.WithContext(context.Background(), "lalu") + wallet.activeBadgeResources = []*walletv1.UserResourceEntitlement{ + {EntitlementId: "ent-badge-1", ResourceId: 8001}, + {EntitlementId: "ent-badge-2", ResourceId: 8002}, + } result, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ EventID: "wallet-badge-event-1", @@ -169,6 +173,39 @@ func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) { if err != nil || duplicate.Status != domain.EventStatusConsumed { t.Fatalf("duplicate wallet badge event should return existing status: result=%+v err=%v", duplicate, err) } + + // 体验卡/限时赠送到期后 wallet 的 active 列表不再包含旧资源;读取必须立即回收三个旧槽。 + wallet.activeBadgeResources = nil + expiredProfile, err := svc.ListBadgeProfile(ctx, 10001) + if err != nil { + t.Fatalf("ListBadgeProfile after badge expiry failed: %v", err) + } + if len(expiredProfile.StripBadges) != 0 || len(expiredProfile.ProfileTileBadges) != 0 || len(expiredProfile.HonorBadges) != 0 { + t.Fatalf("expired entitlement-backed badges must be reclaimed: %+v", expiredProfile) + } + + // 回收后新的 VIP 体验卡要能重新占用首个长徽章位,不能被旧的过期投影挤到后面或跳过。 + wallet.activeBadgeResources = []*walletv1.UserResourceEntitlement{{EntitlementId: "ent-vip-trial-3", ResourceId: 8003}} + if _, err := svc.ConsumeAchievementEvent(ctx, domain.Event{ + EventID: "wallet-badge-event-vip-trial-3", + EventType: "ResourceGroupGranted", + SourceService: "wallet-service", + UserID: 10001, + MetricType: "wallet_badge_grant", + Value: 1, + OccurredAtMS: fixedAchievementNow().UnixMilli(), + DimensionsJSON: `{"grant_id":"grant-vip-trial-3","grant_source":"vip_trial","items":[{"resource_id":8003,"resource_type":"badge","entitlement_id":"ent-vip-trial-3","metadata_json":"{\"badge_form\":\"strip\",\"default_slot\":\"profile_strip\"}"}]}`, + }); err != nil { + t.Fatalf("project replacement VIP trial badge failed: %v", err) + } + replacedProfile, err := svc.ListBadgeProfile(ctx, 10001) + if err != nil { + t.Fatalf("ListBadgeProfile replacement VIP badge failed: %v", err) + } + if len(replacedProfile.StripBadges) != 1 || replacedProfile.StripBadges[0].Position != 1 || + replacedProfile.StripBadges[0].ResourceID != 8003 || replacedProfile.StripBadges[0].SourceType != domain.SourceVIP { + t.Fatalf("replacement VIP badge projection mismatch: %+v", replacedProfile) + } } func TestDirectBadgeAchievementWithoutConditions(t *testing.T) { @@ -274,7 +311,8 @@ func fixedAchievementNow() time.Time { } type fakeAchievementWallet struct { - grants []*walletv1.GrantResourceGroupRequest + grants []*walletv1.GrantResourceGroupRequest + activeBadgeResources []*walletv1.UserResourceEntitlement } func (f *fakeAchievementWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) { @@ -286,3 +324,7 @@ func (f *fakeAchievementWallet) GrantResourceGroup(_ context.Context, req *walle }, }, nil } + +func (f *fakeAchievementWallet) ListUserResources(_ context.Context, _ *walletv1.ListUserResourcesRequest, _ ...grpc.CallOption) (*walletv1.ListUserResourcesResponse, error) { + return &walletv1.ListUserResourcesResponse{Resources: f.activeBadgeResources}, nil +} diff --git a/services/activity-service/internal/storage/mysql/achievement_repository.go b/services/activity-service/internal/storage/mysql/achievement_repository.go index 6d662dad..f2b7c455 100644 --- a/services/activity-service/internal/storage/mysql/achievement_repository.go +++ b/services/activity-service/internal/storage/mysql/achievement_repository.go @@ -232,6 +232,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil } projected := int32(0) + sourceType := walletBadgeDisplaySourceType(payload.GrantSource) for _, item := range payload.Items { if strings.ToLower(strings.TrimSpace(item.ResourceType)) != "badge" || item.ResourceID <= 0 { continue @@ -246,7 +247,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom if count >= 6 { continue } - if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotProfileStrip, int32(count+1), badgeForm, item.ResourceID, item.EntitlementID, domain.SourceSystemGrant, payload.GrantID, "auto", nowMS); err != nil { + if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotProfileStrip, int32(count+1), badgeForm, item.ResourceID, item.EntitlementID, sourceType, payload.GrantID, "auto", nowMS); err != nil { return domain.EventResult{}, err } projected++ @@ -255,7 +256,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom if err != nil { return domain.EventResult{}, err } - if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotHonorWall, position, domain.BadgeFormTile, item.ResourceID, item.EntitlementID, domain.SourceSystemGrant, payload.GrantID, "auto", nowMS); err != nil { + if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotHonorWall, position, domain.BadgeFormTile, item.ResourceID, item.EntitlementID, sourceType, payload.GrantID, "auto", nowMS); err != nil { return domain.EventResult{}, err } projected++ @@ -264,7 +265,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom return domain.EventResult{}, err } if count < 6 { - if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotProfileTile, int32(count+1), domain.BadgeFormTile, item.ResourceID, item.EntitlementID, domain.SourceSystemGrant, payload.GrantID, "auto", nowMS); err != nil { + if err := r.insertBadgeDisplaySlot(ctx, tx, event.UserID, domain.BadgeSlotProfileTile, int32(count+1), domain.BadgeFormTile, item.ResourceID, item.EntitlementID, sourceType, payload.GrantID, "auto", nowMS); err != nil { return domain.EventResult{}, err } projected++ @@ -308,6 +309,40 @@ func (r *Repository) ListBadgeProfile(ctx context.Context, userID int64, nowMS i return profile, nil } +func (r *Repository) DeleteBadgeDisplayItems(ctx context.Context, userID int64, items []domain.BadgeDisplayItem) error { + if r == nil || r.db == nil { + return xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if userID <= 0 || len(items) == 0 { + return nil + } + const batchSize = 50 + for start := 0; start < len(items); start += batchSize { + end := min(start+batchSize, len(items)) + predicates := make([]string, 0, end-start) + args := []any{appcode.FromContext(ctx), userID} + for _, item := range items[start:end] { + if item.Slot == "" || item.Position <= 0 || item.ResourceID <= 0 || item.EntitlementID == "" { + continue + } + // 主键前缀先把扫描范围锁定到单个用户,再用 slot/position 精确命中;附加 + // resource_id/entitlement_id 可防止并发新投影复用同一位置时被旧回收请求误删。 + predicates = append(predicates, `(slot = ? AND position = ? AND resource_id = ? AND entitlement_id = ?)`) + args = append(args, item.Slot, item.Position, item.ResourceID, item.EntitlementID) + } + if len(predicates) == 0 { + continue + } + if _, err := r.db.ExecContext(ctx, ` + DELETE FROM user_badge_display_slots + WHERE app_code = ? AND user_id = ? + AND (`+strings.Join(predicates, " OR ")+`)`, args...); err != nil { + return err + } + } + return nil +} + func (r *Repository) SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, nowMS int64) (domain.BadgeProfile, error) { if r == nil || r.db == nil { return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") @@ -698,6 +733,16 @@ func shouldSkipWalletBadgeGrantSource(source string) bool { } } +func walletBadgeDisplaySourceType(source string) string { + switch strings.ToLower(strings.TrimSpace(source)) { + case "vip_paid", "vip_trial", "vip_purchase": + // VIP 长徽章由 wallet 当前有效会员/体验卡权益决定,显式来源让所有客户端无需猜资源名。 + return domain.SourceVIP + default: + return domain.SourceSystemGrant + } +} + func (r *Repository) listAchievementDefinitionsForEvent(ctx context.Context, q queryer, metricType string, occurredAtMS int64) ([]domain.Definition, error) { rows, err := q.QueryContext(ctx, achievementDefinitionSelectSQL()+` WHERE d.app_code = ? AND d.status = 'active'