15 KiB
每日任务配置驱动 + 后端事件驱动触发 设计方案
★ MVP 优先:本文档是可落地的 MVP 实施方案(方案 A)。计数模型是二元模型的低成本超集,予以采纳;多对多映射(方案 B)与规则引擎(方案 C)不在本次实现,仅作为 §7 平滑升级路线图。
文档说明
- 适用范围:
taskService每日任务(task_type='daily')的完成/触发机制改造,及其前端联动。不含引导任务(onboarding)与收益(revenue)逻辑。 - 工作量估算:约 1 周(2 列 + 1 列 migration、1 个完成引擎方法、MQ 事件接入、3~4 处 emit 点、重置改一行、service 单测)。
- 前置版本/历史:现状为前端硬编码上报 + 后端
def.TaskKey == eventType内联匹配;daily_mint/daily_place_asset无 emit 点,属悬空任务(本方案顺带修复)。 - 目标读者:后端 taskService 开发、前端 App 开发、DBA。
一、方案概述(必读)
要解决的问题
业务问题
- 每日任务列表会"不定时更改":大多数时候是改文案 / 奖励 / 次数 / 顺序 / 上下架(复用已有用户行为),偶尔引入全新的完成行为。
- 诉求:每次改动前端不做大改。
技术问题
- 完成判定硬编码在前端(
Header.vue报daily_login、exhibition.vue报daily_browse_asset),加任务就得改前端,且客户端可伪造。 - 后端匹配是内联的
def.TaskKey == eventType,带TODO,无法表达"次数""多事件"。 daily_mint/daily_place_asset在task_definitions中 active,但全仓库无对应上报点 → 用户永远无法完成(已由实连本地库top-fans确认:这两个 task_key 在user_daily_task_progress中 0 行)。
整体实现路径
| 阶段 | 内容 | 估时 |
|---|---|---|
| 1. 数据模型 | task_definitions 加 trigger_event/target_count;user_daily_task_progress 加 progress;migration + backfill |
0.5d |
| 2. 事件目录 | 共享事件常量文件 + 治理规则 | 0.5d |
| 3. 完成引擎 | ProcessTaskEvent 方法,替换内联匹配 |
1.5d |
| 4. 事件接入 | MQ task:event 消费 + ReportEvent 改为生产者;铸造/上架/登录/浏览源头 emit |
2d |
| 5. 重置 | ResetAllDailyTasks 增加 progress=0 |
0.25d |
| 6. 测试 | ProcessTaskEvent service 单测 |
1d |
关键决策
- 触发源 = 后端事件驱动为主,前端通用上报兜底(详见 §4)。抗刷、可覆盖纯后端行为,且新任务复用已有事件时前端零改动。
- 映射机制 = 方案 A:
task_definitions单列trigger_event+target_count(详见 §2、§4)。最低复杂度,覆盖 90% 场景。 - 计数模型:
target_count=1等价"首次",>1为计数型;MVP 不做 distinct 去重(详见 §5)。 - 单一隔离单元:所有匹配逻辑封在
ProcessTaskEvent一个方法内,成为 A→B 升级的唯一改动点(详见 §4、§7)。
核心架构图(TL;DR)
[后端服务: 登录/铸造成功/上架成功] ──publish──┐
▼
[前端纯 UI 动作(如浏览详情)] ─reportEvent─▶ gateway ─▶ MQ 统一事件
│ task:event { user_id, star_id, event_type }
▼
taskService MQ consumer ┐
ReportEvent RPC(兜底) ├─▶ DailyTaskService.ProcessTaskEvent()
┘ │
▼
查 active daily 定义 where trigger_event = event_type
→ GetOrCreate 进度 → progress += 1
→ progress >= target_count ? status=completed
│
(领取仍是独立步骤: ClaimDailyTask → 发水晶 → claimed)
二、数据模型变更
所有变更需写 migration(放 backend/migrations/),并在 docker/init-db.sql 同步。
2.1 task_definitions 新增 2 列
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
trigger_event |
varchar(50) |
可空 | 驱动该任务的事件名,取自 §3 事件目录 |
target_count |
int |
not null default 1 |
完成所需次数;=1 即"首次",等价现状 |
对应 GORM model model.TaskDefinition 增加字段:
TriggerEvent string `gorm:"column:trigger_event;size:50"`
TargetCount int `gorm:"column:target_count;default:1"`
Backfill(存量 4 行):trigger_event = task_key,target_count = 1。
UPDATE task_definitions SET trigger_event = task_key WHERE task_type = 'daily' AND trigger_event IS NULL;
UPDATE task_definitions SET target_count = 1 WHERE target_count IS NULL OR target_count = 0;
2.2 user_daily_task_progress 新增 1 列
| 列 | 类型 | 约束 | 说明 |
|---|---|---|---|
progress |
int |
not null default 0 |
当前累计次数;progress >= target_count → completed |
对应 model UserDailyTaskProgress 增加:
Progress int `gorm:"column:progress;default:0"`
状态机不变:pending → completed → claimed,每日重置回 pending。
序列规范提醒:如后续脚本手动 INSERT 指定 id,须按 CLAUDE.md 规则重置对应
_id_seq。
三、事件目录(单一事实来源)
在后端建共享常量文件(建议 backend/pkg/mq/tasks/task_events.go 或 services/taskService/model),集中定义有限、稳定的事件枚举:
const (
EventDailyLogin = "daily_login" // 每日首次登录
EventDailyBrowseAsset = "daily_browse_asset" // 每日首次浏览藏品详情
EventDailyMint = "daily_mint" // 每日首次铸造
EventDailyPlaceAsset = "daily_place_asset" // 每日首次上架作品
)
治理规则
- 任务配置的
trigger_event只能引用目录中已存在的事件。 - 新增事件(B 类新行为)= 加一个常量 + 在该行为的源头 emit 一次;此后该事件可被任意数量的新任务复用(纯配置)。
- 事件命名与含义在本节表格维护,改动需同步本文档。
- 每个事件标注归属路径(后端 emit / 前端 reportEvent),遵守 §5 F4 单一路径规则:
| 事件 | 含义 | 归属路径 |
|---|---|---|
daily_login |
每日首次登录 | 后端(auth/登录服务 emit,star_id 从 JWT/token 中取) |
daily_browse_asset |
每日首次浏览藏品详情 | 前端 reportEvent(纯 UI 动作) |
daily_mint |
每日首次铸造 | 后端(铸造成功处 emit,修复悬空) |
daily_place_asset |
每日首次上架作品 | 后端(上架成功处 emit,修复悬空) |
F3 概念区分:业务事件枚举(上表
daily_login…,供trigger_event引用)与 MQ 传输层的任务类型是两回事。MQ 任务类型常量TypeTaskEvent = "task:event"及其 payload structTaskEventPayload{UserID, StarID, EventType}应放在backend/pkg/mq/tasks/registry.go(沿用现有revenue:/gallery:命名风格);业务事件枚举放task_events.go。一条task:eventMQ 消息的 payload 里携带某个业务EventType。
四、完成引擎(唯一隔离单元)
4.1 新方法
在 DailyTaskService 新增:
// TaskEventResult 供 ReportEvent RPC 回填响应
type TaskEventResult struct {
CompletedTaskKeys []string // 本次事件导致 completed 的任务(可能 0~N 个)
}
ProcessTaskEvent(ctx context.Context, userID, starID int64, eventType string) (*TaskEventResult, error)
取代 daily_task_service.go 现有 ReportEvent 内那段 def.TaskKey == eventType 内联循环。
F1 说明:返回结果而非仅
error,是因为ReportEventRPC 的ReportEventResponse需要TaskKey/TaskCompleted/Message(proto 现有字段)。委托后由ReportEvent用TaskEventResult回填:TaskCompleted = len(CompletedTaskKeys) > 0,TaskKey = 首个 completed。MQ consumer 则忽略返回值只关心error。
4.2 两个入口,一个引擎
| 入口 | 来源 | 说明 |
|---|---|---|
| MQ consumer(主) | 后端服务发布的 task:event |
抗刷、覆盖纯后端行为 |
ReportEvent RPC(兜底) |
前端通用上报(纯 UI 动作,如浏览详情) | 改为"发同一条 MQ 事件 / 或直接调 ProcessTaskEvent",不再自己做匹配 |
前端 task-api.js 保留唯一的 reportEvent(eventType, starId),不随任务增减而改动。
4.3 引擎逻辑
输入 {userID, starID, eventType}:
- 查定义:
is_active = true AND task_type = 'daily' AND trigger_event = eventType AND (star_id = ? OR star_id IS NULL)。 - 逐个
GetOrCreateDailyProgress;若状态已是completed/claimed→ 跳过(当天幂等)。 progress += 1;若progress >= def.TargetCount→status = "completed"、completed_at = now。UpdateDailyProgress保存。
隔离保证:事件→任务的映射被封在"第 1 步查询 + 本方法"内。这是 §7 升级 B 的唯一改动点。
F5 多命中说明:同一
trigger_event可能同时命中「全局任务(star_id IS NULL)」与「该 star 专属任务」,此时两者都会各自 +1 —— 这是预期行为(专属任务是全局任务的叠加,而非替代)。若运营需要"专属覆盖全局",属 §7 范畴,MVP 不做。
五、幂等与去重
target_count = 1:天然幂等,completed后跳过,无需额外处理。- 计数型(
target_count > 1):MVP 每个合格事件+1,封顶target_count。语义为"做 N 次",不做 distinct 去重(即"浏览 3 次",而非"3 个不同藏品")。 - MQ 重试:consumer
MaxRetry = 3(沿用现有 revenue handler 模式);引擎对target=1幂等,重试安全。计数型在无去重前提下,重试可能多计——通过"仅在业务成功后 emit 一次 + 合理 MaxRetry"控制;严格 exactly-once 归入 §7。 - "N 个不同对象"(distinct 去重):需要
event_id(如 asset_id)+ 幂等表,归入 §7 升级路径,不进 MVP。
F4 单一路径规则(强约束):同一个用户动作只能经一条路径 emit —— 要么后端 MQ 发布,要么前端
reportEvent,不可两者都发。否则计数型任务(target_count > 1)会重复 +1。约定:能被后端观测的动作(登录/铸造/上架)一律走后端 emit 且前端不再上报;纯 UI 动作(浏览详情)才走前端reportEvent。事件目录(§3)需标注每个事件的归属路径。
六、每日重置
DailyResetWorker 逻辑不变(05:00 Asia/Shanghai、pg_try_advisory_lock 防多实例)。仅 ResetAllDailyTasks() 的 Updates map 增加一项:
Updates(map[string]interface{}{
"status": "pending",
"progress": 0, // 新增
"completed_at": nil,
"claimed_at": nil,
"updated_at": now,
})
七、平滑升级路径(写入文档,不实现)
7.1 A → B:一个任务多种触发 / 每事件独立规则
- 新增表
task_triggers(id, task_id, event_type, increment, dedup_key, created_at),支持一任务多事件、一事件多任务、每触发独立增量与去重键。 - 引擎 §4.3 第 1 步的查询从"读
task_definitions.trigger_event列"改为"读task_triggersjointask_definitions"。 - 因匹配逻辑已隔离在
ProcessTaskEvent一个方法内,只改这一处 + 加一张表;trigger_event列降级为冗余快捷方式或废弃。
7.2 B → C:复杂规则(时间窗、distinct 计数、连续行为)
- 在 trigger 上加 JSON
rule表达式列(如{"event":"browse","distinct_by":"asset_id","count":3,"window":"1d"}),引擎接一个规则求值器。 - 属 Stage 2+,业务复杂度真正上来后再评估,避免 YAGNI。
八、错误处理与测试
错误处理
- 定义缺失 / 未激活 → 静默跳过 +
logger.Info。 - 发奖仍在独立的
ClaimDailyTask/ClaimAllDailyTasks步骤(不变),与完成解耦。 - 引擎内单条任务更新失败不影响其他任务(逐个处理,记 error 日志)。
测试(沿用现有 *_test.go 事务回滚 / 测试容器模式)
ProcessTaskEventservice 单测覆盖:- 首次完成(
target=1) - 计数累加与封顶(
target=3:1→2→3 completed,第 4 次 no-op) - 已 completed / claimed 时再来事件 → 跳过
- 未知 / 无匹配事件 → no-op
- per-star 定义与全局定义并存时的匹配
- 首次完成(
- repository:
progress累加、ResetAllDailyTasks重置progress=0。
九、受影响文件清单
| 文件 | 改动 |
|---|---|
backend/migrations/2026_07_21_*_daily_task_trigger.sql |
新增:加列 + backfill |
docker/init-db.sql |
同步表结构 + 种子 trigger_event/target_count |
backend/services/taskService/model/task_models.go |
TaskDefinition + UserDailyTaskProgress 加字段 |
backend/services/taskService/service/daily_task_service.go |
新增 ProcessTaskEvent,ReportEvent 改为委托 |
backend/services/taskService/repository/daily_task_repo.go |
进度累加;ResetAllDailyTasks 加 progress |
backend/pkg/mq/tasks/task_events.go(新) |
业务事件枚举常量(daily_login…) |
backend/pkg/mq/tasks/registry.go |
加 MQ 任务类型 TypeTaskEvent="task:event" + TaskEventPayload struct |
backend/services/taskService/mq/consumer.go |
注册 task:event handler → ProcessTaskEvent |
| 铸造 / 上架 / 登录服务源头 | emit task:event(修复 daily_mint/daily_place_asset 悬空;遵守 §5 F4 单一路径) |
backend/services/taskService/worker/daily_reset_worker.go |
无需改(调 repo) |
frontend/utils/task-api.js |
无需改(保留唯一 reportEvent) |
frontend/pages/tasks/daily-tasks.vue |
默认无需改(已配置驱动) |
9.1 若需前端展示进度条 "N/M"(可选,非 MVP 默认)
计数型任务若要在前端显示 2/3 进度,DailyTaskItem proto 当前无 progress/target 字段(实测仅 TaskKey/StarId/Name/Description/CrystalReward/Status/CanClaim),需额外:
| 文件 | 改动 |
|---|---|
backend/pkg/proto/task/*.proto |
DailyTaskItem 加 progress / target_count 字段 |
| (重新生成) | protoc 重新生成 task.pb.go / task.triple.go |
daily_task_service.go GetDailyTasks |
映射时填充 progress / target_count |
frontend/pages/tasks/daily-tasks.vue |
渲染进度 progress/target_count |
决策(已定稿):MVP 不展示进度条,前端完全零改动,
progress仅后端内部计数用于判完成。本 §9.1 的 proto 改动作为将来需要进度展示时的参考,不在本次实现范围。