2026-05-14 17:44:16 +08:00

1123 lines
41 KiB
Go

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 ASC, 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 ASC, 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) 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
}
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
}
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 ASC, 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(&current); 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)
}
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]
}