|
@@ -2,7 +2,7 @@
|
|
|
|
|
|
|
|
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
|
|
|
|
|
-**目标:** 修复家庭挑战功能的 7 个断点(spec: `docs/superpowers/specs/2026-08-06-family-challenge-flow-design.md` §7),使挑战形成闭环:家长创建 → 孩子打卡(限次)→ 到期结算 → 奖励发放,并补齐角色校验与前端展示修复。
|
|
|
|
|
|
|
+**目标:** 修复家庭挑战功能的 6 个断点(spec: `docs/superpowers/specs/2026-08-06-family-challenge-flow-design.md` §7 的 #1-#6;**#7 autoCreate 无调用方明确排除**,属 spec §8"未来"功能,如需启用需另行 spec),使挑战形成闭环:家长创建 → 孩子打卡(限次)→ 到期结算 → 奖励发放,并补齐角色校验与前端展示修复。
|
|
|
|
|
|
|
|
**架构:** 后端 cfc-backend 扩展 `FamilyChallengeService`/`FamilyChallengeController`:加角色校验(复用 JwtInterceptor 已设的 `role`/`familyId`/`currentMemberId` attribute)、打卡每日限次、结算时奖励发放(对接 PointsService 能量点)、列表查询时惰性结算。前端 cfc-frontend:`FamilyChallengeCard.vue` 加打卡按钮 + 修复 cancelled 状态显示、`challenge-manage.vue` 限制家长访问、`api.js` 已有封装不动。
|
|
**架构:** 后端 cfc-backend 扩展 `FamilyChallengeService`/`FamilyChallengeController`:加角色校验(复用 JwtInterceptor 已设的 `role`/`familyId`/`currentMemberId` attribute)、打卡每日限次、结算时奖励发放(对接 PointsService 能量点)、列表查询时惰性结算。前端 cfc-frontend:`FamilyChallengeCard.vue` 加打卡按钮 + 修复 cancelled 状态显示、`challenge-manage.vue` 限制家长访问、`api.js` 已有封装不动。
|
|
|
|
|
|
|
@@ -36,6 +36,7 @@
|
|
|
```java
|
|
```java
|
|
|
package com.etotem.cfc.service;
|
|
package com.etotem.cfc.service;
|
|
|
|
|
|
|
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
import com.etotem.cfc.entity.FamilyChallenge;
|
|
import com.etotem.cfc.entity.FamilyChallenge;
|
|
|
import com.etotem.cfc.entity.FamilyChallengeProgress;
|
|
import com.etotem.cfc.entity.FamilyChallengeProgress;
|
|
|
import com.etotem.cfc.mapper.FamilyChallengeMapper;
|
|
import com.etotem.cfc.mapper.FamilyChallengeMapper;
|
|
@@ -46,13 +47,15 @@ import org.junit.jupiter.api.Test;
|
|
|
import org.mockito.InjectMocks;
|
|
import org.mockito.InjectMocks;
|
|
|
import org.mockito.Mock;
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.MockitoAnnotations;
|
|
import org.mockito.MockitoAnnotations;
|
|
|
-import org.springframework.test.util.ReflectionTestUtils;
|
|
|
|
|
|
|
|
|
|
import java.util.Arrays;
|
|
import java.util.Arrays;
|
|
|
import java.util.Date;
|
|
import java.util.Date;
|
|
|
|
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.argThat;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
import static org.mockito.Mockito.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
|
|
|
|
public class FamilyChallengeServiceTest {
|
|
public class FamilyChallengeServiceTest {
|
|
@@ -61,6 +64,7 @@ public class FamilyChallengeServiceTest {
|
|
|
@Mock private FamilyChallengeProgressMapper familyChallengeProgressMapper;
|
|
@Mock private FamilyChallengeProgressMapper familyChallengeProgressMapper;
|
|
|
@Mock private FamilyMemberMapper familyMemberMapper;
|
|
@Mock private FamilyMemberMapper familyMemberMapper;
|
|
|
@Mock private HealthScoreService healthScoreService;
|
|
@Mock private HealthScoreService healthScoreService;
|
|
|
|
|
+ @Mock private PointsService pointsService;
|
|
|
@InjectMocks private FamilyChallengeService service;
|
|
@InjectMocks private FamilyChallengeService service;
|
|
|
|
|
|
|
|
@BeforeEach
|
|
@BeforeEach
|
|
@@ -87,15 +91,20 @@ public class FamilyChallengeServiceTest {
|
|
|
|
|
|
|
|
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
|
|
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
|
|
|
when(familyChallengeProgressMapper.selectOne(any())).thenReturn(existing);
|
|
when(familyChallengeProgressMapper.selectOne(any())).thenReturn(existing);
|
|
|
|
|
+ // 归属校验通过:childId(100) 属于 familyId(10)
|
|
|
|
|
+ when(familyMemberMapper.selectCount(any())).thenReturn(1L);
|
|
|
|
|
|
|
|
- var result = service.updateProgress(1L, 100L, 1);
|
|
|
|
|
|
|
+ Result<Void> result = service.updateProgress(1L, 100L, 1);
|
|
|
|
|
|
|
|
- assertEquals("error", result.getCode());
|
|
|
|
|
|
|
+ // Result.error() 返回 code=500
|
|
|
|
|
+ assertEquals(Integer.valueOf(500), result.getCode());
|
|
|
verify(familyChallengeProgressMapper, never()).updateById(any());
|
|
verify(familyChallengeProgressMapper, never()).updateById(any());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
|
|
+> 注意:`Result.error()` 返回 `code=500`(Integer),`Result.success()` 返回 `code=200`(见 `common/Result.java`)。`familyMemberMapper.selectCount()` 返回 `Long`,mock 用 `thenReturn(1L)`。
|
|
|
|
|
+
|
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
|
|
|
|
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay -pl .`
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest#updateProgress_shouldRejectDuplicateCheckinSameDay -pl .`
|
|
@@ -183,7 +192,7 @@ git commit -m "fix(backend): 家庭挑战打卡每日限次 + memberId 归属校
|
|
|
|
|
|
|
|
---
|
|
---
|
|
|
|
|
|
|
|
-### 任务 2:后端 — 结算时奖励发放(能量点 + 徽章)
|
|
|
|
|
|
|
+### 任务 2:后端 — 结算时奖励发放(能量点)
|
|
|
|
|
|
|
|
**文件:**
|
|
**文件:**
|
|
|
- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:134-182`(`checkAndSettle` 方法)
|
|
- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java:134-182`(`checkAndSettle` 方法)
|
|
@@ -224,7 +233,7 @@ void checkAndSettle_shouldGrantPointsWhenCompleted() {
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-> 已核实:积分发放使用 `PointsService.awardSystemPoints(Long memberId, int amount, String reason)`(`service/PointsService.java:97`),`@Resource` 注入 `PointsService pointsService`。测试需 `@Mock private PointsService pointsService;`。
|
|
|
|
|
|
|
+> 已核实:积分发放使用 `PointsService.awardSystemPoints(Long memberId, int amount, String reason)`(`service/PointsService.java:97`)。`@Mock private PointsService pointsService;` 已在任务 1 测试类中声明(同包 `com.etotem.cfc.service`,无需 import)。
|
|
|
|
|
|
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
- [ ] **步骤 2:运行测试确认失败**
|
|
|
|
|
|
|
@@ -233,29 +242,41 @@ void checkAndSettle_shouldGrantPointsWhenCompleted() {
|
|
|
|
|
|
|
|
- [ ] **步骤 3:实现奖励发放**
|
|
- [ ] **步骤 3:实现奖励发放**
|
|
|
|
|
|
|
|
-在 `checkAndSettle` 中,`isCompleted` 判定通过后、`challenge.setStatus("completed")` 之前插入:
|
|
|
|
|
|
|
+**3a. 先在 `FamilyChallengeService` 字段区新增注入**(在 `healthScoreService` 字段 L33 之后):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+@Resource
|
|
|
|
|
+private PointsService pointsService;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> `PointsService` 与 `FamilyChallengeService` 同包(`com.etotem.cfc.service`),无需 import。字段名 `pointsService` 与类型默认 Bean Name 一致。
|
|
|
|
|
+
|
|
|
|
|
+**3b. 在 `checkAndSettle` 中,`isCompleted` 判定通过后、`challenge.setStatus("completed")` 之前插入:**
|
|
|
|
|
|
|
|
```java
|
|
```java
|
|
|
// 发放奖励:能量点 + 标记成员完成
|
|
// 发放奖励:能量点 + 标记成员完成
|
|
|
if (isCompleted) {
|
|
if (isCompleted) {
|
|
|
int points = challenge.getRewardPoints() != null ? challenge.getRewardPoints() : 0;
|
|
int points = challenge.getRewardPoints() != null ? challenge.getRewardPoints() : 0;
|
|
|
for (FamilyChallengeProgress p : progresses) {
|
|
for (FamilyChallengeProgress p : progresses) {
|
|
|
- if (p.getProgressValue() != null && p.getProgressValue() >= challenge.getTargetValue()) {
|
|
|
|
|
- if (!Boolean.TRUE.equals(p.getCompleted())) {
|
|
|
|
|
- p.setCompleted(true);
|
|
|
|
|
- p.setUpdatedAt(new Date());
|
|
|
|
|
- familyChallengeProgressMapper.updateById(p);
|
|
|
|
|
- }
|
|
|
|
|
- if (points > 0) {
|
|
|
|
|
- // 对接积分服务发放奖励(PointsService.awardSystemPoints,按 memberId 发放)
|
|
|
|
|
- pointsService.awardSystemPoints(p.getChildId(), points,
|
|
|
|
|
- "家庭挑战完成奖励: " + challenge.getTitle());
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (!Boolean.TRUE.equals(p.getCompleted())) {
|
|
|
|
|
+ p.setCompleted(true);
|
|
|
|
|
+ p.setUpdatedAt(new Date());
|
|
|
|
|
+ familyChallengeProgressMapper.updateById(p);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (points > 0) {
|
|
|
|
|
+ // 对接积分服务发放奖励(PointsService.awardSystemPoints,按 memberId 发放)
|
|
|
|
|
+ pointsService.awardSystemPoints(p.getChildId(), points,
|
|
|
|
|
+ "家庭挑战完成奖励: " + challenge.getTitle());
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
|
|
+> **为何对所有参与成员发放(不按个人 progressValue >= targetValue 判断)**:
|
|
|
|
|
+> - **aggregate 模式**(如"全家总运动 300 分钟",targetValue=300):挑战达成条件是家庭**合计**达标,个人进度几乎不可能达到 300 → 若按个人目标判断则**无人得奖**,违背"奖励发放"修复意图(spec §7 #3)。挑战整体达成后,所有**参与成员**(有 progress 记录)都应获得奖励。
|
|
|
|
|
+> - **all_members 模式**:挑战达成条件本就是**每个成员都达到** targetValue,结算时所有成员必然已达标 → 同样应全员发放。
|
|
|
|
|
+> - 两种模式下"有 progress 记录 = 参与了该挑战",统一全员标记完成 + 发放,逻辑一致且简单。
|
|
|
|
|
+
|
|
|
- [ ] **步骤 4:运行测试确认通过**
|
|
- [ ] **步骤 4:运行测试确认通过**
|
|
|
|
|
|
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
@@ -321,11 +342,12 @@ for (FamilyChallenge c : active) {
|
|
|
active = familyChallengeMapper.selectList(wrapper);
|
|
active = familyChallengeMapper.selectList(wrapper);
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-在 `FamilyChallengeController` 各端点加角色校验(模式参考项目其他 controller):
|
|
|
|
|
|
|
+在 `FamilyChallengeController` 各端点加角色校验。以下为**完整方法体**(基于现有 L19-72 逐方法替换,保留原参数与 Service 调用):
|
|
|
|
|
|
|
|
```java
|
|
```java
|
|
|
-// list/history:任何登录角色可看(parent/child),保持现状
|
|
|
|
|
-// progress:仅 child 可打卡(parent/teacher 拒绝)
|
|
|
|
|
|
|
+// list/history:任何登录角色可看(parent/child),保持现状不动(L19-33 不改)
|
|
|
|
|
+
|
|
|
|
|
+// progress:仅 child 可打卡;强制用 currentMemberId,忽略请求体 memberId(防刷他人进度)
|
|
|
@PostMapping("/progress")
|
|
@PostMapping("/progress")
|
|
|
public Result<Void> updateProgress(@RequestBody Map<String, Object> params,
|
|
public Result<Void> updateProgress(@RequestBody Map<String, Object> params,
|
|
|
@RequestAttribute(value = "role", required = false) String role,
|
|
@RequestAttribute(value = "role", required = false) String role,
|
|
@@ -333,10 +355,19 @@ public Result<Void> updateProgress(@RequestBody Map<String, Object> params,
|
|
|
if (!"child".equals(role)) {
|
|
if (!"child".equals(role)) {
|
|
|
return Result.error("仅孩子可打卡");
|
|
return Result.error("仅孩子可打卡");
|
|
|
}
|
|
}
|
|
|
- // ... 原逻辑
|
|
|
|
|
|
|
+ if (currentMemberId == null) {
|
|
|
|
|
+ return Result.error("未识别当前成员");
|
|
|
|
|
+ }
|
|
|
|
|
+ Long challengeId = params.get("challengeId") != null
|
|
|
|
|
+ ? Long.valueOf(params.get("challengeId").toString()) : null;
|
|
|
|
|
+ int delta = params.get("delta") != null
|
|
|
|
|
+ ? Integer.parseInt(params.get("delta").toString()) : 0;
|
|
|
|
|
+ return familyChallengeService.updateProgress(challengeId, currentMemberId, delta);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// create/update/delete:仅 parent 可操作
|
|
|
|
|
|
|
+// templates:任何登录角色可看,保持现状不动(L47-50 不改)
|
|
|
|
|
+
|
|
|
|
|
+// create:仅 parent
|
|
|
@PostMapping("/create")
|
|
@PostMapping("/create")
|
|
|
public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
|
|
public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
|
|
|
@RequestAttribute(value = "role", required = false) String role,
|
|
@RequestAttribute(value = "role", required = false) String role,
|
|
@@ -345,11 +376,37 @@ public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
|
|
|
if (!"parent".equals(role)) {
|
|
if (!"parent".equals(role)) {
|
|
|
return Result.error("仅家长可创建挑战");
|
|
return Result.error("仅家长可创建挑战");
|
|
|
}
|
|
}
|
|
|
- // ... 原逻辑
|
|
|
|
|
|
|
+ return familyChallengeService.createChallenge(familyId, userId, params);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// update:仅 parent
|
|
|
|
|
+@PostMapping("/update/{id}")
|
|
|
|
|
+public Result<Void> updateChallenge(@PathVariable("id") Long challengeId,
|
|
|
|
|
+ @RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute(value = "role", required = false) String role,
|
|
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
|
|
+ if (!"parent".equals(role)) {
|
|
|
|
|
+ return Result.error("仅家长可修改挑战");
|
|
|
|
|
+ }
|
|
|
|
|
+ return familyChallengeService.updateChallenge(challengeId, userId, familyId, params);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// delete:仅 parent
|
|
|
|
|
+@PostMapping("/delete/{id}")
|
|
|
|
|
+public Result<Void> deleteChallenge(@PathVariable("id") Long challengeId,
|
|
|
|
|
+ @RequestAttribute(value = "role", required = false) String role,
|
|
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
|
|
+ if (!"parent".equals(role)) {
|
|
|
|
|
+ return Result.error("仅家长可删除挑战");
|
|
|
|
|
+ }
|
|
|
|
|
+ return familyChallengeService.deleteChallenge(challengeId, userId, familyId);
|
|
|
}
|
|
}
|
|
|
-// update/delete 同理加 "仅家长" 校验
|
|
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
|
|
+> 注意:progress 端点不再读请求体 `memberId`(原 L40-41 的逻辑删除),统一用 `currentMemberId`(JwtInterceptor 对 child 角色已自动从 family_member 表查出并注入,见 `JwtInterceptor.java:147-159`)。
|
|
|
|
|
+
|
|
|
- [ ] **步骤 4:运行测试确认通过**
|
|
- [ ] **步骤 4:运行测试确认通过**
|
|
|
|
|
|
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
运行:`cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest -pl .`
|
|
@@ -373,8 +430,8 @@ git commit -m "feat(backend): 家庭挑战到期惰性结算 + 角色校验(
|
|
|
|
|
|
|
|
**文件:**
|
|
**文件:**
|
|
|
- 修改:`cfc-frontend/components/FamilyChallengeCard.vue`
|
|
- 修改:`cfc-frontend/components/FamilyChallengeCard.vue`
|
|
|
-- 修改:`cfc-frontend/pages/index-home/index.vue`(如需传 `childId`)
|
|
|
|
|
-- 修改:`cfc-frontend/pages/home-pages/parent-index.vue`(如需传 `childId`)
|
|
|
|
|
|
|
+- 修改:`cfc-frontend/pages/index-home/index.vue`(孩子端打卡入口)
|
|
|
|
|
+- **不改**:`cfc-frontend/pages/home-pages/parent-index.vue`(家长端 L51-53 只传 `:challenge="ch"`,`showCheckin` 默认 false 不显示打卡按钮——后端仅允许 child 角色打卡,家长端不应出现打卡入口;状态显示修复在组件内部自动生效,无需改动此文件)
|
|
|
|
|
|
|
|
- [ ] **步骤 1:修复 cancelled 状态显示**
|
|
- [ ] **步骤 1:修复 cancelled 状态显示**
|
|
|
|
|
|
|
@@ -394,16 +451,13 @@ git commit -m "feat(backend): 家庭挑战到期惰性结算 + 角色校验(
|
|
|
</text>
|
|
</text>
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-并在 `statusClass` 计算属性加 cancelled 分支:
|
|
|
|
|
|
|
+`statusClass` 计算属性(L55-57)已存在且通用(返回 `'status-' + status`),**无需修改**。但 style 区缺 `.status-cancelled` 样式(现有 L115-116 只有 `.status-active`/`.status-completed`),在 L116 后新增:
|
|
|
|
|
|
|
|
-```js
|
|
|
|
|
-statusClass: function() {
|
|
|
|
|
- var s = this.challenge && this.challenge.status ? this.challenge.status : 'active'
|
|
|
|
|
- return 'status-' + s
|
|
|
|
|
-}
|
|
|
|
|
|
|
+```css
|
|
|
|
|
+.status-cancelled { background: #FEE2E2; color: #DC2626; }
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-- [ ] **步骤 2:加打卡按钮(孩子端显示)**
|
|
|
|
|
|
|
+- [ ] **步骤 2:加打卡按钮(仅 child 登录角色显示)**
|
|
|
|
|
|
|
|
在卡片底部(`card-detail-row` 之后)加:
|
|
在卡片底部(`card-detail-row` 之后)加:
|
|
|
|
|
|
|
@@ -418,22 +472,18 @@ statusClass: function() {
|
|
|
```js
|
|
```js
|
|
|
props: {
|
|
props: {
|
|
|
challenge: { type: Object, default: null },
|
|
challenge: { type: Object, default: null },
|
|
|
- showCheckin: { type: Boolean, default: false },
|
|
|
|
|
- childId: { type: [Number, String], default: null }
|
|
|
|
|
|
|
+ showCheckin: { type: Boolean, default: false }
|
|
|
},
|
|
},
|
|
|
methods: {
|
|
methods: {
|
|
|
onCheckin: function() {
|
|
onCheckin: function() {
|
|
|
var self = this
|
|
var self = this
|
|
|
- if (!this.childId) {
|
|
|
|
|
- uni.showToast({ title: '请先选择家庭成员', icon: 'none' })
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
uni.showModal({
|
|
uni.showModal({
|
|
|
title: '确认打卡',
|
|
title: '确认打卡',
|
|
|
content: '确认完成今日打卡吗?',
|
|
content: '确认完成今日打卡吗?',
|
|
|
success: function(res) {
|
|
success: function(res) {
|
|
|
if (res.confirm) {
|
|
if (res.confirm) {
|
|
|
- self.$emit('checkin', { challengeId: self.challenge.id, childId: self.childId })
|
|
|
|
|
|
|
+ // 后端已强制使用 JWT 注入的 currentMemberId,无需传 childId
|
|
|
|
|
+ self.$emit('checkin', { challengeId: self.challenge.id })
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
@@ -441,43 +491,57 @@ methods: {
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-- [ ] **步骤 3:index-home.vue 传入 childId + 处理打卡事件**
|
|
|
|
|
|
|
+> **不要加 `childId` prop 守卫**:后端 progress 端点强制使用 JwtInterceptor 注入的 `currentMemberId`(对 `role=child` 自动从 family_member 表查出,`JwtInterceptor.java:147-159`),前端**不需要也不应传** childId。若在卡片加 `if (!this.childId)` 守卫会误拦合法孩子打卡——孩子登录时 `currentChildId` storage 可能为空(`store/index.js` 的 `setUserInfo` 不写该值、`setChildren` 只写 state 不写 storage)。
|
|
|
|
|
|
|
|
-`cfc-frontend/pages/index-home/index.vue` 第 98-105 行当前嵌入卡片未传 childId。改为:
|
|
|
|
|
|
|
+- [ ] **步骤 3:index-home.vue 接入打卡事件**
|
|
|
|
|
+
|
|
|
|
|
+`cfc-frontend/pages/index-home/index.vue` 当前 L98-105 的 section 结构已有 `section-header`("🔥 家庭挑战"标题),**不要改动外层结构**,只改 `FamilyChallengeCard` 标签(原 L104-109):
|
|
|
|
|
|
|
|
```html
|
|
```html
|
|
|
-<view class="section challenge-section" v-if="isLoggedIn() && activeChallenges.length > 0">
|
|
|
|
|
- <FamilyChallengeCard
|
|
|
|
|
- v-for="ch in activeChallenges"
|
|
|
|
|
- :key="getItemKey(ch)"
|
|
|
|
|
- :challenge="ch"
|
|
|
|
|
- :showCheckin="true"
|
|
|
|
|
- :childId="currentChildId"
|
|
|
|
|
- @checkin="onChallengeCheckin" />
|
|
|
|
|
-</view>
|
|
|
|
|
|
|
+<FamilyChallengeCard
|
|
|
|
|
+ v-for="ch in activeChallenges"
|
|
|
|
|
+ :key="ch.id"
|
|
|
|
|
+ :challenge="ch"
|
|
|
|
|
+ :showCheckin="showCheckinBtn"
|
|
|
|
|
+ @checkin="onChallengeCheckin" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 保留 `:key="ch.id"`(简单属性访问,合规);**不要**引入 `getItemKey`(index-home 无此方法,也不必要)。卡片已无 `childId` prop(见步骤 2),此处**不传** `:childId`。
|
|
|
|
|
+>
|
|
|
|
|
+> **打卡按钮必须按登录角色门控**:index-home 是双角色页面(家长登录后可切换到孩子视角,此时 `currentRole='child'` 但 JWT 登录角色仍是 `parent`),而后端 progress 端点仅接受 `role=child`(JwtInterceptor 只对 child 角色注入 `currentMemberId`,家长打卡必被拒)。所以 **不能** 用 `currentRole` 判断,必须用登录角色 `uni.getStorageSync('role')`。
|
|
|
|
|
+
|
|
|
|
|
+**3a. 补 import**:L379 的 api.js import 行加入 `updateChallengeProgress`(现有行已含 `getChallengeList`,在其后追加):
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getTodayTasksByCategory, getChildren, getEnergyOverview, getProductsByDomain, getChallengeList, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle, switchToFamilyMember, getMyMembership, updateChallengeProgress } from '../../utils/api.js'
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-并在 methods 加(`currentChildId` 从 Vuex/store 取当前孩子成员 id,若为空则用 `currentMemberId` 逻辑):
|
|
|
|
|
|
|
+**3b. 在 computed 区(L478 起)加** `showCheckinBtn`(登录角色为 child 才显示打卡按钮;家长切换孩子视角时 `currentRole='child'` 但登录角色仍是 `parent`,不可打卡):
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+showCheckinBtn: function() {
|
|
|
|
|
+ return (uni.getStorageSync('role') || 'parent') === 'child'
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**3c. 在 methods 加**:
|
|
|
|
|
|
|
|
```js
|
|
```js
|
|
|
onChallengeCheckin: function(payload) {
|
|
onChallengeCheckin: function(payload) {
|
|
|
var self = this
|
|
var self = this
|
|
|
- updateChallengeProgress({ challengeId: payload.challengeId, memberId: payload.childId, delta: 1 })
|
|
|
|
|
|
|
+ updateChallengeProgress({ challengeId: payload.challengeId, delta: 1 })
|
|
|
.then(function(res) {
|
|
.then(function(res) {
|
|
|
- if (res && res.code === 0) {
|
|
|
|
|
|
|
+ if (res && res.code === 200) {
|
|
|
uni.showToast({ title: '打卡成功', icon: 'success' })
|
|
uni.showToast({ title: '打卡成功', icon: 'success' })
|
|
|
self.loadChallenges()
|
|
self.loadChallenges()
|
|
|
} else {
|
|
} else {
|
|
|
uni.showToast({ title: (res && res.message) || '打卡失败', icon: 'none' })
|
|
uni.showToast({ title: (res && res.message) || '打卡失败', icon: 'none' })
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
-},
|
|
|
|
|
-getItemKey: function(item) {
|
|
|
|
|
- return item.id || item.challengeId || ''
|
|
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
-> 注意:`getItemKey` 若已在页面 methods 中存在(用于其他列表),复用;`currentChildId` 若页面已存在直接复用,否则从 Vuex store 读当前成员。
|
|
|
|
|
|
|
+> **不要加 `currentChildId` 守卫**:孩子登录时 `currentChildId` storage 可能为空(`store/index.js` 的 `setUserInfo` 不写该值、`setChildren` 只写 state 不写 storage),守卫会误拦合法打卡。后端已强制用 JWT 注入的 `currentMemberId`(见任务 3 步骤 3),请求体**不需要**传 `memberId`。`loadChallenges` 是 index-home 现有方法(L703),直接复用。
|
|
|
|
|
|
|
|
- [ ] **步骤 4:构建验证**
|
|
- [ ] **步骤 4:构建验证**
|
|
|
|
|
|
|
@@ -500,7 +564,7 @@ git commit -m "feat(frontend): 家庭挑战孩子端打卡入口 + cancelled 状
|
|
|
|
|
|
|
|
- [ ] **步骤 1:加角色判断**
|
|
- [ ] **步骤 1:加角色判断**
|
|
|
|
|
|
|
|
-在 `challenge-manage.vue` 的 `onLoad` 中读取角色,非 parent 时提示并返回:
|
|
|
|
|
|
|
+在 `challenge-manage.vue` 的 `onLoad` 开头(现有 `loadFamilyId()` 调用之前)插入角色判断:
|
|
|
|
|
|
|
|
```js
|
|
```js
|
|
|
onLoad: function() {
|
|
onLoad: function() {
|
|
@@ -510,14 +574,18 @@ onLoad: function() {
|
|
|
setTimeout(function() { uni.navigateBack() }, 1000)
|
|
setTimeout(function() { uni.navigateBack() }, 1000)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- // ... 原有加载逻辑
|
|
|
|
|
|
|
+ this.loadFamilyId()
|
|
|
|
|
+ this.loadData()
|
|
|
|
|
+ this.loadTemplates()
|
|
|
},
|
|
},
|
|
|
getUserRole: function() {
|
|
getUserRole: function() {
|
|
|
- // 从 store 读取当前角色
|
|
|
|
|
- return this.$store && this.$store.state && this.$store.state.userInfo && this.$store.state.userInfo.role
|
|
|
|
|
|
|
+ // store state 顶层有 role 字段(见 store/index.js:15)
|
|
|
|
|
+ return this.$store && this.$store.state ? this.$store.state.role : ''
|
|
|
}
|
|
}
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
|
|
+> 已核实:`challenge-manage.vue` 现有 `onLoad` 只调 `loadFamilyId()/loadData()/loadTemplates()`(L170-174),无其他前置逻辑;`getUserRole` 是新增方法,加入 `methods` 区。store 角色字段是 `state.role`(`store/index.js:15` 初始 `'parent'`),不是 `state.userInfo.role`。
|
|
|
|
|
+
|
|
|
- [ ] **步骤 2:验证现有加载逻辑未被破坏**
|
|
- [ ] **步骤 2:验证现有加载逻辑未被破坏**
|
|
|
|
|
|
|
|
读取 `challenge-manage.vue` 的完整 `onLoad`/`onShow` 及 `created` 钩子,确认插入位置正确、原有逻辑保留。
|
|
读取 `challenge-manage.vue` 的完整 `onLoad`/`onShow` 及 `created` 钩子,确认插入位置正确、原有逻辑保留。
|
|
@@ -558,16 +626,25 @@ cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ |
|
|
|
|
|
|
|
|
- [ ] **步骤 4:最终 Commit(若有遗漏改动)**
|
|
- [ ] **步骤 4:最终 Commit(若有遗漏改动)**
|
|
|
|
|
|
|
|
|
|
+> ⚠️ 仓库工作区存在 42 个遗留未提交文件(与本次无关)。**禁止** `git add -A` 或 `git add .`,必须用明确文件路径。
|
|
|
|
|
+
|
|
|
```bash
|
|
```bash
|
|
|
-git add -A -- cfc-backend/src/main/java/com/etotem/cfc cfc-frontend/components/FamilyChallengeCard.vue cfc-frontend/pages
|
|
|
|
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java \
|
|
|
|
|
+ cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java \
|
|
|
|
|
+ cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java \
|
|
|
|
|
+ cfc-frontend/components/FamilyChallengeCard.vue \
|
|
|
|
|
+ cfc-frontend/pages/index-home/index.vue \
|
|
|
|
|
+ cfc-frontend/pages/health/challenge-manage.vue
|
|
|
git commit -m "fix: 家庭挑战断点修复收尾"
|
|
git commit -m "fix: 家庭挑战断点修复收尾"
|
|
|
```
|
|
```
|
|
|
|
|
|
|
|
|
|
+> 提交前检查:`git diff --cached --stat` 确认 staged 内容仅含上述 6 个文件。
|
|
|
|
|
+
|
|
|
---
|
|
---
|
|
|
|
|
|
|
|
## 自检
|
|
## 自检
|
|
|
|
|
|
|
|
-**1. 规格覆盖度(spec §7 断点 ↔ 任务映射):**
|
|
|
|
|
|
|
+**1. 规格覆盖度(spec §7 断点 ↔ 任务映射,目标为修复 #1-#6,排除 #7):**
|
|
|
|
|
|
|
|
| spec §7 # | 断点 | 任务 |
|
|
| spec §7 # | 断点 | 任务 |
|
|
|
|---|---|---|
|
|
|---|---|---|
|
|
@@ -581,6 +658,16 @@ git commit -m "fix: 家庭挑战断点修复收尾"
|
|
|
|
|
|
|
|
**2. 占位符扫描:** 无 TBD/TODO。任务 2 已核实 `PointsService.awardSystemPoints` 签名并写死到计划中,无"实现时确认"残留。
|
|
**2. 占位符扫描:** 无 TBD/TODO。任务 2 已核实 `PointsService.awardSystemPoints` 签名并写死到计划中,无"实现时确认"残留。
|
|
|
|
|
|
|
|
-**3. 类型一致性:** `updateProgress(challengeId, childId, delta)` 签名全计划一致;`onCheckin`/`onChallengeCheckin`/`getItemKey` 命名一致;`getItemKey` 已在项目其他页面存在时复用。
|
|
|
|
|
|
|
+**3. 类型一致性:** `updateProgress(challengeId, childId, delta)` 签名全计划一致;`onCheckin`/`onChallengeCheckin` 命名一致;卡片 `:key="ch.id"` 为简单属性访问(合规,不违反"禁止 :key 表达式"规范);`currentChildId` 为 index-home 现有 data 属性直接复用。
|
|
|
|
|
+
|
|
|
|
|
+**4. 与代码库事实核对(本计划所有引用均已验证):**
|
|
|
|
|
+- `Result.success()` → `code=200`,`Result.error()` → `code=500`(`common/Result.java`),前端统一 `res.code === 200` 判断
|
|
|
|
|
+- `PointsService.awardSystemPoints(Long memberId, int amount, String reason)` 存在于 `service/PointsService.java:97`;`FamilyChallengeService` 需新增 `@Resource private PointsService pointsService;`
|
|
|
|
|
+- `JwtInterceptor` 已注入 `role`/`familyId`/`currentMemberId` attribute(child 角色自动查 family_member 表,`JwtInterceptor.java:147-159`)
|
|
|
|
|
+- `FamilyChallengeCard.vue` 的 `statusClass` 计算属性已存在且通用,仅缺 `.status-cancelled` CSS
|
|
|
|
|
+- index-home 现有 `section-header`("🔥 家庭挑战")不重复渲染;`currentChildId` 是现有 data 属性(L458);`loadChallenges` 是现有方法(L703)
|
|
|
|
|
+- `challenge-manage.vue` 现有 `onLoad` 仅调三个 load 方法;store 角色字段是 `state.role`(`store/index.js:15`)
|
|
|
|
|
+- `FamilyChallengeController` 各端点现有签名已读取(L19-72),替换片段基于真实代码
|
|
|
|
|
+- 仓库有 42 个遗留未提交文件,所有 git 命令均为明确路径,禁止 `git add -A`
|
|
|
|
|
|
|
|
**执行交接:** 计划已保存到 `docs/superpowers/plans/2026-08-06-family-challenge-fix.md`。
|
|
**执行交接:** 计划已保存到 `docs/superpowers/plans/2026-08-06-family-challenge-fix.md`。
|