942 lines
35 KiB
Go
942 lines
35 KiB
Go
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]
|
||
}
|