1123 lines
41 KiB
Go
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(¤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)
|
|
}
|
|
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]
|
|
}
|