# 每日任务配置驱动 + 后端事件驱动触发 设计方案 > **★ 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 | ### 关键决策 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` 或 `services/taskService/model`),集中定义**有限、稳定**的事件枚举: ```go const ( EventDailyLogin = "daily_login" // 每日首次登录 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` | 每日首次登录 | 后端(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 struct `TaskEventPayload{UserID, StarID, EventType}` 应放在 `backend/pkg/mq/tasks/registry.go`(沿用现有 `revenue:` / `gallery:` 命名风格);业务事件枚举放 `task_events.go`。一条 `task:event` MQ 消息的 payload 里携带某个业务 `EventType`。 --- ## 四、完成引擎(唯一隔离单元) ### 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 说明**:返回结果而非仅 `error`,是因为 `ReportEvent` RPC 的 `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}`: 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 动作(浏览详情)才走前端 `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` 列降级为冗余快捷方式或废弃。 ### 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` | 进度累加;`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 改动作为将来需要进度展示时的参考,不在本次实现范围。