2026-05-09 21:47:33 +08:00

793 lines
28 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"
"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]
}