# 每日任务配置驱动 + 后端事件驱动触发 设计方案 > **★ 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`、`asset-detail.vue` 报 `daily_browse_asset`,原 `exhibition.vue` 错位实现需迁移),加任务就得改前端,且客户端可伪造。 - 后端匹配是内联的 `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` 委托 `ProcessTaskEvent`;铸造/上架源头 emit;前端 `daily_login`/`daily_browse_asset` 保留 reportEvent | 2d | | 5. 重置 | `ResetAllDailyTasks` 增加 `progress=0` | 0.25d | | 6. 测试 | `ProcessTaskEvent` service 单测 | 1d | ### 关键决策 1. **触发源 = 后端事件驱动为主,前端通用上报兜底**(详见 §4)。抗刷、可覆盖纯后端行为,且新任务复用已有事件时前端零改动。 2. **映射机制 = 方案 A:`task_definitions` 单列 `trigger_event` + `target_count`**(详见 §2、§4)。最低复杂度,覆盖 90% 场景。 3. **计数模型**:`target_count=1` 等价"首次",`>1` 为计数型;MVP 不做 distinct 去重(详见 §5)。 4. **单一隔离单元**:所有匹配逻辑封在 `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` 增加字段: ```go 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`。 ```sql 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` 增加: ```go Progress int `gorm:"column:progress;default:0"` ``` 状态机不变:`pending → completed → claimed`,每日重置回 `pending`。 > **序列规范提醒**:如后续脚本手动 INSERT 指定 id,须按 CLAUDE.md 规则重置对应 `_id_seq`。 --- ## 三、事件目录(单一事实来源) 在后端新建 `backend/pkg/mq/tasks/task_events.go`(**新增约定**:与现有 `pkg/mq/tasks/registry.go` 单文件风格不同——业务事件枚举与 MQ TaskType 解耦;后续若有更多业务事件枚举,也放本文件),集中定义**有限、稳定**的事件枚举: ```go const ( EventDailyLogin = "daily_login" // 每日首次活跃(进入 home;前端 reportEvent) EventDailyBrowseAsset = "daily_browse_asset" // 每日首次浏览藏品详情 EventDailyMint = "daily_mint" // 每日首次铸造 EventDailyPlaceAsset = "daily_place_asset" // 每日首次上架作品 ) ``` **治理规则** 1. 任务配置的 `trigger_event` **只能引用目录中已存在的事件**。 2. 新增事件(B 类新行为)= 加一个常量 + 在该行为的**源头 emit 一次**;此后该事件可被任意数量的新任务复用(纯配置)。 3. 事件命名与含义在本节表格维护,改动需同步本文档。 4. 每个事件标注**归属路径**(后端 emit / 前端 reportEvent),遵守 §5 F4 单一路径规则: | 事件 | 含义 | 归属路径 | |---|---|---| | `daily_login` | 每日首次活跃(进入 home) | 前端 `reportEvent`(`Header.vue` 进 home 时触发;前端本地缓存 `daily_login_completed_${date}_${uid}_${starId}` 做日内去重) | | `daily_browse_asset` | 每日首次浏览藏品详情 | 前端 `reportEvent`(`asset-detail.vue` onLoad 时触发;现有 `exhibition.vue:1566` 错位实现需迁移并删除,避免重复触发) | | `daily_mint` | 每日首次铸造 | 后端(铸造成功处 emit,修复悬空) | | `daily_place_asset` | 每日首次上架作品 | 前端 `reportEvent`(`myWorks.vue` `placeAssetToGalleryApi` 调用成功后触发;走前端 emit 而非后端 galleryService emit,简化实施) | > **关键决策(daily_login 归属)**:用户保持登录态今天打开 app 进 home 时**没有"登录成功"事件**可 emit,但 `daily_login` 仍需触发(语义是"每日首次活跃"而非"登录")。因此 `daily_login` 必须走前端 `reportEvent`,**不能**强制走后端 emit。`§5 F4` 单一路径规则适用此场景——前端 `Header.vue` 是**唯一** emit 入口,登录服务不再为 `daily_login` 发事件。 > **MQ 与业务事件分层**:上表常量(`daily_login…`)是**业务事件**枚举,供 `task_definitions.trigger_event` 引用;**MQ 任务类型** `TypeTaskEvent = "task:event"` 及其 `TaskEventPayload{UserID, StarID, EventType}` 是**传输层**概念,放 `backend/pkg/mq/tasks/registry.go`(沿用 `revenue:exhibition` / `gallery:exhibition-settled` 命名风格)。一条 `task:event` MQ 消息的 payload 里携带某个业务 `EventType` 值,由 §4 `ProcessTaskEvent` 引擎消费。 --- ## 四、完成引擎(唯一隔离单元) ### 4.1 新方法 在 `DailyTaskService` 新增: ```go // 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 说明**:`ProcessTaskEvent` 返回 `*TaskEventResult` 同时被两条调用链使用—— > - **MQ consumer**:忽略返回值,只关心 `error`(consumer 异步执行,无前端依赖) > - **`ReportEvent` handler**:从结果回填 `ReportEventResponse`——`TaskCompleted = len(CompletedTaskKeys) > 0`,`TaskKey = 首个 completed`,`Message = "任务完成" | "未匹配任务"` > > 两条路径共用 §4.3 同一引擎逻辑(不重复实现 match + 累加 + 状态机),是方案 A "单一隔离单元" 的具体落地。 ### 4.2 两个入口,一个引擎 | 入口 | 来源 | 说明 | |---|---|---| | MQ consumer(主) | 后端服务发布的 `task:event` | 抗刷、覆盖纯后端行为 | | `ReportEvent` RPC(兜底) | 前端通用上报(纯 UI 动作,如浏览详情) | 改为**直接调 `ProcessTaskEvent`**(gateway 无需改造;handler 内部委托),不再自己做匹配;响应从 `TaskEventResult` 回填(见 §4.1 F1) | 前端 `task-api.js` 保留**唯一**的 `reportEvent(eventType, starId)`,不随任务增减而改动。 ### 4.3 引擎逻辑 输入 `{userID, starID, eventType}`: 1. 查定义:`is_active = true AND task_type = 'daily' AND trigger_event = eventType AND (star_id = ? OR star_id IS NULL)`。 2. 逐个 `GetOrCreateDailyProgress`;若状态已是 `completed` / `claimed` → 跳过(当天幂等)。 3. `progress += 1`;若 `progress >= def.TargetCount` → `status = "completed"`、`completed_at = now`。 4. `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 动作(浏览详情)和**仅前端可观测的状态**("每日首次活跃",见 §3)才走前端 `reportEvent`。事件目录(§3)需标注每个事件的归属路径。 --- ## 六、每日重置 `DailyResetWorker` 逻辑不变(05:00 Asia/Shanghai、`pg_try_advisory_lock` 防多实例)。仅 `ResetAllDailyTasks()` 的 `Updates` map 增加一项: ```go 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_triggers` join `task_definitions`"。 - 因匹配逻辑已隔离在 `ProcessTaskEvent` 一个方法内,**只改这一处 + 加一张表**;`trigger_event` 列降级为冗余快捷方式或废弃。 - **存量数据迁移**:Stage 2 实施时需将存量 `task_definitions.trigger_event` 4 行数据 INSERT 到 `task_triggers`,灰度对比 `ProcessTaskEvent` 行为一致后,再 `ALTER TABLE task_definitions DROP COLUMN trigger_event`(如废弃)。中间态可保留 `trigger_event` 列做冗余快捷方式(命中即返回,避免 `task_triggers` join 性能损耗)。 ### 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` 事务回滚 / 测试容器模式) - `ProcessTaskEvent` service 单测覆盖: - 首次完成(`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` | 接口新增 `IncrementProgress(progress *UserDailyTaskProgress, def *TaskDefinition) error`(事务内 `UPDATE ... SET progress = progress + 1, status=?, completed_at=? WHERE id=? AND status='pending'`,避免 Save 全量覆写);`ResetAllDailyTasks` 的 `Updates` map 加 `progress: 0` | | `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` | | `backend/services/assetService` 铸造成功处 | emit `task:event { event_type: EventDailyMint, user_id, star_id }`(修复 `daily_mint` 悬空;候选位置:`service/mint_service.go` line 603 前后,伴随现有 `asset.mint` 事件埋点) | | ~~`backend/services/galleryService` 上架成功处~~ | **本方案不修改**:`daily_place_asset` 改为前端 emit(见 §3),`galleryService` 不需要新增 emit 点。 | | ~~`backend/services/userService` 登录服务~~ | **本方案不修改**:`daily_login` 改为前端 emit(见 §3),登录服务不 emit `task:event`。如 Stage 2 引入"每日首次活跃"后端事件,需在此处补充。 | | `backend/services/taskService/worker/daily_reset_worker.go` | 无需改(调 repo) | | `frontend/pages/components/Header.vue` | 无需改(保留 `reportEvent("daily_login", starId)` 调用,归属=前端;本地缓存 `daily_login_completed_${date}_${uid}_${starId}` 做日内去重) | | `frontend/pages/exhibition/exhibition.vue` | **需改**:删除 line 1564-1568 的 `reportEvent('daily_browse_asset', starId)`(错位实现;`daily_browse_asset` 应绑定 `asset-detail.vue` 而非展馆浏览页) | | `frontend/pages/asset-detail/asset-detail.vue` | **需改**:在 `onLoad` 钩子(line 851 之后)新增 `reportEvent('daily_browse_asset', starId)`(正确触发源:藏品详情页) | | `frontend/pages/profile/myWorks.vue` | **需改**:在 `placeAssetToGalleryApi` 成功后(line 347 后)新增 `reportEvent('daily_place_asset', starId)`(前端 emit 简化实施) | | `frontend/utils/task-api.js` | 无需改(保留唯一 `reportEvent`) | | `frontend/pages/tasks/daily-tasks.vue` | **需改**:删除 line 62-63 错误的 `task.current_count` / `task.target_count` 进度渲染(proto 中无此字段;详见 §9.1) | ### 9.1 配套前端清理(必须,与决策"MVP 不展示进度条"对齐) **现状问题**:现有 `frontend/pages/tasks/daily-tasks.vue:62-63` 错误地引用了 proto 中**不存在**的字段: ```html ({{ task.current_count }}/{{ task.target_count }}) ``` 实测 `DailyTaskItem` proto 仅含 7 个字段:`TaskKey` / `StarId` / `Name` / `Description` / `CrystalReward` / `Status` / `CanClaim`(见 `backend/pkg/proto/task/task.pb.go`)。该段代码在生产中永远不会渲染任何内容(`v-if="undefined"` 永远为 `false`),属于 MVP 实施必须清理的死代码: | 文件 | 改动 | |---|---| | `frontend/pages/tasks/daily-tasks.vue` | 删除 line 62-63 的 `` 段(连带 `` 闭合标签,约 3 行) | **与 §1 "前端不做大改" 的关系**:本清理属于"修正已有 bug",**不是新增功能**。MVP 决策"MVP 不展示进度条"保持不变,`progress` 仅后端内部计数用于判完成。 > **未来扩展**:若运营后续要求展示 "N/M" 进度条,需: > 1. 在 proto 源文件中给 `DailyTaskItem` 加 `progress` / `target_count` 字段 > 2. 重新生成 `task.pb.go` / `task.triple.go`(**注意**:项目当前所有 `pkg/proto/*` 目录下均无 `.proto` 源文件,团队需先确认 proto 源文件归属位置——可能在独立 repo 或专用生成脚本中) > 3. `daily_task_service.go` `GetDailyTasks` 映射时填充两个字段 > 4. 前端 `daily-tasks.vue` 重新渲染 `task.progress / task.target_count` > > **不在本次 MVP 范围**。