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)
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) {

View File

@ -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) {
@ -275,6 +312,7 @@ func fixedAchievementNow() time.Time {
type fakeAchievementWallet struct {
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
}

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
}
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'