|
|
@@ -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 直接打卡(非任务场景)→ 打卡正常,不触发任务逻辑
|