fix: reclaim expired vip badge titles

This commit is contained in:
zhx 2026-07-23 16:10:52 +08:00
parent 180d7ecd8b
commit 680cbc219d
3 changed files with 204 additions and 8 deletions

View File

@ -22,6 +22,7 @@ type Repository interface {
ConsumeAchievementEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error) ConsumeAchievementEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error)
ConsumeWalletBadgeGrantEvent(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) 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) 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) 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) 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 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 { type WalletClient interface {
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) 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. // 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() event.OccurredAtMS = s.now().UnixMilli()
} }
if isWalletBadgeGrantEvent(event) { 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.ConsumeWalletBadgeGrantEvent(ctx, event, s.now().UnixMilli())
} }
return s.repository.ConsumeAchievementEvent(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 { if userID <= 0 {
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "user_id is required") 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) { 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 { if command.Slot != domain.BadgeSlotProfileTile {
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "only profile_tile can be changed by user") 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 { if len(command.Items) > 6 {
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "too many display badges") 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 { if item.ResourceID <= 0 {
return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "badge resource_id is required") 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) { func (s *Service) UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand) (domain.Definition, bool, error) {

View File

@ -122,8 +122,12 @@ func TestAchievementUnlockBadgeAndRewardFlow(t *testing.T) {
} }
func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) { func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) {
svc, _ := newAchievementService(t) svc, wallet := newAchievementService(t)
ctx := appcode.WithContext(context.Background(), "lalu") 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{ result, err := svc.ConsumeAchievementEvent(ctx, domain.Event{
EventID: "wallet-badge-event-1", EventID: "wallet-badge-event-1",
@ -169,6 +173,39 @@ func TestWalletBadgeGrantProjectsDisplaySlots(t *testing.T) {
if err != nil || duplicate.Status != domain.EventStatusConsumed { if err != nil || duplicate.Status != domain.EventStatusConsumed {
t.Fatalf("duplicate wallet badge event should return existing status: result=%+v err=%v", duplicate, err) 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) { func TestDirectBadgeAchievementWithoutConditions(t *testing.T) {
@ -274,7 +311,8 @@ func fixedAchievementNow() time.Time {
} }
type fakeAchievementWallet struct { 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) { 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 }, nil
} }
func (f *fakeAchievementWallet) ListUserResources(_ context.Context, _ *walletv1.ListUserResourcesRequest, _ ...grpc.CallOption) (*walletv1.ListUserResourcesResponse, error) {
return &walletv1.ListUserResourcesResponse{Resources: f.activeBadgeResources}, nil
}

View File

@ -232,6 +232,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom
return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil
} }
projected := int32(0) projected := int32(0)
sourceType := walletBadgeDisplaySourceType(payload.GrantSource)
for _, item := range payload.Items { for _, item := range payload.Items {
if strings.ToLower(strings.TrimSpace(item.ResourceType)) != "badge" || item.ResourceID <= 0 { if strings.ToLower(strings.TrimSpace(item.ResourceType)) != "badge" || item.ResourceID <= 0 {
continue continue
@ -246,7 +247,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom
if count >= 6 { if count >= 6 {
continue 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 return domain.EventResult{}, err
} }
projected++ projected++
@ -255,7 +256,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom
if err != nil { if err != nil {
return domain.EventResult{}, err 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 return domain.EventResult{}, err
} }
projected++ projected++
@ -264,7 +265,7 @@ func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event dom
return domain.EventResult{}, err return domain.EventResult{}, err
} }
if count < 6 { 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 return domain.EventResult{}, err
} }
projected++ projected++
@ -308,6 +309,40 @@ func (r *Repository) ListBadgeProfile(ctx context.Context, userID int64, nowMS i
return profile, nil 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) { func (r *Repository) SetBadgeDisplay(ctx context.Context, command domain.DisplayCommand, nowMS int64) (domain.BadgeProfile, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") 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) { func (r *Repository) listAchievementDefinitionsForEvent(ctx context.Context, q queryer, metricType string, occurredAtMS int64) ([]domain.Definition, error) {
rows, err := q.QueryContext(ctx, achievementDefinitionSelectSQL()+` rows, err := q.QueryContext(ctx, achievementDefinitionSelectSQL()+`
WHERE d.app_code = ? AND d.status = 'active' WHERE d.app_code = ? AND d.status = 'active'