1180 lines
43 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(&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)
} 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]
}