package mysql import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "hyapp/pkg/appcode" "hyapp/pkg/idgen" "hyapp/pkg/xerr" taskdomain "hyapp/services/activity-service/internal/domain/task" ) // ListVisibleDefinitions 返回 App 当前可见任务;查询路径不初始化用户进度,避免 daily 刷新产生批量写。 func (r *Repository) ListVisibleDefinitions(ctx context.Context, nowMS int64) ([]taskdomain.Definition, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } rows, err := r.db.QueryContext(ctx, ` SELECT app_code, task_id, task_type, category, metric_type, title, description, target_value, target_unit, reward_coin_amount, status, sort_order, version, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM task_definitions WHERE app_code = ? AND status = 'active' AND (effective_from_ms = 0 OR effective_from_ms <= ?) AND (effective_to_ms = 0 OR effective_to_ms > ?) ORDER BY sort_order ASC, task_id ASC`, appcode.FromContext(ctx), nowMS, nowMS, ) if err != nil { return nil, err } defer rows.Close() return scanDefinitions(rows) } // ListUserProgress 批量读取当前用户 daily/lifetime 进度,缺失进度由 service 按 0 渲染。 func (r *Repository) ListUserProgress(ctx context.Context, userID int64, cycleKeys []string) ([]taskdomain.Progress, error) { if r == nil || r.db == nil { return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") } cycleKeys = normalizeCycleKeys(cycleKeys) if len(cycleKeys) == 0 { return nil, nil } placeholders := strings.TrimRight(strings.Repeat("?,", len(cycleKeys)), ",") args := []any{appcode.FromContext(ctx), userID} for _, cycleKey := range cycleKeys { args = append(args, cycleKey) } rows, err := r.db.QueryContext(ctx, ` SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value, target_value, reward_coin_amount, status, completed_at_ms, claimed_at_ms, updated_at_ms FROM user_task_progress WHERE app_code = ? AND user_id = ? AND cycle_key IN (`+placeholders+`)`, args...) if err != nil { return nil, err } defer rows.Close() progresses := make([]taskdomain.Progress, 0) for rows.Next() { progress, err := scanProgress(rows) if err != nil { return nil, err } progresses = append(progresses, progress) } if err := rows.Err(); err != nil { return nil, err } return progresses, nil } // ConsumeTaskEvent 在单事务内完成事件幂等、任务匹配和用户进度累加。 func (r *Repository) ConsumeTaskEvent(ctx context.Context, event taskdomain.Event, cycleKey string, nowMS int64) (taskdomain.EventResult, error) { if r == nil || r.db == nil { return taskdomain.EventResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return taskdomain.EventResult{}, err } defer func() { _ = tx.Rollback() }() inserted, existingStatus, err := r.insertTaskEventConsumption(ctx, tx, event, cycleKey, nowMS) if err != nil { return taskdomain.EventResult{}, err } if !inserted { return taskdomain.EventResult{EventID: event.EventID, Status: existingStatus}, nil } definitions, err := r.listDefinitionsForEvent(ctx, tx, event.MetricType, event.OccurredAtMS) if err != nil { return taskdomain.EventResult{}, err } matched := int32(0) for _, definition := range definitions { progressCycle := cycleKey if definition.TaskType == taskdomain.TypeExclusive { progressCycle = taskdomain.CycleLifetime } changed, err := r.applyTaskProgressDelta(ctx, tx, event.UserID, definition, progressCycle, event.Value, nowMS) if err != nil { return taskdomain.EventResult{}, err } if changed { matched++ } } status := taskdomain.EventStatusConsumed skipReason := "" if matched == 0 { status = taskdomain.EventStatusSkipped skipReason = "no_matching_task" } if _, err := tx.ExecContext(ctx, ` UPDATE task_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 taskdomain.EventResult{}, err } if err := tx.Commit(); err != nil { return taskdomain.EventResult{}, err } return taskdomain.EventResult{EventID: event.EventID, Status: status, MatchedTaskCount: matched}, nil } // ListTaskDefinitions 是后台任务配置分页查询;keyword 只匹配任务 ID、标题和指标,避免扫描大 JSON。 func (r *Repository) ListTaskDefinitions(ctx context.Context, query taskdomain.DefinitionQuery) ([]taskdomain.Definition, int64, error) { if r == nil || r.db == nil { return nil, 0, xerr.New(xerr.Unavailable, "mysql repository is not configured") } where, args := taskDefinitionWhere(ctx, query) var total int64 if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM task_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, ` SELECT app_code, task_id, task_type, category, metric_type, title, description, target_value, target_unit, reward_coin_amount, status, sort_order, version, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM task_definitions `+where+` ORDER BY sort_order ASC, updated_at_ms DESC, task_id ASC LIMIT ? OFFSET ?`, args...) if err != nil { return nil, 0, err } defer rows.Close() definitions, err := scanDefinitions(rows) if err != nil { return nil, 0, err } return definitions, total, nil } // UpsertTaskDefinition 创建或更新任务定义;领奖关键字段变更时写新版本快照。 func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, bool, error) { if r == nil || r.db == nil { return taskdomain.Definition{}, false, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return taskdomain.Definition{}, false, err } defer func() { _ = tx.Rollback() }() if strings.TrimSpace(command.TaskID) == "" { definition, err := r.createTaskDefinition(ctx, tx, command, nowMS) if err != nil { return taskdomain.Definition{}, false, err } if err := tx.Commit(); err != nil { return taskdomain.Definition{}, false, err } return definition, true, nil } current, err := r.getTaskDefinitionForUpdate(ctx, tx, command.TaskID) if err != nil { return taskdomain.Definition{}, false, err } next := current next.TaskType = command.TaskType next.Category = command.Category next.MetricType = command.MetricType next.Title = command.Title next.Description = command.Description next.TargetValue = command.TargetValue next.TargetUnit = command.TargetUnit next.RewardCoinAmount = command.RewardCoinAmount next.Status = command.Status next.SortOrder = command.SortOrder next.EffectiveFromMS = command.EffectiveFromMS next.EffectiveToMS = command.EffectiveToMS next.UpdatedByAdminID = command.OperatorAdminID next.UpdatedAtMS = nowMS if taskDefinitionNeedsVersion(current, next) { next.Version = current.Version + 1 versionID, err := r.insertTaskDefinitionVersion(ctx, tx, next, nowMS) if err != nil { return taskdomain.Definition{}, false, err } next.CurrentVersionID = versionID } if _, err := tx.ExecContext(ctx, ` UPDATE task_definitions SET task_type = ?, category = ?, metric_type = ?, title = ?, description = ?, target_value = ?, target_unit = ?, reward_coin_amount = ?, status = ?, sort_order = ?, version = ?, current_task_version_id = ?, effective_from_ms = ?, effective_to_ms = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND task_id = ?`, next.TaskType, next.Category, next.MetricType, next.Title, next.Description, next.TargetValue, next.TargetUnit, next.RewardCoinAmount, next.Status, next.SortOrder, next.Version, next.CurrentVersionID, next.EffectiveFromMS, next.EffectiveToMS, next.UpdatedByAdminID, next.UpdatedAtMS, appcode.FromContext(ctx), next.TaskID, ); err != nil { return taskdomain.Definition{}, false, err } if err := tx.Commit(); err != nil { return taskdomain.Definition{}, false, err } return next, false, nil } // SetTaskDefinitionStatus 更新后台状态;archived 也只是不再展示,不删除历史事实。 func (r *Repository) SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64, nowMS int64) (taskdomain.Definition, error) { if r == nil || r.db == nil { return taskdomain.Definition{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } result, err := r.db.ExecContext(ctx, ` UPDATE task_definitions SET status = ?, updated_by_admin_id = ?, updated_at_ms = ? WHERE app_code = ? AND task_id = ?`, status, operatorAdminID, nowMS, appcode.FromContext(ctx), taskID, ) if err != nil { return taskdomain.Definition{}, err } affected, err := result.RowsAffected() if err != nil { return taskdomain.Definition{}, err } if affected == 0 { return taskdomain.Definition{}, xerr.New(xerr.NotFound, "task definition not found") } return r.getTaskDefinition(ctx, taskID) } // PrepareTaskClaim 锁定完成进度并创建 pending claim;钱包调用在事务外执行。 func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.ClaimCommand, nowMS int64) (taskdomain.Claim, error) { if r == nil || r.db == nil { return taskdomain.Claim{}, xerr.New(xerr.Unavailable, "mysql repository is not configured") } tx, err := r.db.BeginTx(ctx, nil) if err != nil { return taskdomain.Claim{}, err } defer func() { _ = tx.Rollback() }() if existing, exists, err := r.getClaimByCommandForUpdate(ctx, tx, command.CommandID); err != nil || exists { if err != nil { return taskdomain.Claim{}, err } if existing.UserID != command.UserID || existing.TaskID != command.TaskID || existing.TaskType != command.TaskType || existing.CycleKey != command.CycleKey { return taskdomain.Claim{}, xerr.New(xerr.RequestConflict, "claim command payload conflicts") } if err := tx.Commit(); err != nil { return taskdomain.Claim{}, err } return existing, nil } if existing, exists, err := r.getClaimByTaskCycleForUpdate(ctx, tx, command.UserID, command.TaskID, command.CycleKey); err != nil || exists { if err != nil { return taskdomain.Claim{}, err } if existing.Status == taskdomain.ClaimStatusGranted { if err := tx.Commit(); err != nil { return taskdomain.Claim{}, err } return existing, nil } return taskdomain.Claim{}, xerr.New(xerr.RewardPending, "task reward claim is pending") } definition, err := r.getTaskDefinitionForUpdate(ctx, tx, command.TaskID) if err != nil { return taskdomain.Claim{}, err } if definition.TaskType != command.TaskType { return taskdomain.Claim{}, xerr.New(xerr.InvalidArgument, "task_type does not match task definition") } progress, err := r.getProgressForUpdate(ctx, tx, command.UserID, command.TaskID, command.CycleKey) if err != nil { if errors.Is(err, sql.ErrNoRows) { return taskdomain.Claim{}, xerr.New(xerr.Conflict, "task is not completed") } return taskdomain.Claim{}, err } if progress.Status == taskdomain.StatusClaimed { return taskdomain.Claim{}, xerr.New(xerr.Conflict, "task reward already claimed") } if progress.Status != taskdomain.StatusCompleted { return taskdomain.Claim{}, xerr.New(xerr.Conflict, "task is not completed") } claim := taskdomain.Claim{ ClaimID: idgen.New("tclaim"), CommandID: command.CommandID, UserID: command.UserID, TaskID: command.TaskID, TaskType: command.TaskType, CycleKey: command.CycleKey, RewardCoinAmount: progress.RewardCoinAmount, Status: taskdomain.ClaimStatusPending, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } claim.WalletCommandID = walletTaskCommandID(claim.ClaimID) if _, err := tx.ExecContext(ctx, ` INSERT INTO task_reward_claims ( app_code, claim_id, command_id, user_id, task_id, task_type, cycle_key, reward_coin_amount, wallet_command_id, status, failure_reason, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`, appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.TaskID, claim.TaskType, claim.CycleKey, claim.RewardCoinAmount, claim.WalletCommandID, claim.Status, claim.CreatedAtMS, claim.UpdatedAtMS, ); err != nil { return taskdomain.Claim{}, err } if err := tx.Commit(); err != nil { return taskdomain.Claim{}, err } return claim, nil } // MarkTaskClaimGranted 把钱包成功回执和用户任务 claimed 状态放在同一 activity 事务里。 func (r *Repository) MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (taskdomain.Claim, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { return taskdomain.Claim{}, err } defer func() { _ = tx.Rollback() }() claim, err := r.getClaimByIDForUpdate(ctx, tx, claimID) if err != nil { return taskdomain.Claim{}, err } if claim.Status == taskdomain.ClaimStatusGranted { if err := tx.Commit(); err != nil { return taskdomain.Claim{}, err } return claim, nil } if _, err := tx.ExecContext(ctx, ` UPDATE task_reward_claims SET status = 'granted', wallet_transaction_id = ?, failure_reason = '', updated_at_ms = ? WHERE app_code = ? AND claim_id = ?`, walletTransactionID, grantedAtMS, appcode.FromContext(ctx), claimID, ); err != nil { return taskdomain.Claim{}, err } if _, err := tx.ExecContext(ctx, ` UPDATE user_task_progress SET status = 'claimed', claimed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?`, grantedAtMS, grantedAtMS, appcode.FromContext(ctx), claim.UserID, claim.TaskID, claim.CycleKey, ); err != nil { return taskdomain.Claim{}, err } if err := tx.Commit(); err != nil { return taskdomain.Claim{}, err } claim.Status = taskdomain.ClaimStatusGranted claim.WalletTransactionID = walletTransactionID claim.UpdatedAtMS = grantedAtMS return claim, nil } // MarkTaskClaimFailed 记录钱包失败原因但不修改 progress,用户后续可用同 command_id 重试。 func (r *Repository) MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason 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 task_reward_claims SET status = 'failed', failure_reason = ?, updated_at_ms = ? WHERE app_code = ? AND claim_id = ? AND status <> 'granted'`, truncateTaskFailure(failureReason), nowMS, appcode.FromContext(ctx), claimID, ) return err } func (r *Repository) insertTaskEventConsumption(ctx context.Context, tx *sql.Tx, event taskdomain.Event, cycleKey string, nowMS int64) (bool, string, error) { _, err := tx.ExecContext(ctx, ` INSERT INTO task_event_consumption ( app_code, event_id, event_type, source_service, user_id, task_day, status, skip_reason, created_at_ms, consumed_at_ms ) VALUES (?, ?, ?, ?, ?, ?, 'consumed', '', ?, 0)`, appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, cycleKey, nowMS, ) if err == nil { return true, "", nil } if !isMySQLDuplicate(err) { return false, "", err } var status string if err := tx.QueryRowContext(ctx, ` SELECT status FROM task_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) listDefinitionsForEvent(ctx context.Context, tx *sql.Tx, metricType string, occurredAtMS int64) ([]taskdomain.Definition, error) { rows, err := tx.QueryContext(ctx, ` SELECT app_code, task_id, task_type, category, metric_type, title, description, target_value, target_unit, reward_coin_amount, status, sort_order, version, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM task_definitions WHERE app_code = ? AND status = 'active' AND metric_type = ? AND (effective_from_ms = 0 OR effective_from_ms <= ?) AND (effective_to_ms = 0 OR effective_to_ms > ?) ORDER BY sort_order ASC, task_id ASC FOR UPDATE`, appcode.FromContext(ctx), metricType, occurredAtMS, occurredAtMS, ) if err != nil { return nil, err } defer rows.Close() return scanDefinitions(rows) } func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, userID int64, definition taskdomain.Definition, cycleKey string, delta int64, nowMS int64) (bool, error) { progress, err := r.getProgressForUpdate(ctx, tx, userID, definition.TaskID, cycleKey) if err != nil { if !errors.Is(err, sql.ErrNoRows) { return false, err } status := taskdomain.StatusInProgress completedAtMS := int64(0) if delta >= definition.TargetValue { status = taskdomain.StatusCompleted completedAtMS = nowMS } _, err := tx.ExecContext(ctx, ` INSERT INTO user_task_progress ( app_code, user_id, task_id, task_version_id, cycle_key, progress_value, target_value, reward_coin_amount, status, completed_at_ms, claimed_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, appcode.FromContext(ctx), userID, definition.TaskID, definition.CurrentVersionID, cycleKey, delta, definition.TargetValue, definition.RewardCoinAmount, status, completedAtMS, nowMS, ) return err == nil, err } if definition.TaskType == taskdomain.TypeExclusive && (progress.Status == taskdomain.StatusCompleted || progress.Status == taskdomain.StatusClaimed) { return false, nil } nextValue := progress.ProgressValue + delta nextStatus := progress.Status completedAtMS := progress.CompletedAtMS if nextStatus == taskdomain.StatusInProgress && nextValue >= progress.TargetValue { nextStatus = taskdomain.StatusCompleted completedAtMS = nowMS } _, err = tx.ExecContext(ctx, ` UPDATE user_task_progress SET progress_value = ?, status = ?, completed_at_ms = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?`, nextValue, nextStatus, completedAtMS, nowMS, appcode.FromContext(ctx), userID, definition.TaskID, cycleKey, ) return err == nil, err } func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, error) { definition := taskdomain.Definition{ AppCode: appcode.FromContext(ctx), TaskID: idgen.New("task"), TaskType: command.TaskType, Category: command.Category, MetricType: command.MetricType, Title: command.Title, Description: command.Description, TargetValue: command.TargetValue, TargetUnit: command.TargetUnit, RewardCoinAmount: command.RewardCoinAmount, Status: command.Status, SortOrder: command.SortOrder, Version: 1, EffectiveFromMS: command.EffectiveFromMS, EffectiveToMS: command.EffectiveToMS, CreatedByAdminID: command.OperatorAdminID, UpdatedByAdminID: command.OperatorAdminID, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } versionID, err := r.insertTaskDefinitionVersion(ctx, tx, definition, nowMS) if err != nil { return taskdomain.Definition{}, err } definition.CurrentVersionID = versionID _, err = tx.ExecContext(ctx, ` INSERT INTO task_definitions ( app_code, task_id, task_type, category, metric_type, title, description, target_value, target_unit, reward_coin_amount, status, sort_order, version, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType, definition.Title, definition.Description, definition.TargetValue, definition.TargetUnit, definition.RewardCoinAmount, definition.Status, definition.SortOrder, definition.Version, definition.CurrentVersionID, definition.EffectiveFromMS, definition.EffectiveToMS, definition.CreatedByAdminID, definition.UpdatedByAdminID, definition.CreatedAtMS, definition.UpdatedAtMS, ) return definition, err } func (r *Repository) insertTaskDefinitionVersion(ctx context.Context, tx *sql.Tx, definition taskdomain.Definition, nowMS int64) (int64, error) { payload, err := json.Marshal(definition) if err != nil { return 0, err } effectiveCycle := taskdomain.CycleLifetime if definition.TaskType == taskdomain.TypeDaily { effectiveCycle = "current" } result, err := tx.ExecContext(ctx, ` INSERT INTO task_definition_versions ( app_code, task_id, version, snapshot_json, effective_from_cycle, created_at_ms ) VALUES (?, ?, ?, ?, ?, ?)`, appcode.FromContext(ctx), definition.TaskID, definition.Version, string(payload), effectiveCycle, nowMS, ) if err != nil { return 0, err } return result.LastInsertId() } func (r *Repository) getTaskDefinition(ctx context.Context, taskID string) (taskdomain.Definition, error) { row := r.db.QueryRowContext(ctx, ` SELECT app_code, task_id, task_type, category, metric_type, title, description, target_value, target_unit, reward_coin_amount, status, sort_order, version, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM task_definitions WHERE app_code = ? AND task_id = ?`, appcode.FromContext(ctx), taskID, ) return scanDefinition(row) } func (r *Repository) getTaskDefinitionForUpdate(ctx context.Context, tx *sql.Tx, taskID string) (taskdomain.Definition, error) { row := tx.QueryRowContext(ctx, ` SELECT app_code, task_id, task_type, category, metric_type, title, description, target_value, target_unit, reward_coin_amount, status, sort_order, version, current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms FROM task_definitions WHERE app_code = ? AND task_id = ? FOR UPDATE`, appcode.FromContext(ctx), taskID, ) definition, err := scanDefinition(row) if errors.Is(err, sql.ErrNoRows) { return taskdomain.Definition{}, xerr.New(xerr.NotFound, "task definition not found") } return definition, err } func (r *Repository) getProgressForUpdate(ctx context.Context, tx *sql.Tx, userID int64, taskID string, cycleKey string) (taskdomain.Progress, error) { row := tx.QueryRowContext(ctx, ` SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value, target_value, reward_coin_amount, status, completed_at_ms, claimed_at_ms, updated_at_ms FROM user_task_progress WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ? FOR UPDATE`, appcode.FromContext(ctx), userID, taskID, cycleKey, ) return scanProgress(row) } func (r *Repository) getClaimByCommandForUpdate(ctx context.Context, tx *sql.Tx, commandID string) (taskdomain.Claim, bool, error) { row := tx.QueryRowContext(ctx, taskClaimSelectSQL()+` WHERE app_code = ? AND command_id = ? FOR UPDATE`, appcode.FromContext(ctx), commandID) claim, err := scanClaim(row) if errors.Is(err, sql.ErrNoRows) { return taskdomain.Claim{}, false, nil } return claim, err == nil, err } func (r *Repository) getClaimByTaskCycleForUpdate(ctx context.Context, tx *sql.Tx, userID int64, taskID string, cycleKey string) (taskdomain.Claim, bool, error) { row := tx.QueryRowContext(ctx, taskClaimSelectSQL()+` WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ? FOR UPDATE`, appcode.FromContext(ctx), userID, taskID, cycleKey) claim, err := scanClaim(row) if errors.Is(err, sql.ErrNoRows) { return taskdomain.Claim{}, false, nil } return claim, err == nil, err } func (r *Repository) getClaimByIDForUpdate(ctx context.Context, tx *sql.Tx, claimID string) (taskdomain.Claim, error) { row := tx.QueryRowContext(ctx, taskClaimSelectSQL()+` WHERE app_code = ? AND claim_id = ? FOR UPDATE`, appcode.FromContext(ctx), claimID) claim, err := scanClaim(row) if errors.Is(err, sql.ErrNoRows) { return taskdomain.Claim{}, xerr.New(xerr.NotFound, "task reward claim not found") } return claim, err } func scanDefinitions(rows *sql.Rows) ([]taskdomain.Definition, error) { definitions := make([]taskdomain.Definition, 0) for rows.Next() { definition, err := scanDefinition(rows) if err != nil { return nil, err } definitions = append(definitions, definition) } if err := rows.Err(); err != nil { return nil, err } return definitions, nil } type rowScanner interface { Scan(dest ...any) error } func scanDefinition(row rowScanner) (taskdomain.Definition, error) { var definition taskdomain.Definition err := row.Scan( &definition.AppCode, &definition.TaskID, &definition.TaskType, &definition.Category, &definition.MetricType, &definition.Title, &definition.Description, &definition.TargetValue, &definition.TargetUnit, &definition.RewardCoinAmount, &definition.Status, &definition.SortOrder, &definition.Version, &definition.CurrentVersionID, &definition.EffectiveFromMS, &definition.EffectiveToMS, &definition.CreatedByAdminID, &definition.UpdatedByAdminID, &definition.CreatedAtMS, &definition.UpdatedAtMS, ) return definition, err } func scanProgress(row rowScanner) (taskdomain.Progress, error) { var progress taskdomain.Progress err := row.Scan( &progress.AppCode, &progress.UserID, &progress.TaskID, &progress.TaskVersionID, &progress.CycleKey, &progress.ProgressValue, &progress.TargetValue, &progress.RewardCoinAmount, &progress.Status, &progress.CompletedAtMS, &progress.ClaimedAtMS, &progress.UpdatedAtMS, ) return progress, err } func scanClaim(row rowScanner) (taskdomain.Claim, error) { var claim taskdomain.Claim err := row.Scan( &claim.ClaimID, &claim.CommandID, &claim.UserID, &claim.TaskID, &claim.TaskType, &claim.CycleKey, &claim.RewardCoinAmount, &claim.WalletCommandID, &claim.WalletTransactionID, &claim.Status, &claim.FailureReason, &claim.CreatedAtMS, &claim.UpdatedAtMS, ) return claim, err } func taskClaimSelectSQL() string { return `SELECT claim_id, command_id, user_id, task_id, task_type, cycle_key, reward_coin_amount, wallet_command_id, wallet_transaction_id, status, failure_reason, created_at_ms, updated_at_ms FROM task_reward_claims ` } func taskDefinitionWhere(ctx context.Context, query taskdomain.DefinitionQuery) (string, []any) { conditions := []string{"app_code = ?"} args := []any{appcode.FromContext(ctx)} if query.TaskType != "" { conditions = append(conditions, "task_type = ?") args = append(args, query.TaskType) } if query.Category != "" { conditions = append(conditions, "category = ?") args = append(args, query.Category) } if query.Status != "" { conditions = append(conditions, "status = ?") args = append(args, query.Status) } if query.Keyword != "" { conditions = append(conditions, "(task_id LIKE ? OR title LIKE ? OR metric_type LIKE ?)") like := "%" + query.Keyword + "%" args = append(args, like, like, like) } return "WHERE " + strings.Join(conditions, " AND "), args } func taskDefinitionNeedsVersion(current taskdomain.Definition, next taskdomain.Definition) bool { return current.TaskType != next.TaskType || current.Category != next.Category || current.MetricType != next.MetricType || current.TargetValue != next.TargetValue || current.TargetUnit != next.TargetUnit || current.RewardCoinAmount != next.RewardCoinAmount } func normalizePageSize(value int32) int32 { if value <= 0 { return 20 } if value > 100 { return 100 } return value } func normalizeCycleKeys(values []string) []string { seen := make(map[string]bool, len(values)) result := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" || seen[value] { continue } seen[value] = true result = append(result, value) } return result } func walletTaskCommandID(claimID string) string { return fmt.Sprintf("wtask_%s", claimID) } func truncateTaskFailure(value string) string { value = strings.TrimSpace(value) if len(value) <= 255 { return value } return value[:255] }