Przeglądaj źródła

docs: 饮食/睡眠/运动打卡接入任务完成链路设计(方案B双轨)

iwt 1 tydzień temu
rodzic
commit
6ae3318012

+ 308 - 0
docs/superpowers/specs/2026-10-13-health-checkin-task-linkage-design.md

@@ -0,0 +1,308 @@
+# 饮食/睡眠/运动打卡接入任务完成链路 — 设计说明
+
+> 日期:2026-10-13
+> 状态:设计已批准,待实现
+> 决策:采用方案 B(双轨复用现有机制)
+
+## 背景与问题
+
+系统现状:饮食/睡眠/运动打卡是独立的健康数据模块,与任务系统**零关联**:
+
+1. `Task.actionType` 枚举(minigame/focus/survey/article/activity/cart/order)中没有饮食/睡眠/运动值
+2. 打卡页面不传 `taskId`,也不调用任务完成接口
+3. 健康打卡 Controller 成功后只存记录、发积分、重算画像,不回写任务进度
+4. 设计文档明确"五维打卡:本次不合并",但用户预期"大部分任务需要打卡记录关联功能页"
+
+目标:让饮食/睡眠/运动打卡可作为任务完成凭证,覆盖两类任务来源:
+
+- **手动创建的一次性任务**(点击即跳转到打卡页,打卡后自动完成)
+- **系统每日自动生成的打卡任务**(打卡后累计进度,达标自动完成)
+
+## 核心设计决策
+
+### 决策 1:一次性任务采用 `actionType` 联动机制
+
+- `Task.actionType`(VARCHAR(32))新增取值 `diet` / `sleep` / `exercise`
+- 无需 DDL,VARCHAR 字段天然支持新枚举值
+- `tasks.vue` 的 `getActionLabel()` 增加 3 个分支,显示"去打卡"
+- `tasks.vue` 的 `navigateByAction()` 增加跳转逻辑
+
+### 决策 2:每日打卡任务复用 `updateProgress()` 回写机制
+
+- 已有 `TaskService.updateProgress(userId, taskKey, increment)` 方法(第 1653 行)
+- 已有调用先例:`HealthCheckinService` 签到后回写 DAILY_CHECKIN 进度(第 178 行)
+- 每日自动生成任务采用 `is_daily_progress=1` + 固定 `taskKey` 约定
+
+### 决策 3:打卡 Controller 统一挂钩,一次性任务与每日任务双轨并行
+
+- 三个 Health 打卡 Controller(Meal/Sleep/Exercise)的 `create()` 方法入库成功后:
+  - **若请求带 `taskId`** → 反查任务,按任务真实类型分流:
+    - 一次性任务(`actionType` = diet/sleep/exercise 且非每日进度型)→ 调 `TaskService.completeTask(taskId, ...)` 完成任务
+    - 每日进度型任务(`is_daily_progress=1`)→ 调 `TaskService.updateProgress(userId, taskKey, 1)` 累计进度
+  - **若不带 `taskId`** → 调 `TaskService.updateProgress(userId, taskKey, 1)` 累计今日对应类型的每日任务进度(无则 log.warn 跳过)
+- 判定以**任务真实类型**为准(前端带不带 taskId 均可,不依赖前端区分),后端反查 `tasks` 表决定走哪条路径
+- 补打场景(`isBackfill=1`)不触发任务回写,与现有打卡积分逻辑保持一致
+
+## 实现路径
+
+### 1. 后端改动
+
+#### 1.1 `Task.java` 实体
+
+- 无需修改字段(`actionType` 已是 VARCHAR(32))
+- 注释补充:`actionType` 可选值 `diet/sleep/exercise`
+
+#### 1.2 `CreateTaskDTO.java` 与任务创建接口
+
+- `actionType` 注释更新,支持 `diet/sleep/exercise`
+
+#### 1.3 `TaskService.java` — 新增方法
+
+```java
+/**
+ * 健康打卡完成后回写任务进度
+ * @param userId  登录用户 ID(用于反查 FamilyMember)
+ * @param taskKey 任务类型标识("DIET" / "SLEEP" / "EXERCISE")
+ * @param increment 进度增量
+ */
+public void updateHealthCheckinProgress(Long userId, String taskKey, int increment)
+// 直接调用现有 updateProgress(userId, taskKey, increment)
+```
+
+#### 1.4 三个 Health 打卡 Controller — `create()` 方法挂钩
+
+**改动点**(Meal / Sleep / Exercise 各一处):
+
+```java
+@PostMapping("/create")
+public Result<HealthXxxRecord> create(@RequestBody HealthXxxRecord record,
+                                       @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+    // 原有打卡入库逻辑...
+    healthXxxRecordMapper.insert(record);
+
+    // ===== 新增:任务回写挂钩 =====
+    try {
+        if (!isBackfill && record.getTaskId() != null) {
+            Task linked = taskMapper.selectById(record.getTaskId());
+            if (linked != null) {
+                if (linked.getIsDailyProgress() != null && linked.getIsDailyProgress() == 1) {
+                    // 每日进度型任务:按 taskKey 累计进度
+                    Long userId = familyMemberMapper.selectById(record.getMemberId()).getUserId();
+                    taskService.updateProgress(userId, taskKey, 1);
+                } else {
+                    // 一次性任务:直接完成
+                    taskService.completeTask(record.getTaskId(), record.getMemberId(), photoUrl, content, "text");
+                }
+            }
+        } else if (!isBackfill) {
+            // 未带 taskId:仍累计今日对应类型的每日任务进度(无则静默跳过)
+            Long userId = familyMemberMapper.selectById(record.getMemberId()).getUserId();
+            taskService.updateProgress(userId, taskKey, 1);
+        }
+    } catch (Exception e) {
+        log.warn("打卡任务回写失败: {}", e.getMessage());
+    }
+    // 原有积分发放、画像重算逻辑...
+}
+```
+
+**三个 Controller 的 `taskKey` 约定**:
+
+| Controller | 打卡类型 | taskKey 值 |
+|---|---|---|
+| `HealthMealController` | 饮食打卡 | `DIET` |
+| `HealthSleepController` | 睡眠打卡 | `SLEEP` |
+| `HealthExerciseController` | 运动打卡 | `EXERCISE` |
+
+#### 1.5 `HealthXxxRecord` 实体 — 新增字段
+
+三个打卡实体(Meal/Sleep/Exercise)各加一个非持久化字段:
+
+```java
+@TableField(exist = false)
+private Long taskId;  // 一次性任务 ID(可选)
+```
+
+**注意**:该字段为可选,前端不传时为 null,走每日任务累计路径。
+
+### 2. 每日任务生成
+
+#### 2.1 生成时机
+
+复用 `RepeatTaskGenerator` 现有每日生成能力(在 `task/RepeatTaskGenerator.java` 的 `generateDailyTasks()` 中)。
+
+#### 2.2 生成逻辑
+
+每天为每个有打卡任务的成员生成 3 条 `is_daily_progress=1` 的打卡任务:
+
+| 标题前缀 | taskKey | 默认 target_value |
+|---|---|---|
+| `[DIET]饮食打卡` | `DIET` | 1 |
+| `[SLEEP]睡眠打卡` | `SLEEP` | 1 |
+| `[EXERCISE]运动打卡` | `EXERCISE` | 1 |
+
+**taskKey 匹配约定**(沿用 `updateProgress` 现有逻辑):
+- `title` 精确匹配 `taskKey`,**或**
+- `title` 以 `[taskKey]` 前缀匹配
+
+实际采用**前缀约定**:`title = "[DIET]饮食打卡"`,`taskKey = "DIET"`,`updateProgress` 用 `likeRight(title, "[DIET]")` 查找。
+
+### 3. 前端改动
+
+#### 3.1 `pages/tasks/tasks.vue` — 任务中心
+
+**`getActionLabel()` 方法**(第 413 行附近):
+
+```js
+getActionLabel(task) {
+  if (task.startRequired === 1 && task.status !== 'in_progress') return '开始'
+  if (task.requireInput === 1) return '填写完成'
+  var at = task.actionType
+  if (at === 'minigame') return '开始游戏'
+  if (at === 'focus') return '开始专注'
+  if (at === 'survey') return '去填写'
+  if (at === 'article') return '去阅读'
+  if (at === 'activity') return '去报名'
+  if (at === 'cart' || at === 'order') return '去购物'
+  // 新增:饮食/睡眠/运动打卡
+  if (at === 'diet') return '去打卡'
+  if (at === 'sleep') return '去打卡'
+  if (at === 'exercise') return '去打卡'
+  return '完成'
+}
+```
+
+**`navigateByAction()` 方法**(第 462 行附近):
+
+```js
+navigateByAction(task) {
+  var at = task.actionType
+  var taskId = task.id
+  if (at === 'minigame') { /* 原有 */ }
+  else if (at === 'focus') { /* 原有 */ }
+  // ... 原有分支
+
+  // 新增:饮食/睡眠/运动打卡
+  else if (at === 'diet') {
+    uni.navigateTo({ url: '/pages/growth/diet-checkin?taskId=' + taskId })
+  } else if (at === 'sleep') {
+    uni.navigateTo({ url: '/pages/growth/sleep-checkin?taskId=' + taskId })
+  } else if (at === 'exercise') {
+    uni.navigateTo({ url: '/pages/growth/exercise-checkin?taskId=' + taskId })
+  }
+}
+```
+
+#### 3.2 三个打卡页 — 接收 `taskId` 参数
+
+`pages/growth/diet-checkin.vue` / `sleep-checkin.vue` / `exercise-checkin.vue`:
+
+```js
+onLoad(options) {
+  this.taskId = options.taskId || null
+  this.loadData()
+}
+```
+
+打卡提交时(调用 create 接口),若 `this.taskId` 非空:
+
+```js
+async submitCheckin() {
+  var payload = { /* 原有字段 */ }
+  if (this.taskId) {
+    payload.taskId = this.taskId  // 一次性任务走直接完成路径
+  }
+  var res = await createDietCheckin(payload)
+  if (res.code === 200) {
+    uni.showToast({
+      title: this.taskId ? '打卡成功,任务已完成' : '打卡成功',
+      icon: 'success'
+    })
+    // 返回任务列表刷新
+    uni.navigateBack()
+  }
+}
+```
+
+**注意**:每日任务场景下,打卡成功由后端自动回写进度,前端无需额外调 `completeTask`。
+
+## 数据流
+
+### 场景 1:一次性任务 → 打卡完成
+
+```
+家长创建任务(actionType=diet, 标题"记录饮食")
+        │
+        ▼
+任务列表显示"去打卡"
+        │
+        ▼ 点击
+跳转饮食打卡页(URL 带 taskId=123)
+        │
+        ▼ 用户填写食物清单/拍照,提交
+HealthMealController.create(record, taskId=123)
+        │
+        ├── 打卡入库(原有逻辑)
+        ├── 任务回写:taskService.completeTask(123, memberId, photoUrl, content, "text")
+        │       │
+        │       ├── 校验任务存在、状态合法、无前置任务
+        │       ├── 校验 requireInput(若有)
+        │       └── 记录执行、发放任务积分
+        └── 原有积分发放(打卡 5 CF)、画像重算
+        │
+        ▼
+返回任务列表 → 刷新 → 任务状态 = completed ✅
+```
+
+### 场景 2:每日任务 → 累计进度 → 达标完成
+
+```
+RepeatTaskGenerator 每日生成(is_daily_progress=1, title="[DIET]饮食打卡")
+        │
+        ▼
+任务列表显示"去打卡"(进度 0/1)
+        │
+        ▼ 点击(或直接从健康 Tab 进打卡页)
+饮食打卡页(不带 taskId,或带 taskId 但属于每日任务 → 后端走 updateProgress)
+        │
+        ▼ 用户打卡,提交
+HealthMealController.create(record)
+        │
+        ├── 打卡入库(原有逻辑)
+        ├── 任务回写:taskService.updateProgress(userId, "DIET", 1)
+        │       │
+        │       ├── 查找今日 [DIET] 前缀的进度任务
+        │       ├── 累加 progress(task_executions.extra)
+        │       └── 若 progress >= target_value → 自动完成 + 发奖
+        └── 原有积分发放(打卡 5 CF)、画像重算
+        │
+        ▼
+返回任务列表 → 刷新 → 进度 1/1,任务自动完成 ✅
+```
+
+## 边界情况与错误处理
+
+| 场景 | 行为 |
+|---|---|
+| `taskId` 任务不存在 / 已完成 | `completeTask` 返回 null,打卡正常,任务不完成(前端仅显示"打卡成功") |
+| 补打(`isBackfill=1`) | 不触发任务回写(与积分发放逻辑一致) |
+| 每日任务当天未生成 | `updateProgress` 找不到任务 → `log.warn` 跳过,不报错 |
+| 同时存在一次性任务和每日任务 | 两者独立:`taskId` 路径完成一次性任务;`taskKey` 路径累计每日任务,互不干扰 |
+| 任务回写异常 | `try-catch` 包裹,不阻断打卡主流程 |
+| 积分重复发放 | 一次性任务完成发任务积分;打卡本身发 5 CF,两次积分独立来源,不重复 |
+
+## 不改动(边界明确)
+
+- **不修改** `completeTask()` 内部校验逻辑(通用完成接口,靠 `actionConfig` 区分)
+- **不修改** `updateProgress()` 实现(已有,直接复用)
+- **不修改** 打卡页现有功能(仅新增 `taskId` 参数支持)
+- **不修改** 积分发放与画像重算逻辑(原有链路保持)
+- **不修改** `HealthMealRecord` 等实体的持久化字段(仅加非持久化的 `taskId` 字段)
+
+## 验证
+
+1. **编译**:`mvn clean compile` 通过(唯一验证方式)
+2. **前端语法**:`node --check` 校验 tasks.vue / 3 个打卡页
+3. **联调路径 1**:创建一次性饮食打卡任务(actionType=diet)→ 任务列表显示"去打卡"→ 跳转打卡页 → 打卡 → 任务变 `completed` + 发积分
+4. **联调路径 2**:每日打卡任务(is_daily_progress=1,标题 `[DIET]饮食打卡`)→ 任务列表显示 0/1 → 打卡 → 进度变 1/1 → 自动完成 + 发积分
+5. **回归**:打卡页不带 taskId 直接打卡(非任务场景)→ 打卡正常,不触发任务逻辑