fix: reclaim expired vip badge titles
This commit is contained in:
parent
180d7ecd8b
commit
680cbc219d
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user