package mysql import ( "context" "database/sql" "encoding/json" "errors" "strings" "time" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" domain "hyapp/services/activity-service/internal/domain/achievement" ) // ListAchievements returns user-facing achievements with current progress and reward state. func (r *Repository) ListAchievements(ctx context.Context, query domain.ListQuery, nowMS int64) ([]domain.UserAchievement, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } where, args := achievementDefinitionWhere(ctx, query, nowMS) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM achievement_definitions `+where, args...).Scan(&total); err != nil { return nil, 0, err } pageSize := normalizePageSize(query.PageSize) page := query.Page if page <= 0 { page = 1 } args = append(args, pageSize, (page-1)*pageSize) rows, err := r.db.QueryContext(ctx, achievementDefinitionSelectSQL()+where+` ORDER BY sort_order DESC, updated_at_ms DESC, achievement_id ASC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } definitions, err := scanAchievementDefinitions(rows) _ = rows.Close() if err != nil { return nil, 0, err } for index := range definitions { conditions, err := r.listAchievementConditions(ctx, r.db, definitions[index].AchievementID, definitions[index].Version) if err != nil { return nil, 0, err } definitions[index].Conditions = conditions } items := make([]domain.UserAchievement, 0, len(definitions)) for _, definition := range definitions { item, err := r.userAchievementFromDefinition(ctx, query.UserID, definition) if err != nil { return nil, 0, err } items = append(items, item) } return items, total, nil } // ListAchievementDefinitions returns the operator-facing rule catalog without binding it to a user progress row. func (r *Repository) ListAchievementDefinitions(ctx context.Context, query domain.ListQuery, nowMS int64) ([]domain.Definition, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } where, args := achievementDefinitionAdminWhere(ctx, query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM achievement_definitions `+where, args...).Scan(&total); err != nil { return nil, 0, err } pageSize := normalizePageSize(query.PageSize) page := query.Page if page <= 0 { page = 1 } args = append(args, pageSize, (page-1)*pageSize) rows, err := r.db.QueryContext(ctx, achievementDefinitionSelectSQL()+where+` ORDER BY sort_order DESC, updated_at_ms DESC, achievement_id ASC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } definitions, err := scanAchievementDefinitions(rows) _ = rows.Close() if err != nil { return nil, 0, err } for index := range definitions { conditions, err := r.listAchievementConditions(ctx, r.db, definitions[index].AchievementID, definitions[index].Version) if err != nil { return nil, 0, err } definitions[index].Conditions = conditions } return definitions, total, nil } // ConsumeAchievementEvent applies one server-side fact to matching achievements. func (r *Repository) ConsumeAchievementEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error) { if r == nil || r.db == nil { return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.EventResult{}, err } defer func() { _ = tx.Rollback() }() inserted, existingStatus, err := r.insertAchievementEventConsumption(ctx, tx, event, nowMS) if err != nil { return domain.EventResult{}, err } if !inserted { return domain.EventResult{EventID: event.EventID, Status: existingStatus}, nil } definitions, err := r.listAchievementDefinitionsForEvent(ctx, tx, event.MetricType, event.OccurredAtMS) if err != nil { return domain.EventResult{}, err } matched := int32(0) unlocked := int32(0) rewardJobs := int64(0) for _, definition := range definitions { cycleKey := achievementCycleKey(definition) conditionMatched := false for _, condition := range definition.Conditions { if condition.MetricType != event.MetricType { continue } if err := r.applyAchievementProgressDelta(ctx, tx, event.UserID, definition, condition, cycleKey, event.Value, nowMS); err != nil { return domain.EventResult{}, err } conditionMatched = true } if !conditionMatched { continue } matched++ created, jobCreated, err := r.unlockAchievementIfComplete(ctx, tx, event.UserID, definition, cycleKey, nowMS) if err != nil { return domain.EventResult{}, err } if created { unlocked++ } if jobCreated { rewardJobs++ } } status := domain.EventStatusConsumed skipReason := "" if matched == 0 { status = domain.EventStatusSkipped skipReason = "no_matching_achievement" } if _, err := tx.ExecContext(ctx, ` UPDATE achievement_event_consumption SET status = ?, skip_reason = ?, consumed_at_ms = ? WHERE app_code = ? AND event_id = ?`, status, skipReason, nowMS, appcode.FromContext(ctx), event.EventID, ); err != nil { return domain.EventResult{}, err } if err := tx.Commit(); err != nil { return domain.EventResult{}, err } return domain.EventResult{ EventID: event.EventID, Status: status, MatchedAchievementCount: matched, UnlockedAchievementCount: unlocked, RewardJobCount: rewardJobs, }, nil } type walletBadgeGrantPayload struct { GrantID string `json:"grant_id"` GrantSource string `json:"grant_source"` Items []walletBadgeGrantItem `json:"items"` } type walletBadgeGrantItem struct { ResourceID int64 `json:"resource_id"` ResourceType string `json:"resource_type"` EntitlementID string `json:"entitlement_id"` ResourceSnapshotJSON string `json:"resource_snapshot_json"` MetadataJSON string `json:"metadata_json"` } type walletBadgeMetadata struct { BadgeForm string `json:"badge_form"` DefaultSlot string `json:"default_slot"` } // ConsumeWalletBadgeGrantEvent projects wallet system-granted badge resources into profile and honor display slots. func (r *Repository) ConsumeWalletBadgeGrantEvent(ctx context.Context, event domain.Event, nowMS int64) (domain.EventResult, error) { if r == nil || r.db == nil { return domain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.EventResult{}, err } defer func() { _ = tx.Rollback() }() inserted, existingStatus, err := r.insertAchievementEventConsumption(ctx, tx, event, nowMS) if err != nil { return domain.EventResult{}, err } if !inserted { return domain.EventResult{EventID: event.EventID, Status: existingStatus}, nil } payload, err := parseWalletBadgeGrantPayload(event.DimensionsJSON) if err != nil { if updateErr := r.updateAchievementEventConsumption(ctx, tx, event.EventID, domain.EventStatusSkipped, "invalid_wallet_badge_payload", nowMS); updateErr != nil { return domain.EventResult{}, updateErr } if err := tx.Commit(); err != nil { return domain.EventResult{}, err } return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil } if shouldSkipWalletBadgeGrantSource(payload.GrantSource) { if updateErr := r.updateAchievementEventConsumption(ctx, tx, event.EventID, domain.EventStatusSkipped, "owner_projection_source", nowMS); updateErr != nil { return domain.EventResult{}, updateErr } if err := tx.Commit(); err != nil { return domain.EventResult{}, err } return domain.EventResult{EventID: event.EventID, Status: domain.EventStatusSkipped}, nil } projected := int32(0) for _, item := range payload.Items { if strings.ToLower(strings.TrimSpace(item.ResourceType)) != "badge" || item.ResourceID <= 0 { continue } slot, badgeForm := walletBadgeSlotAndForm(item) switch slot { case domain.BadgeSlotProfileStrip: count, err := r.badgeDisplaySlotCount(ctx, tx, event.UserID, domain.BadgeSlotProfileStrip) if err != nil { return domain.EventResult{}, err } 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 { return domain.EventResult{}, err } projected++ default: position, err := r.nextBadgeDisplayPosition(ctx, tx, event.UserID, domain.BadgeSlotHonorWall) 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 { return domain.EventResult{}, err } projected++ count, err := r.badgeDisplaySlotCount(ctx, tx, event.UserID, domain.BadgeSlotProfileTile) if err != nil { 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 { return domain.EventResult{}, err } projected++ } } } status := domain.EventStatusConsumed skipReason := "" if projected == 0 { status = domain.EventStatusSkipped skipReason = "no_badge_display_slot_projected" } if err := r.updateAchievementEventConsumption(ctx, tx, event.EventID, status, skipReason, nowMS); err != nil { return domain.EventResult{}, err } if err := tx.Commit(); err != nil { return domain.EventResult{}, err } return domain.EventResult{EventID: event.EventID, Status: status, MatchedAchievementCount: projected}, nil } func (r *Repository) ListBadgeProfile(ctx context.Context, userID int64, nowMS int64) (domain.BadgeProfile, error) { if r == nil || r.db == nil { return domain.BadgeProfile{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } items, err := r.listBadgeDisplayItems(ctx, r.db, userID, "") if err != nil { return domain.BadgeProfile{}, err } profile := domain.BadgeProfile{ServerTimeMS: nowMS} for _, item := range items { switch item.Slot { case domain.BadgeSlotProfileStrip: profile.StripBadges = append(profile.StripBadges, item) case domain.BadgeSlotProfileTile: profile.ProfileTileBadges = append(profile.ProfileTileBadges, item) case domain.BadgeSlotHonorWall: profile.HonorBadges = append(profile.HonorBadges, item) } } return profile, 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") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.BadgeProfile{}, err } defer func() { _ = tx.Rollback() }() seen := map[int64]bool{} for _, item := range command.Items { if seen[item.ResourceID] { return domain.BadgeProfile{}, xerr.New(xerr.InvalidArgument, "duplicate badge resource") } seen[item.ResourceID] = true ok, err := r.userHasBadgeDisplayResource(ctx, tx, command.UserID, item.ResourceID) if err != nil { return domain.BadgeProfile{}, err } if !ok { return domain.BadgeProfile{}, xerr.New(xerr.NotFound, "badge resource not available for display") } } if _, err := tx.ExecContext(ctx, ` DELETE FROM user_badge_display_slots WHERE app_code = ? AND user_id = ? AND slot = ?`, appcode.FromContext(ctx), command.UserID, command.Slot, ); err != nil { return domain.BadgeProfile{}, err } for index, item := range command.Items { position := item.Position if position <= 0 { position = int32(index + 1) } if err := r.insertBadgeDisplaySlot(ctx, tx, command.UserID, command.Slot, position, domain.BadgeFormTile, item.ResourceID, item.EntitlementID, item.SourceType, item.SourceID, "user", nowMS); err != nil { return domain.BadgeProfile{}, err } } if err := tx.Commit(); err != nil { return domain.BadgeProfile{}, err } return r.ListBadgeProfile(ctx, command.UserID, nowMS) } func (r *Repository) UpsertAchievementDefinition(ctx context.Context, command domain.DefinitionCommand, nowMS int64) (domain.Definition, bool, error) { if r == nil || r.db == nil { return domain.Definition{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.Definition{}, false, err } defer func() { _ = tx.Rollback() }() created := false achievementID := command.AchievementID version := int64(1) if achievementID == "" { achievementID = idgen.New("ach") created = true _, err = tx.ExecContext(ctx, ` INSERT INTO achievement_definitions ( app_code, achievement_id, collection_id, collection_type, achievement_type, title, description, status, version, primary_badge_resource_id, reward_resource_group_id, auto_pin_policy, honor_visibility, effective_from_ms, effective_to_ms, sort_order, display_config_json, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), achievementID, command.CollectionID, command.CollectionType, command.AchievementType, command.Title, command.Description, command.Status, version, command.PrimaryBadgeResourceID, command.RewardResourceGroupID, command.AutoPinPolicy, command.HonorVisibility, command.EffectiveFromMS, command.EffectiveToMS, command.SortOrder, command.DisplayConfigJSON, command.OperatorAdminID, command.OperatorAdminID, nowMS, nowMS, ) } else { current, err := r.getAchievementDefinitionForUpdate(ctx, tx, achievementID) if err != nil { return domain.Definition{}, false, err } version = current.Version + 1 _, err = tx.ExecContext(ctx, ` UPDATE achievement_definitions SET collection_id = ?, collection_type = ?, achievement_type = ?, title = ?, description = ?, status = ?, version = ?, primary_badge_resource_id = ?, reward_resource_group_id = ?, auto_pin_policy = ?, honor_visibility = ?, effective_from_ms = ?, effective_to_ms = ?, sort_order = ?, display_config_json = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND achievement_id = ?`, command.CollectionID, command.CollectionType, command.AchievementType, command.Title, command.Description, command.Status, version, command.PrimaryBadgeResourceID, command.RewardResourceGroupID, command.AutoPinPolicy, command.HonorVisibility, command.EffectiveFromMS, command.EffectiveToMS, command.SortOrder, command.DisplayConfigJSON, command.OperatorAdminID, nowMS, appcode.FromContext(ctx), achievementID, ) } if err != nil { return domain.Definition{}, false, err } if _, err := tx.ExecContext(ctx, ` DELETE FROM achievement_conditions WHERE app_code = ? AND achievement_id = ?`, appcode.FromContext(ctx), achievementID, ); err != nil { return domain.Definition{}, false, err } for _, condition := range command.Conditions { if _, err := tx.ExecContext(ctx, ` INSERT INTO achievement_conditions ( app_code, condition_id, achievement_id, version, metric_type, target_value, target_unit, dimension_filter_json, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), idgen.New("achcond"), achievementID, version, condition.MetricType, condition.TargetValue, condition.TargetUnit, condition.DimensionFilterJSON, nowMS, ); err != nil { return domain.Definition{}, false, err } } if err := tx.Commit(); err != nil { return domain.Definition{}, false, err } definition, err := r.getAchievementDefinition(ctx, r.db, achievementID) return definition, created, err } func (r *Repository) DeleteAchievementDefinition(ctx context.Context, achievementID string, operatorAdminID int64, nowMS int64) (domain.Definition, error) { if r == nil || r.db == nil { return domain.Definition{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return domain.Definition{}, err } defer func() { _ = tx.Rollback() }() current, err := r.getAchievementDefinitionForUpdate(ctx, tx, achievementID) if err != nil { return domain.Definition{}, err } version := current.Version if current.Status != domain.StatusArchived { version = current.Version + 1 // 归档保留定义主键和历史用户事实,只让 App 与后台默认列表跳过;这样不会破坏用户已有徽章展示引用。 if _, err := tx.ExecContext(ctx, ` UPDATE achievement_definitions SET status = 'archived', version = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND achievement_id = ?`, version, operatorAdminID, nowMS, appcode.FromContext(ctx), achievementID, ); err != nil { return domain.Definition{}, err } // 条件只服务旧事件推进;归档时清掉当前条件,避免恢复脚本或历史事件误匹配已删除配置。 if _, err := tx.ExecContext(ctx, ` DELETE FROM achievement_conditions WHERE app_code = ? AND achievement_id = ?`, appcode.FromContext(ctx), achievementID, ); err != nil { return domain.Definition{}, err } } if err := tx.Commit(); err != nil { return domain.Definition{}, err } return r.getAchievementDefinition(ctx, r.db, achievementID) } func (r *Repository) ClaimPendingAchievementRewardJobs(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.RewardJob, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return nil, err } defer func() { _ = tx.Rollback() }() rows, err := tx.QueryContext(ctx, achievementRewardSelectSQL()+` WHERE app_code = ? AND status IN ('pending', 'failed') AND next_retry_at_ms <= ? AND (locked_until_ms = 0 OR locked_until_ms <= ?) ORDER BY created_at_ms ASC, reward_job_id ASC LIMIT ? FOR UPDATE SKIP LOCKED`, appcode.FromContext(ctx), nowMS, nowMS, batchSize, ) if err != nil { return nil, err } jobs, err := scanAchievementRewards(rows) _ = rows.Close() if err != nil { return nil, err } lockUntil := nowMS + lockTTL.Milliseconds() for _, job := range jobs { if _, err := tx.ExecContext(ctx, ` UPDATE achievement_reward_jobs SET status = 'running', attempt_count = attempt_count + 1, locked_by = ?, locked_until_ms = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, workerID, lockUntil, nowMS, appcode.FromContext(ctx), job.RewardJobID, ); err != nil { return nil, err } } if err := tx.Commit(); err != nil { return nil, err } return jobs, nil } func (r *Repository) MarkAchievementRewardGranted(ctx context.Context, rewardJobID string, walletGrantID string, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } _, err := r.db.ExecContext(ctx, ` UPDATE achievement_reward_jobs SET status = 'granted', wallet_grant_id = ?, locked_by = '', locked_until_ms = 0, failure_reason = '', updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ?`, walletGrantID, nowMS, appcode.FromContext(ctx), rewardJobID, ) return err } func (r *Repository) MarkAchievementRewardFailed(ctx context.Context, rewardJobID string, failureReason string, nextRetryAtMS int64, nowMS int64) error { if r == nil || r.db == nil { return xerr.New(xerr.Unavailable, "mysql repository is not configured") } _, err := r.db.ExecContext(ctx, ` UPDATE achievement_reward_jobs SET status = 'failed', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0, failure_reason = ?, updated_at_ms = ? WHERE app_code = ? AND reward_job_id = ? AND status <> 'granted'`, nextRetryAtMS, truncateAchievementFailure(failureReason), nowMS, appcode.FromContext(ctx), rewardJobID, ) return err } func (r *Repository) userAchievementFromDefinition(ctx context.Context, userID int64, definition domain.Definition) (domain.UserAchievement, error) { cycleKey := achievementCycleKey(definition) progresses, err := r.listAchievementProgress(ctx, r.db, userID, definition.AchievementID, cycleKey) if err != nil { return domain.UserAchievement{}, err } targetTotal := int64(0) for _, condition := range definition.Conditions { targetTotal += condition.TargetValue } if targetTotal == 0 && definition.PrimaryBadgeResourceID > 0 { targetTotal = 1 } progressTotal := int64(0) completed := len(definition.Conditions) > 0 for _, condition := range definition.Conditions { value := progresses[condition.ConditionID] progressTotal += value if value < condition.TargetValue { completed = false } } unlockedAt, unlocked, err := r.getAchievementUnlock(ctx, r.db, userID, definition.AchievementID, cycleKey) if err != nil { return domain.UserAchievement{}, err } if !unlocked && definition.PrimaryBadgeResourceID > 0 { // 新版成就以徽章资源配置为核心,不再强依赖事件条件;用户已经拥有该徽章展示资源时,H5 直接视为已激活。 hasBadge, err := r.userHasBadgeDisplayResource(ctx, r.db, userID, definition.PrimaryBadgeResourceID) if err != nil { return domain.UserAchievement{}, err } if hasBadge { unlocked = true progressTotal = targetTotal } } userStatus := domain.ProgressStatusInProgress if completed { userStatus = domain.ProgressStatusCompleted } if unlocked { userStatus = domain.UnlockStatusUnlocked } rewardStatus, err := r.getAchievementRewardStatus(ctx, r.db, userID, definition.AchievementID, cycleKey) if err != nil { return domain.UserAchievement{}, err } if definition.RewardResourceGroupID == 0 && rewardStatus == "" { rewardStatus = "none" } return domain.UserAchievement{ Definition: definition, CycleKey: cycleKey, ProgressValue: progressTotal, TargetValue: targetTotal, UserStatus: userStatus, UnlockedAtMS: unlockedAt, RewardStatus: rewardStatus, }, nil } func (r *Repository) insertAchievementEventConsumption(ctx context.Context, tx *sql.Tx, event domain.Event, nowMS int64) (bool, string, error) { _, err := tx.ExecContext(ctx, ` INSERT INTO achievement_event_consumption ( app_code, event_id, event_type, source_service, user_id, metric_type, status, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`, appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, event.MetricType, nowMS, ) if err == nil { return true, "", nil } if !isMySQLDuplicate(err) { return false, "", err } var status string if err := tx.QueryRowContext(ctx, ` SELECT status FROM achievement_event_consumption WHERE app_code = ? AND event_id = ?`, appcode.FromContext(ctx), event.EventID, ).Scan(&status); err != nil { return false, "", err } return false, status, nil } func (r *Repository) updateAchievementEventConsumption(ctx context.Context, tx *sql.Tx, eventID string, status string, skipReason string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` UPDATE achievement_event_consumption SET status = ?, skip_reason = ?, consumed_at_ms = ? WHERE app_code = ? AND event_id = ?`, status, truncateAchievementFailure(skipReason), nowMS, appcode.FromContext(ctx), eventID, ) return err } func parseWalletBadgeGrantPayload(value string) (walletBadgeGrantPayload, error) { var payload walletBadgeGrantPayload if err := json.Unmarshal([]byte(value), &payload); err != nil { return walletBadgeGrantPayload{}, err } payload.GrantID = strings.TrimSpace(payload.GrantID) payload.GrantSource = strings.ToLower(strings.TrimSpace(payload.GrantSource)) if payload.GrantID == "" { return walletBadgeGrantPayload{}, xerr.New(xerr.InvalidArgument, "grant_id is required") } return payload, nil } func walletBadgeSlotAndForm(item walletBadgeGrantItem) (string, string) { metadata := walletBadgeMetadata{} metadataJSON := strings.TrimSpace(item.MetadataJSON) if metadataJSON == "" || metadataJSON == "{}" { metadataJSON = metadataJSONFromResourceSnapshot(item.ResourceSnapshotJSON) } _ = json.Unmarshal([]byte(metadataJSON), &metadata) metadata.BadgeForm = strings.ToLower(strings.TrimSpace(metadata.BadgeForm)) metadata.DefaultSlot = strings.ToLower(strings.TrimSpace(metadata.DefaultSlot)) if metadata.BadgeForm == domain.BadgeFormStrip || metadata.DefaultSlot == domain.BadgeSlotProfileStrip { return domain.BadgeSlotProfileStrip, domain.BadgeFormStrip } return domain.BadgeSlotHonorWall, domain.BadgeFormTile } func metadataJSONFromResourceSnapshot(value string) string { var snapshot map[string]json.RawMessage if err := json.Unmarshal([]byte(value), &snapshot); err != nil { return "{}" } for _, key := range []string{"MetadataJSON", "metadata_json"} { raw, ok := snapshot[key] if !ok { continue } var text string if err := json.Unmarshal(raw, &text); err == nil { if strings.TrimSpace(text) != "" { return text } continue } if len(raw) > 0 { return string(raw) } } return "{}" } func shouldSkipWalletBadgeGrantSource(source string) bool { switch strings.ToLower(strings.TrimSpace(source)) { case domain.GrantSourceAchievement, "growth_level", "vip_purchase": return true default: return false } } 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' AND (d.effective_from_ms = 0 OR d.effective_from_ms <= ?) AND (d.effective_to_ms = 0 OR d.effective_to_ms > ?) AND EXISTS ( SELECT 1 FROM achievement_conditions c WHERE c.app_code = d.app_code AND c.achievement_id = d.achievement_id AND c.version = d.version AND c.metric_type = ? ) ORDER BY d.sort_order DESC, d.achievement_id ASC`, appcode.FromContext(ctx), occurredAtMS, occurredAtMS, metricType, ) if err != nil { return nil, err } definitions, err := scanAchievementDefinitions(rows) _ = rows.Close() if err != nil { return nil, err } for index := range definitions { conditions, err := r.listAchievementConditions(ctx, q, definitions[index].AchievementID, definitions[index].Version) if err != nil { return nil, err } definitions[index].Conditions = conditions } return definitions, nil } func (r *Repository) applyAchievementProgressDelta(ctx context.Context, tx *sql.Tx, userID int64, definition domain.Definition, condition domain.Condition, cycleKey string, delta int64, nowMS int64) error { _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO user_achievement_progress ( app_code, user_id, achievement_id, condition_id, cycle_key, progress_value, target_value, status, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 0, ?, 'in_progress', ?)`, appcode.FromContext(ctx), userID, definition.AchievementID, condition.ConditionID, cycleKey, condition.TargetValue, nowMS, ) if err != nil { return err } var current int64 if err := tx.QueryRowContext(ctx, ` SELECT progress_value FROM user_achievement_progress WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND condition_id = ? AND cycle_key = ? FOR UPDATE`, appcode.FromContext(ctx), userID, definition.AchievementID, condition.ConditionID, cycleKey, ).Scan(¤t); err != nil { return err } next := current + delta status := domain.ProgressStatusInProgress if next >= condition.TargetValue { status = domain.ProgressStatusCompleted } _, err = tx.ExecContext(ctx, ` UPDATE user_achievement_progress SET progress_value = ?, target_value = ?, status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND condition_id = ? AND cycle_key = ?`, next, condition.TargetValue, status, nowMS, appcode.FromContext(ctx), userID, definition.AchievementID, condition.ConditionID, cycleKey, ) return err } func (r *Repository) unlockAchievementIfComplete(ctx context.Context, tx *sql.Tx, userID int64, definition domain.Definition, cycleKey string, nowMS int64) (bool, bool, error) { progresses, err := r.listAchievementProgress(ctx, tx, userID, definition.AchievementID, cycleKey) if err != nil { return false, false, err } for _, condition := range definition.Conditions { if progresses[condition.ConditionID] < condition.TargetValue { return false, false, nil } } result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO user_achievement_unlocks ( app_code, user_id, achievement_id, cycle_key, version, status, unlocked_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, 'unlocked', ?, ?, ?)`, appcode.FromContext(ctx), userID, definition.AchievementID, cycleKey, definition.Version, nowMS, nowMS, nowMS, ) if err != nil { return false, false, err } affected, err := result.RowsAffected() if err != nil || affected == 0 { return false, false, err } rewardCreated := false rewardJobID := "" if definition.RewardResourceGroupID > 0 { rewardJobID = idgen.New("achreward") changed, err := r.insertAchievementRewardJob(ctx, tx, rewardJobID, userID, definition.AchievementID, cycleKey, definition.RewardResourceGroupID, nowMS) if err != nil { return false, false, err } rewardCreated = changed if changed { if _, err := tx.ExecContext(ctx, ` UPDATE user_achievement_unlocks SET reward_job_id = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`, rewardJobID, nowMS, appcode.FromContext(ctx), userID, definition.AchievementID, cycleKey, ); err != nil { return false, false, err } } } if definition.PrimaryBadgeResourceID > 0 { if err := r.autoPinAchievementBadge(ctx, tx, userID, definition, nowMS); err != nil { return false, false, err } } return true, rewardCreated, nil } func (r *Repository) insertAchievementRewardJob(ctx context.Context, tx *sql.Tx, rewardJobID string, userID int64, achievementID string, cycleKey string, resourceGroupID int64, nowMS int64) (bool, error) { result, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO achievement_reward_jobs ( app_code, reward_job_id, user_id, achievement_id, cycle_key, resource_group_id, wallet_command_id, status, next_retry_at_ms, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`, appcode.FromContext(ctx), rewardJobID, userID, achievementID, cycleKey, resourceGroupID, "achievement_reward:"+rewardJobID, nowMS, nowMS, ) if err != nil { return false, err } affected, err := result.RowsAffected() return affected > 0, err } func (r *Repository) autoPinAchievementBadge(ctx context.Context, tx *sql.Tx, userID int64, definition domain.Definition, nowMS int64) error { sourceID := definition.AchievementID + ":" + achievementCycleKey(definition) if definition.HonorVisibility { position, err := r.nextBadgeDisplayPosition(ctx, tx, userID, domain.BadgeSlotHonorWall) if err != nil { return err } if err := r.insertBadgeDisplaySlot(ctx, tx, userID, domain.BadgeSlotHonorWall, position, domain.BadgeFormTile, definition.PrimaryBadgeResourceID, "", domain.SourceAchievement, sourceID, "auto", nowMS); err != nil { return err } } switch definition.AutoPinPolicy { case domain.AutoPinIfEmpty: count, err := r.badgeDisplaySlotCount(ctx, tx, userID, domain.BadgeSlotProfileTile) if err != nil { return err } if count == 0 { return r.insertBadgeDisplaySlot(ctx, tx, userID, domain.BadgeSlotProfileTile, 1, domain.BadgeFormTile, definition.PrimaryBadgeResourceID, "", domain.SourceAchievement, sourceID, "auto", nowMS) } case domain.AutoPinAlways: count, err := r.badgeDisplaySlotCount(ctx, tx, userID, domain.BadgeSlotProfileTile) if err != nil { return err } if count < 6 { return r.insertBadgeDisplaySlot(ctx, tx, userID, domain.BadgeSlotProfileTile, int32(count+1), domain.BadgeFormTile, definition.PrimaryBadgeResourceID, "", domain.SourceAchievement, sourceID, "auto", nowMS) } } return nil } func (r *Repository) insertBadgeDisplaySlot(ctx context.Context, tx *sql.Tx, userID int64, slot string, position int32, badgeForm string, resourceID int64, entitlementID string, sourceType string, sourceID string, pinMode string, nowMS int64) error { _, err := tx.ExecContext(ctx, ` INSERT IGNORE INTO user_badge_display_slots ( app_code, user_id, slot, position, badge_form, resource_id, entitlement_id, source_type, source_id, pin_mode, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), userID, slot, position, badgeForm, resourceID, entitlementID, sourceType, sourceID, pinMode, nowMS, ) return err } func (r *Repository) badgeDisplaySlotCount(ctx context.Context, q queryer, userID int64, slot string) (int64, error) { var count int64 err := q.QueryRowContext(ctx, ` SELECT COUNT(*) FROM user_badge_display_slots WHERE app_code = ? AND user_id = ? AND slot = ?`, appcode.FromContext(ctx), userID, slot, ).Scan(&count) return count, err } func (r *Repository) nextBadgeDisplayPosition(ctx context.Context, q queryer, userID int64, slot string) (int32, error) { var position int32 err := q.QueryRowContext(ctx, ` SELECT COALESCE(MAX(position), 0) + 1 FROM user_badge_display_slots WHERE app_code = ? AND user_id = ? AND slot = ?`, appcode.FromContext(ctx), userID, slot, ).Scan(&position) return position, err } func (r *Repository) userHasBadgeDisplayResource(ctx context.Context, q queryer, userID int64, resourceID int64) (bool, error) { var count int64 err := q.QueryRowContext(ctx, ` SELECT COUNT(*) FROM user_badge_display_slots WHERE app_code = ? AND user_id = ? AND resource_id = ? AND slot IN ('honor_wall', 'profile_tile')`, appcode.FromContext(ctx), userID, resourceID, ).Scan(&count) return count > 0, err } func (r *Repository) listBadgeDisplayItems(ctx context.Context, q queryer, userID int64, slot string) ([]domain.BadgeDisplayItem, error) { where := []string{"app_code = ?", "user_id = ?"} args := []any{appcode.FromContext(ctx), userID} if slot != "" { where = append(where, "slot = ?") args = append(args, slot) } rows, err := q.QueryContext(ctx, ` SELECT user_id, slot, position, badge_form, resource_id, entitlement_id, source_type, source_id, pin_mode, updated_at_ms FROM user_badge_display_slots WHERE `+strings.Join(where, " AND ")+` ORDER BY slot ASC, position ASC`, args...) if err != nil { return nil, err } defer rows.Close() items := make([]domain.BadgeDisplayItem, 0) for rows.Next() { item, err := scanBadgeDisplayItem(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (r *Repository) listAchievementProgress(ctx context.Context, q queryer, userID int64, achievementID string, cycleKey string) (map[string]int64, error) { rows, err := q.QueryContext(ctx, ` SELECT condition_id, progress_value FROM user_achievement_progress WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`, appcode.FromContext(ctx), userID, achievementID, cycleKey, ) if err != nil { return nil, err } defer rows.Close() result := map[string]int64{} for rows.Next() { var conditionID string var progressValue int64 if err := rows.Scan(&conditionID, &progressValue); err != nil { return nil, err } result[conditionID] = progressValue } return result, rows.Err() } func (r *Repository) getAchievementUnlock(ctx context.Context, q queryer, userID int64, achievementID string, cycleKey string) (int64, bool, error) { var unlockedAt int64 err := q.QueryRowContext(ctx, ` SELECT unlocked_at_ms FROM user_achievement_unlocks WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`, appcode.FromContext(ctx), userID, achievementID, cycleKey, ).Scan(&unlockedAt) if errors.Is(err, sql.ErrNoRows) { return 0, false, nil } return unlockedAt, err == nil, err } func (r *Repository) getAchievementRewardStatus(ctx context.Context, q queryer, userID int64, achievementID string, cycleKey string) (string, error) { var status string err := q.QueryRowContext(ctx, ` SELECT status FROM achievement_reward_jobs WHERE app_code = ? AND user_id = ? AND achievement_id = ? AND cycle_key = ?`, appcode.FromContext(ctx), userID, achievementID, cycleKey, ).Scan(&status) if errors.Is(err, sql.ErrNoRows) { return "", nil } return status, err } func (r *Repository) getAchievementDefinitionForUpdate(ctx context.Context, tx *sql.Tx, achievementID string) (domain.Definition, error) { row := tx.QueryRowContext(ctx, achievementDefinitionSelectSQL()+` WHERE d.app_code = ? AND d.achievement_id = ? FOR UPDATE`, appcode.FromContext(ctx), achievementID, ) item, err := scanAchievementDefinition(row) if errors.Is(err, sql.ErrNoRows) { return domain.Definition{}, xerr.New(xerr.NotFound, "achievement definition not found") } return item, err } func (r *Repository) getAchievementDefinition(ctx context.Context, q queryer, achievementID string) (domain.Definition, error) { row := q.QueryRowContext(ctx, achievementDefinitionSelectSQL()+` WHERE d.app_code = ? AND d.achievement_id = ?`, appcode.FromContext(ctx), achievementID, ) item, err := scanAchievementDefinition(row) if errors.Is(err, sql.ErrNoRows) { return domain.Definition{}, xerr.New(xerr.NotFound, "achievement definition not found") } if err != nil { return domain.Definition{}, err } conditions, err := r.listAchievementConditions(ctx, q, item.AchievementID, item.Version) if err != nil { return domain.Definition{}, err } item.Conditions = conditions return item, nil } func (r *Repository) listAchievementConditions(ctx context.Context, q queryer, achievementID string, version int64) ([]domain.Condition, error) { rows, err := q.QueryContext(ctx, ` SELECT app_code, condition_id, achievement_id, version, metric_type, target_value, target_unit, COALESCE(CAST(dimension_filter_json AS CHAR), '{}'), created_at_ms FROM achievement_conditions WHERE app_code = ? AND achievement_id = ? AND version = ? ORDER BY condition_id ASC`, appcode.FromContext(ctx), achievementID, version, ) if err != nil { return nil, err } defer rows.Close() items := make([]domain.Condition, 0) for rows.Next() { var item domain.Condition if err := rows.Scan(&item.AppCode, &item.ConditionID, &item.AchievementID, &item.Version, &item.MetricType, &item.TargetValue, &item.TargetUnit, &item.DimensionFilterJSON, &item.CreatedAtMS); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func achievementDefinitionWhere(ctx context.Context, query domain.ListQuery, nowMS int64) (string, []any) { conditions := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} if query.Status != "" { conditions = append(conditions, "status = ?") args = append(args, query.Status) } else { conditions = append(conditions, "status = 'active'") conditions = append(conditions, "(effective_from_ms = 0 OR effective_from_ms <= ?)") args = append(args, nowMS) conditions = append(conditions, "(effective_to_ms = 0 OR effective_to_ms > ?)") args = append(args, nowMS) } if query.CollectionType != "" { conditions = append(conditions, "collection_type = ?") args = append(args, query.CollectionType) } if query.CollectionID != "" { conditions = append(conditions, "collection_id = ?") args = append(args, query.CollectionID) } return "WHERE " + strings.Join(conditions, " AND "), args } func achievementDefinitionAdminWhere(ctx context.Context, query domain.ListQuery) (string, []any) { conditions := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} if query.Status != "" { conditions = append(conditions, "status = ?") args = append(args, query.Status) } else { conditions = append(conditions, "status <> 'archived'") } if query.CollectionType != "" { conditions = append(conditions, "collection_type = ?") args = append(args, query.CollectionType) } if query.CollectionID != "" { conditions = append(conditions, "collection_id = ?") args = append(args, query.CollectionID) } return "WHERE " + strings.Join(conditions, " AND "), args } func achievementCycleKey(definition domain.Definition) string { if definition.AchievementType == domain.TypeActivityLimited { if definition.CollectionID != "" { return "activity:" + definition.CollectionID } return "activity:" + definition.AchievementID } return domain.CycleLifetime } func scanAchievementDefinitions(rows *sql.Rows) ([]domain.Definition, error) { items := make([]domain.Definition, 0) for rows.Next() { item, err := scanAchievementDefinition(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func scanAchievementDefinition(row rowScanner) (domain.Definition, error) { var item domain.Definition err := row.Scan( &item.AppCode, &item.AchievementID, &item.CollectionID, &item.CollectionType, &item.AchievementType, &item.Title, &item.Description, &item.Status, &item.Version, &item.PrimaryBadgeResourceID, &item.RewardResourceGroupID, &item.AutoPinPolicy, &item.HonorVisibility, &item.EffectiveFromMS, &item.EffectiveToMS, &item.SortOrder, &item.DisplayConfigJSON, &item.CreatedByAdminID, &item.UpdatedByAdminID, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func scanAchievementRewards(rows *sql.Rows) ([]domain.RewardJob, error) { items := make([]domain.RewardJob, 0) for rows.Next() { item, err := scanAchievementReward(rows) if err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func scanAchievementReward(row rowScanner) (domain.RewardJob, error) { var item domain.RewardJob err := row.Scan( &item.AppCode, &item.RewardJobID, &item.UserID, &item.AchievementID, &item.CycleKey, &item.ResourceGroupID, &item.WalletCommandID, &item.WalletGrantID, &item.Status, &item.AttemptCount, &item.FailureReason, &item.CreatedAtMS, &item.UpdatedAtMS, ) return item, err } func scanBadgeDisplayItem(row rowScanner) (domain.BadgeDisplayItem, error) { var item domain.BadgeDisplayItem err := row.Scan( &item.UserID, &item.Slot, &item.Position, &item.BadgeForm, &item.ResourceID, &item.EntitlementID, &item.SourceType, &item.SourceID, &item.PinMode, &item.UpdatedAtMS, ) return item, err } func achievementDefinitionSelectSQL() string { return `SELECT d.app_code, d.achievement_id, d.collection_id, d.collection_type, d.achievement_type, d.title, d.description, d.status, d.version, d.primary_badge_resource_id, d.reward_resource_group_id, d.auto_pin_policy, d.honor_visibility, d.effective_from_ms, d.effective_to_ms, d.sort_order, COALESCE(CAST(d.display_config_json AS CHAR), '{}'), d.created_by_admin_id, d.updated_by_admin_id, d.created_at_ms, d.updated_at_ms FROM achievement_definitions d ` } func achievementRewardSelectSQL() string { return `SELECT app_code, reward_job_id, user_id, achievement_id, cycle_key, resource_group_id, wallet_command_id, wallet_grant_id, status, attempt_count, failure_reason, created_at_ms, updated_at_ms FROM achievement_reward_jobs ` } func truncateAchievementFailure(value string) string { value = strings.TrimSpace(value) if len(value) <= 255 { return value } return value[:255] }