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