264 lines
15 KiB
Markdown
264 lines
15 KiB
Markdown
# 每日任务配置驱动 + 后端事件驱动触发 设计方案
|
||
|
||
> **★ 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 改动作为将来需要进度展示时的参考,不在本次实现范围。
|