2026-06-11 13:33:44 +08:00

942 lines
35 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"
"reflect"
"strconv"
"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,
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
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() }()
// 先插入事件消费表再做进度变更event_id 是全局幂等键;重复事件直接返回已有状态,不再读取任务配置或累加进度。
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
}
// 只锁定同 app、同 metric、当前事件时间有效的 active 配置;后台后续编辑不会影响这次事件已经选择的任务集合。
definitions, err := r.listDefinitionsForEvent(ctx, tx, event.MetricType, event.OccurredAtMS)
if err != nil {
return taskdomain.EventResult{}, err
}
matched := int32(0)
for _, definition := range definitions {
// 维度过滤在写进度前执行;例如指定 gift_id/game_id 不匹配时,这条事实被消费但不会推动该任务。
matchedFilter, err := dimensionFilterMatches(definition.DimensionFilterJSON, event.DimensionsJSON)
if err != nil {
return taskdomain.EventResult{}, err
}
if !matchedFilter {
continue
}
progressCycle := cycleKey
if definition.TaskType == taskdomain.TypeExclusive {
// 新手/一次性任务使用 lifetime 周期,完成或领取后永久不再被每日 cycle 重置。
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 {
// 没有匹配任务也要把事件标记为 skipped避免 MQ 对“当前没配置任务”的历史事实无限重试。
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,
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
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.AudienceType = command.AudienceType
next.IconKey = command.IconKey
next.IconURL = command.IconURL
next.ActionType = command.ActionType
next.ActionParam = command.ActionParam
next.ActionPayloadJSON = command.ActionPayloadJSON
next.DimensionFilterJSON = command.DimensionFilterJSON
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 = ?,
audience_type = ?, icon_key = ?, icon_url = ?, action_type = ?, action_param = ?,
action_payload_json = CAST(? AS JSON), dimension_filter_json = CAST(? AS JSON),
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.AudienceType, next.IconKey, next.IconURL, next.ActionType, next.ActionParam,
next.ActionPayloadJSON, next.DimensionFilterJSON,
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) {
// 初始状态先写 consumed 占位,事务末尾再按匹配结果改成 consumed/skipped同事务回滚时占位记录也会一起撤销。
_, err := tx.ExecContext(ctx, `
INSERT INTO task_event_consumption (
app_code, event_id, event_type, source_service, user_id, task_day, status,
metric_type, value_delta, dimensions_json,
skip_reason, created_at_ms, consumed_at_ms
) VALUES (?, ?, ?, ?, ?, ?, 'consumed', ?, ?, CAST(? AS JSON), '', ?, 0)`,
appcode.FromContext(ctx), event.EventID, event.EventType, event.SourceService, event.UserID, cycleKey,
event.MetricType, event.Value, event.DimensionsJSON, nowMS,
)
if err == nil {
return true, "", nil
}
if !isMySQLDuplicate(err) {
return false, "", err
}
// 重复事件说明之前已经提交过消费事务;读取原状态返回给调用方,让 MQ/gRPC 重放保持幂等成功。
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) {
// FOR UPDATE 锁住配置快照,保证同一个事件事务内读取版本和写入用户进度时不会遇到半更新配置。
rows, err := tx.QueryContext(ctx, `
SELECT app_code, task_id, task_type, category, metric_type, title, description,
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
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 {
// 只有从 in_progress 首次跨过目标值时写 completed_at_ms重复事件被幂等表挡住已完成任务不会刷新完成时间。
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,
AudienceType: command.AudienceType,
IconKey: command.IconKey,
IconURL: command.IconURL,
ActionType: command.ActionType,
ActionParam: command.ActionParam,
ActionPayloadJSON: command.ActionPayloadJSON,
DimensionFilterJSON: command.DimensionFilterJSON,
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,
audience_type, icon_key, icon_url, action_type, action_param, action_payload_json, dimension_filter_json,
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType,
definition.Title, definition.Description, definition.AudienceType, definition.IconKey, definition.IconURL,
definition.ActionType, definition.ActionParam, definition.ActionPayloadJSON, definition.DimensionFilterJSON,
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,
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
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,
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
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.AudienceType,
&definition.IconKey,
&definition.IconURL,
&definition.ActionType,
&definition.ActionParam,
&definition.ActionPayloadJSON,
&definition.DimensionFilterJSON,
&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.AudienceType != next.AudienceType ||
current.MetricType != next.MetricType ||
current.DimensionFilterJSON != next.DimensionFilterJSON ||
current.TargetValue != next.TargetValue ||
current.TargetUnit != next.TargetUnit ||
current.RewardCoinAmount != next.RewardCoinAmount
}
func dimensionFilterMatches(filterJSON string, dimensionsJSON string) (bool, error) {
filter, err := decodeTaskJSONObject(filterJSON)
if err != nil {
return false, err
}
if len(filter) == 0 {
// 空过滤代表所有同 metric 事件都计入,用于“任意礼物/任意游戏”类通用任务。
return true, nil
}
dimensions, err := decodeTaskJSONObject(dimensionsJSON)
if err != nil {
return false, err
}
for key, expected := range filter {
actual, ok := dimensions[key]
if !ok {
// 后台指定了某个维度但事件没带该字段,必须跳过,不能用空值兜底命中。
return false, nil
}
if !taskDimensionValueMatches(expected, actual) {
return false, nil
}
}
return true, nil
}
func decodeTaskJSONObject(value string) (map[string]any, error) {
value = strings.TrimSpace(value)
if value == "" {
return map[string]any{}, nil
}
var decoded map[string]any
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
return nil, err
}
if decoded == nil {
return map[string]any{}, nil
}
return decoded, nil
}
func taskDimensionValueMatches(expected any, actual any) bool {
if candidates, ok := expected.([]any); ok {
// filter 支持数组,表示“任意一个值命中即可”,方便后台配置多个 gift_id/game_id。
for _, candidate := range candidates {
if taskDimensionValueMatches(candidate, actual) {
return true
}
}
return false
}
expectedScalar, expectedOK := scalarTaskDimensionValue(expected)
actualScalar, actualOK := scalarTaskDimensionValue(actual)
if expectedOK && actualOK {
// JSON 数字、字符串和布尔都转成稳定字符串比较,避免 game_id 123 和 "123" 因编码差异无法命中。
return expectedScalar == actualScalar
}
// 对象等复杂维度目前只做结构完全相等;任务过滤主路径仍建议使用标量字段。
return reflect.DeepEqual(expected, actual)
}
func scalarTaskDimensionValue(value any) (string, bool) {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed), true
case bool:
return strconv.FormatBool(typed), true
case float64:
if typed == float64(int64(typed)) {
return strconv.FormatInt(int64(typed), 10), true
}
return strconv.FormatFloat(typed, 'f', -1, 64), true
case json.Number:
return typed.String(), true
case nil:
return "", true
default:
return "", false
}
}
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]
}