# DOA 家庭打卡(个人目标-周计划-周复盘)实现计划 > **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 **目标:** 在家庭挑战模块中新增并列的「个人目标 DOA」体系:每人每阶段定 1-3 个领域目标(自选 2/4/8/12 周周期),每周填写行动宣告计划(A表),周末填写复盘五问(B表),家庭成员互相可见、能登录用户各自管理、不能登录成员由能登录用户代管。 **架构:** 全新 3 张表(`doa_stage` / `doa_goal` / `doa_week`)+ 新 `DoaService` + `DoaController`(`/api/doa/*`),与现有 `family_challenge` 完全解耦。前端在 `pages/health/challenge-manage.vue` 顶部加「共同挑战/个人目标」双 tab,新增 4 个页面(总览/创建/详情/周编辑)。 **技术栈:** Spring Boot 2.7.18 + MyBatis-Plus(Java 8)、uni-app Vue 2 小程序(Options API)、MySQL 8。 **规格文档:** `docs/superpowers/specs/2026-09-12-doa-family-goal-design.md` **数据库迁移:** 迁移 314(最新编号 313),`DatabaseInitializer.runMigrations()` + `schema.sql` 同步 --- ## 文件结构 **后端(全部新建,除标注修改):** | 文件 | 职责 | |---|---| | `cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java` | 阶段实体(doa_stage 表) | | `cfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.java` | 目标实体(doa_goal 表) | | `cfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.java` | 周记录实体(doa_week 表) | | `cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.java` | 阶段 Mapper | | `cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java` | 目标 Mapper | | `cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java` | 周记录 Mapper | | `cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java` | 业务逻辑(创建/查询/计划/复盘/代管校验) | | `cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java` | REST 接口(/api/doa/*) | | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` **修改** | 迁移 314 建三表 | | `cfc-backend/src/main/resources/schema.sql` **修改** | 同步三表 CREATE TABLE | **前端:** | 文件 | 职责 | |---|---| | `cfc-frontend/utils/api.js` **修改** | 新增 7 个 doa 接口封装 | | `cfc-frontend/pages.json` **修改** | 注册 4 个 doa 页面 | | `cfc-frontend/pages/health/challenge-manage.vue` **修改** | 顶部加「共同挑战/个人目标」tab | | `cfc-frontend/pages/health/doa-index.vue` | DOA 家庭总览 | | `cfc-frontend/pages/health/doa-stage-create.vue` | 创建阶段(选成员/周期/目标) | | `cfc-frontend/pages/health/doa-stage-detail.vue` | 阶段详情(目标+周时间线) | | `cfc-frontend/pages/health/doa-week-edit.vue` | 周计划/复盘编辑 | **文档:** | 文件 | 职责 | |---|---| | `docs/superpowers/api/API_REFERENCE.md` **修改** | 登记 /api/doa/* 接口 | | `AGENTS.md` / `cfc-backend/AGENTS.md` **修改** | 记录「孩子=不能登录用户」规范 | --- ## 任务 1:数据库迁移 314(建三表) **文件:** - 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`(文件末尾 `}` 前,`seedGutFloraFingerprints()` 方法之后) - 修改:`cfc-backend/src/main/resources/schema.sql`(文件末尾追加) - [ ] **步骤 1:在 DatabaseInitializer 末尾添加迁移 314** 在 `runMigrations()` 方法末尾(`seedGutFloraFingerprints();` 调用之后、方法闭合 `}` 之前)追加以下代码: ```java // 迁移314: 创建 DOA 个人目标-周计划-周复盘三表(家庭打卡个人目标模块,2026-09-12) try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_stage (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "family_id BIGINT NOT NULL COMMENT '家庭ID', " + "member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)', " + "title VARCHAR(200) COMMENT '阶段主题(可选)', " + "start_date DATE NOT NULL COMMENT '阶段开始日期', " + "end_date DATE NOT NULL COMMENT '阶段结束日期', " + "duration_weeks TINYINT NOT NULL COMMENT '周期周数:2/4/8/12', " + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', " + "created_by BIGINT NOT NULL COMMENT '创建人userId(代管时为代管人)', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_ds_family_member (family_id, member_id), " + "INDEX idx_ds_status (status) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段'"); log.info("迁移314: 已创建 doa_stage 表"); } catch (Exception e) { log.warn("迁移314: 创建 doa_stage 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_goal (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " + "domain VARCHAR(20) NOT NULL COMMENT '领域:health/wealth/family/social/help', " + "content VARCHAR(500) NOT NULL COMMENT '量化目标内容', " + "status VARCHAR(20) DEFAULT 'active' COMMENT 'active/achieved/abandoned', " + "sort_order TINYINT DEFAULT 0 COMMENT '排序', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "INDEX idx_dg_stage (stage_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段目标'"); log.info("迁移314: 已创建 doa_goal 表"); } catch (Exception e) { log.warn("迁移314: 创建 doa_goal 表失败: {}", e.getMessage()); } try { jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS doa_week (" + "id BIGINT AUTO_INCREMENT PRIMARY KEY, " + "stage_id BIGINT NOT NULL COMMENT '所属阶段ID', " + "week_no TINYINT NOT NULL COMMENT '第几周(1起)', " + "week_start DATE NOT NULL COMMENT '本周开始(周一)', " + "week_end DATE NOT NULL COMMENT '本周结束(周日)', " + "plan TEXT COMMENT '周行动宣告计划(A表)', " + "achieved TEXT COMMENT '复盘:本周达成了什么', " + "experience TEXT COMMENT '复盘:有什么体验', " + "worked TEXT COMMENT '复盘:行得通的', " + "improve TEXT COMMENT '复盘:要提升/行不通的', " + "next_challenge TEXT COMMENT '复盘:下周挑战自己什么', " + "status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/planned/reviewed', " + "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " + "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + "UNIQUE KEY uk_dw_stage_week (stage_id, week_no), " + "INDEX idx_dw_stage (stage_id) " + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录'"); log.info("迁移314: 已创建 doa_week 表"); } catch (Exception e) { log.warn("迁移314: 创建 doa_week 表失败: {}", e.getMessage()); } ``` - [ ] **步骤 2:同步 schema.sql** 在 `cfc-backend/src/main/resources/schema.sql` 文件末尾追加: ```sql -- ============================================================ -- DOA 个人目标-周计划-周复盘(迁移314,家庭打卡个人目标模块) -- ============================================================ CREATE TABLE IF NOT EXISTS doa_stage ( id BIGINT AUTO_INCREMENT PRIMARY KEY, family_id BIGINT NOT NULL COMMENT '家庭ID', member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)', title VARCHAR(200) COMMENT '阶段主题(可选)', start_date DATE NOT NULL COMMENT '阶段开始日期', end_date DATE NOT NULL COMMENT '阶段结束日期', duration_weeks TINYINT NOT NULL COMMENT '周期周数:2/4/8/12', status VARCHAR(20) DEFAULT 'active' COMMENT 'active/completed/cancelled', created_by BIGINT NOT NULL COMMENT '创建人userId(代管时为代管人)', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_ds_family_member (family_id, member_id), INDEX idx_ds_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段'; CREATE TABLE IF NOT EXISTS doa_goal ( id BIGINT AUTO_INCREMENT PRIMARY KEY, stage_id BIGINT NOT NULL COMMENT '所属阶段ID', domain VARCHAR(20) NOT NULL COMMENT '领域:health/wealth/family/social/help', content VARCHAR(500) NOT NULL COMMENT '量化目标内容', status VARCHAR(20) DEFAULT 'active' COMMENT 'active/achieved/abandoned', sort_order TINYINT DEFAULT 0 COMMENT '排序', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_dg_stage (stage_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 阶段目标'; CREATE TABLE IF NOT EXISTS doa_week ( id BIGINT AUTO_INCREMENT PRIMARY KEY, stage_id BIGINT NOT NULL COMMENT '所属阶段ID', week_no TINYINT NOT NULL COMMENT '第几周(1起)', week_start DATE NOT NULL COMMENT '本周开始(周一)', week_end DATE NOT NULL COMMENT '本周结束(周日)', plan TEXT COMMENT '周行动宣告计划(A表)', achieved TEXT COMMENT '复盘:本周达成了什么', experience TEXT COMMENT '复盘:有什么体验', worked TEXT COMMENT '复盘:行得通的', improve TEXT COMMENT '复盘:要提升/行不通的', next_challenge TEXT COMMENT '复盘:下周挑战自己什么', status VARCHAR(20) DEFAULT 'pending' COMMENT 'pending/planned/reviewed', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_dw_stage_week (stage_id, week_no), INDEX idx_dw_stage (stage_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='DOA 周记录'; ``` - [ ] **步骤 3:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS(迁移代码是幂等的 try-catch,无需运行时验证) - [ ] **步骤 4:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql git commit -m "feat(doa): 迁移314创建DOA三表(doa_stage/doa_goal/doa_week)" ``` --- ## 任务 2:后端 Entity + Mapper(3 实体 3 Mapper) **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java` - 创建:`cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java` - [ ] **步骤 1:创建 DoaStage 实体** ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; import java.util.List; import java.util.Map; @Data @TableName("doa_stage") public class DoaStage implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long familyId; private Long memberId; private String title; private Date startDate; private Date endDate; private Integer durationWeeks; private String status; private Long createdBy; private Date createdAt; private Date updatedAt; /** 非表字段:目标列表 */ @TableField(exist = false) private List goals; /** 非表字段:周记录列表 */ @TableField(exist = false) private List weeks; /** 非表字段:目标成员昵称 */ @TableField(exist = false) private String memberNickname; /** 非表字段:已完成周数(复盘过的周) */ @TableField(exist = false) private Integer reviewedWeeks; /** 非表字段:周总数 */ @TableField(exist = false) private Integer totalWeeks; } ``` - [ ] **步骤 2:创建 DoaGoal 实体** ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; @Data @TableName("doa_goal") public class DoaGoal implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long stageId; private String domain; private String content; private String status; private Integer sortOrder; private Date createdAt; private Date updatedAt; } ``` - [ ] **步骤 3:创建 DoaWeek 实体** ```java package com.etotem.cfc.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.io.Serializable; import java.util.Date; @Data @TableName("doa_week") public class DoaWeek implements Serializable { @TableId(type = IdType.AUTO) private Long id; private Long stageId; private Integer weekNo; private Date weekStart; private Date weekEnd; private String plan; private String achieved; private String experience; private String worked; private String improve; private String nextChallenge; private String status; private Date createdAt; private Date updatedAt; } ``` - [ ] **步骤 4:创建 3 个 Mapper** ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.DoaStage; public interface DoaStageMapper extends BaseMapper { } ``` ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.DoaGoal; public interface DoaGoalMapper extends BaseMapper { } ``` ```java package com.etotem.cfc.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.etotem.cfc.entity.DoaWeek; public interface DoaWeekMapper extends BaseMapper { } ``` - [ ] **步骤 5:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS - [ ] **步骤 6:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java cfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.java cfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.java cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java git commit -m "feat(doa): 新增DoaStage/DoaGoal/DoaWeek实体与Mapper" ``` --- ## 任务 3:DoaService(核心业务逻辑) **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java` **依赖注入的 Mapper/Service:** `DoaStageMapper`、`DoaGoalMapper`、`DoaWeekMapper`、`FamilyMemberMapper`(校验家庭归属与代管权限) - [ ] **步骤 1:创建 DoaService(完整代码)** ```java package com.etotem.cfc.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.etotem.cfc.common.Result; import com.etotem.cfc.entity.DoaGoal; import com.etotem.cfc.entity.DoaStage; import com.etotem.cfc.entity.DoaWeek; import com.etotem.cfc.entity.FamilyMember; import com.etotem.cfc.mapper.DoaGoalMapper; import com.etotem.cfc.mapper.DoaStageMapper; import com.etotem.cfc.mapper.DoaWeekMapper; import com.etotem.cfc.mapper.FamilyMemberMapper; import com.etotem.cfc.util.SortUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.text.SimpleDateFormat; import java.util.*; @Slf4j @Service public class DoaService { @Resource private DoaStageMapper doaStageMapper; @Resource private DoaGoalMapper doaGoalMapper; @Resource private DoaWeekMapper doaWeekMapper; @Resource private FamilyMemberMapper familyMemberMapper; private static final Set VALID_DOMAINS = new HashSet<>(Arrays.asList( "health", "wealth", "family", "social", "help")); /** 周起始:周一 */ private static Date weekStart(Date date) { Calendar cal = Calendar.getInstance(); cal.setTime(date); cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); return cal.getTime(); } private static Date weekEnd(Date weekStart) { Calendar cal = Calendar.getInstance(); cal.setTime(weekStart); cal.add(Calendar.DAY_OF_YEAR, 6); cal.set(Calendar.HOUR_OF_DAY, 23); cal.set(Calendar.MINUTE, 59); cal.set(Calendar.SECOND, 59); cal.set(Calendar.MILLISECOND, 999); return cal.getTime(); } /** * 创建阶段:校验归属 + 周期 + 目标数量,写 stage + goals,预生成 N 条 week。 * * @param memberId 目标成员ID;null 表示当前登录成员自己 * @param creatorUserId 当前登录 userId * @param familyId 当前家庭 */ @Transactional public Result createStage(Long memberId, Long creatorUserId, Long familyId, String title, Integer durationWeeks, List> goals) { if (creatorUserId == null || familyId == null) { return Result.error("未登录或未加入家庭"); } if (durationWeeks == null || (durationWeeks != 2 && durationWeeks != 4 && durationWeeks != 8 && durationWeeks != 12)) { return Result.error("durationWeeks必须是2/4/8/12"); } if (goals == null || goals.isEmpty() || goals.size() > 3) { return Result.error("每阶段需设置1-3个目标"); } // 目标成员:默认当前登录成员自己 if (memberId == null) { memberId = resolveMemberIdByUserId(creatorUserId, familyId); if (memberId == null) { return Result.error("未找到当前成员身份"); } } // 校验目标成员属于当前家庭 FamilyMember targetMember = familyMemberMapper.selectById(memberId); if (targetMember == null || !familyId.equals(targetMember.getFamilyId())) { return Result.error("目标成员不属于当前家庭"); } // 代管校验:memberId != 当前成员 时,目标成员必须是不能登录成员(userId 为空) Long selfMemberId = resolveMemberIdByUserId(creatorUserId, familyId); if (selfMemberId != null && !selfMemberId.equals(memberId)) { if (targetMember.getUserId() != null) { return Result.error("只能代管不能登录成员的目标"); } } // 校验目标领域与去重 Set domains = new HashSet<>(); for (Map g : goals) { String domain = g.get("domain") != null ? g.get("domain").toString() : ""; String content = g.get("content") != null ? g.get("content").toString().trim() : ""; if (!VALID_DOMAINS.contains(domain)) { return Result.error("目标领域无效: " + domain); } if (content.isEmpty()) { return Result.error("目标内容不能为空"); } if (!domains.add(domain)) { return Result.error("同一阶段目标领域不能重复: " + domain); } } // 日期计算:start=今天,end=start + durationWeeks*7 - 1 Date startDate = new Date(); Calendar cal = Calendar.getInstance(); cal.setTime(startDate); cal.add(Calendar.DAY_OF_YEAR, durationWeeks * 7 - 1); Date endDate = cal.getTime(); // 写 stage DoaStage stage = new DoaStage(); stage.setFamilyId(familyId); stage.setMemberId(memberId); stage.setTitle(title); stage.setStartDate(startDate); stage.setEndDate(endDate); stage.setDurationWeeks(durationWeeks); stage.setStatus("active"); stage.setCreatedBy(creatorUserId); stage.setCreatedAt(new Date()); doaStageMapper.insert(stage); // 写 goals int order = 0; for (Map g : goals) { DoaGoal goal = new DoaGoal(); goal.setStageId(stage.getId()); goal.setDomain(g.get("domain").toString()); goal.setContent(g.get("content").toString().trim()); goal.setStatus("active"); goal.setSortOrder(order++); goal.setCreatedAt(new Date()); doaGoalMapper.insert(goal); } // 预生成 N 条 week(周一为一周起点,首周从 start 所在周的周一起算) Date firstWeekStart = weekStart(stage.getStartDate()); for (int i = 1; i <= durationWeeks; i++) { DoaWeek week = new DoaWeek(); week.setStageId(stage.getId()); week.setWeekNo(i); Date ws = addDays(firstWeekStart, (i - 1) * 7); week.setWeekStart(ws); week.setWeekEnd(weekEnd(ws)); week.setStatus("pending"); week.setCreatedAt(new Date()); doaWeekMapper.insert(week); } log.info("DOA创建阶段: stageId={}, memberId={}, weeks={}, goals={}", stage.getId(), memberId, durationWeeks, goals.size()); return Result.success(stage.getId()); } /** 家庭 DOA 总览:各成员当前 active 阶段 */ public List> getFamilyOverview(Long familyId) { List> result = new ArrayList<>(); if (familyId == null) return result; LambdaQueryWrapper mw = new LambdaQueryWrapper() .eq(FamilyMember::getFamilyId, familyId); SortUtil.applySort(mw); List members = familyMemberMapper.selectList(mw); for (FamilyMember m : members) { LambdaQueryWrapper sw = new LambdaQueryWrapper() .eq(DoaStage::getFamilyId, familyId) .eq(DoaStage::getMemberId, m.getId()) .eq(DoaStage::getStatus, "active") .orderByDesc(DoaStage::getCreatedAt); SortUtil.applySort(sw); List stages = doaStageMapper.selectList(sw); Map memberMap = new HashMap<>(); memberMap.put("memberId", m.getId()); memberMap.put("nickname", m.getNickname()); memberMap.put("memberType", m.getUserId() == null ? "local" : "login"); if (!stages.isEmpty()) { DoaStage current = stages.get(0); fillStageDetail(current); memberMap.put("currentStage", current); } result.add(memberMap); } return result; } /** 阶段详情(stage + goals + weeks) */ public Result getStageDetail(Long stageId, Long familyId) { if (stageId == null) return Result.error("stageId不能为空"); DoaStage stage = doaStageMapper.selectById(stageId); if (stage == null) return Result.error("阶段不存在"); if (!familyId.equals(stage.getFamilyId())) return Result.error("无权查看该阶段"); fillStageDetail(stage); // 成员昵称 FamilyMember m = familyMemberMapper.selectById(stage.getMemberId()); stage.setMemberNickname(m != null ? m.getNickname() : "成员" + stage.getMemberId()); return Result.success(stage); } /** 填充 stage 的 goals + weeks(含复盘周数统计) */ private void fillStageDetail(DoaStage stage) { LambdaQueryWrapper gw = new LambdaQueryWrapper() .eq(DoaGoal::getStageId, stage.getId()) .orderByAsc(DoaGoal::getSortOrder); SortUtil.applySort(gw); stage.setGoals(doaGoalMapper.selectList(gw)); LambdaQueryWrapper ww = new LambdaQueryWrapper() .eq(DoaWeek::getStageId, stage.getId()) .orderByAsc(DoaWeek::getWeekNo); SortUtil.applySort(ww); List weeks = doaWeekMapper.selectList(ww); stage.setWeeks(weeks); stage.setTotalWeeks(weeks.size()); int reviewed = 0; for (DoaWeek w : weeks) { if ("reviewed".equals(w.getStatus())) reviewed++; } stage.setReviewedWeeks(reviewed); } /** 取消阶段 */ @Transactional public Result cancelStage(Long stageId, Long userId, Long familyId) { DoaStage stage = doaStageMapper.selectById(stageId); if (stage == null) return Result.error("阶段不存在"); if (!familyId.equals(stage.getFamilyId())) return Result.error("无权操作该阶段"); if (!"active".equals(stage.getStatus())) return Result.error("仅active阶段可取消"); // 权限:本人 或 代管不能登录成员 Long selfMemberId = resolveMemberIdByUserId(userId, familyId); boolean isSelf = selfMemberId != null && selfMemberId.equals(stage.getMemberId()); FamilyMember target = familyMemberMapper.selectById(stage.getMemberId()); boolean canManageLocal = target != null && target.getUserId() == null; if (!isSelf && !canManageLocal) { return Result.error("无权取消该阶段"); } stage.setStatus("cancelled"); doaStageMapper.updateById(stage); return Result.success(); } /** 提交周计划(A表) */ @Transactional public Result submitWeekPlan(Long weekId, String plan, Long userId, Long familyId) { DoaWeek week = doaWeekMapper.selectById(weekId); if (week == null) return Result.error("周记录不存在"); DoaStage stage = doaStageMapper.selectById(week.getStageId()); if (stage == null || !familyId.equals(stage.getFamilyId())) return Result.error("无权操作"); if (!checkStageMemberPermission(stage, userId, familyId)) return Result.error("无权操作该阶段"); if ("reviewed".equals(week.getStatus())) return Result.error("已复盘不可修改计划"); week.setPlan(plan); if ("pending".equals(week.getStatus())) week.setStatus("planned"); doaWeekMapper.updateById(week); return Result.success(); } /** 提交周复盘(B表五问) */ @Transactional public Result submitWeekReview(Long weekId, Map review, Long userId, Long familyId) { DoaWeek week = doaWeekMapper.selectById(weekId); if (week == null) return Result.error("周记录不存在"); DoaStage stage = doaStageMapper.selectById(week.getStageId()); if (stage == null || !familyId.equals(stage.getFamilyId())) return Result.error("无权操作"); if (!checkStageMemberPermission(stage, userId, familyId)) return Result.error("无权操作该阶段"); if ("reviewed".equals(week.getStatus())) return Result.error("本周已复盘,不可重复提交"); week.setAchieved(review.get("achieved")); week.setExperience(review.get("experience")); week.setWorked(review.get("worked")); week.setImprove(review.get("improve")); week.setNextChallenge(review.get("nextChallenge")); week.setStatus("reviewed"); doaWeekMapper.updateById(week); return Result.success(); } /** 周记录列表(按阶段查,含周序号) */ public List listWeeks(Long stageId, Long familyId) { DoaStage stage = doaStageMapper.selectById(stageId); if (stage == null || !familyId.equals(stage.getFamilyId())) return new ArrayList<>(); LambdaQueryWrapper ww = new LambdaQueryWrapper() .eq(DoaWeek::getStageId, stageId) .orderByAsc(DoaWeek::getWeekNo); SortUtil.applySort(ww); return doaWeekMapper.selectList(ww); } /** 校验当前用户对该阶段的编辑权限(本人 或 代管不能登录成员) */ private boolean checkStageMemberPermission(DoaStage stage, Long userId, Long familyId) { Long selfMemberId = resolveMemberIdByUserId(userId, familyId); if (selfMemberId != null && selfMemberId.equals(stage.getMemberId())) return true; FamilyMember target = familyMemberMapper.selectById(stage.getMemberId()); return target != null && target.getUserId() == null; } /** userId → 家庭内成员ID */ private Long resolveMemberIdByUserId(Long userId, Long familyId) { if (userId == null) return null; LambdaQueryWrapper mw = new LambdaQueryWrapper() .eq(FamilyMember::getUserId, userId) .eq(FamilyMember::getFamilyId, familyId); SortUtil.applySort(mw); List members = familyMemberMapper.selectList(mw); return members.isEmpty() ? null : members.get(0).getId(); } private static Date addDays(Date date, int days) { Calendar cal = Calendar.getInstance(); cal.setTime(date); cal.add(Calendar.DAY_OF_YEAR, days); return cal.getTime(); } } ``` > **注意:** 若 `Result` 类没有 `noFamily` 之外所需的静态方法(如 `error(String)`),先阅读 `cfc-backend/src/main/java/com/etotem/cfc/common/Result.java` 确认方法签名。上述代码假设 `Result.error(String)` 与 `Result.success(T)` 存在(与 `FamilyChallengeService` 用法一致)。`SortUtil.applySort(wrapper)` 是项目通用排序工具,与 `FamilyChallengeService` 用法一致。 - [ ] **步骤 2:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS(若有 Result 方法名不一致的编译错误,按 Result.java 实际签名调整) - [ ] **步骤 3:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java git commit -m "feat(doa): 新增DoaService(创建阶段/家庭总览/详情/周计划/周复盘)" ``` --- ## 任务 4:DoaController(REST 接口) **文件:** - 创建:`cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java` - [ ] **步骤 1:创建 DoaController(完整代码)** ```java package com.etotem.cfc.controller; import com.etotem.cfc.common.Result; import com.etotem.cfc.entity.DoaStage; import com.etotem.cfc.entity.DoaWeek; import com.etotem.cfc.service.DoaService; import com.etotem.cfc.util.ParamUtils; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/doa") public class DoaController { @Resource private DoaService doaService; @PostMapping("/stage/create") public Result createStage(@RequestBody Map params, @RequestAttribute(value = "familyId", required = false) Long familyId, @RequestAttribute(value = "userId", required = false) Long userId) { Long memberId = params.get("memberId") != null ? Long.valueOf(params.get("memberId").toString()) : null; String title = params.get("title") != null ? params.get("title").toString() : null; Integer durationWeeks = params.get("durationWeeks") != null ? Integer.parseInt(params.get("durationWeeks").toString()) : null; List> goals = params.get("goals") instanceof List ? (List>) params.get("goals") : null; return doaService.createStage(memberId, userId, familyId, title, durationWeeks, goals); } @PostMapping("/overview") public Result>> overview(@RequestAttribute(value = "familyId", required = false) Long familyId) { if (familyId == null) { return Result.noFamily("请先创建或加入家庭"); } return Result.success(doaService.getFamilyOverview(familyId)); } @PostMapping("/stage/detail") public Result stageDetail(@RequestBody Map params, @RequestAttribute(value = "familyId", required = false) Long familyId) { Long stageId = ParamUtils.getLong(params.get("stageId")); return doaService.getStageDetail(stageId, familyId); } @PostMapping("/stage/cancel") public Result cancelStage(@RequestBody Map params, @RequestAttribute(value = "familyId", required = false) Long familyId, @RequestAttribute(value = "userId", required = false) Long userId) { Long stageId = ParamUtils.getLong(params.get("stageId")); return doaService.cancelStage(stageId, userId, familyId); } @PostMapping("/week/plan") public Result submitPlan(@RequestBody Map params, @RequestAttribute(value = "familyId", required = false) Long familyId, @RequestAttribute(value = "userId", required = false) Long userId) { Long weekId = ParamUtils.getLong(params.get("weekId")); String plan = params.get("plan") != null ? params.get("plan").toString() : ""; return doaService.submitWeekPlan(weekId, plan, userId, familyId); } @PostMapping("/week/review") public Result submitReview(@RequestBody Map params, @RequestAttribute(value = "familyId", required = false) Long familyId, @RequestAttribute(value = "userId", required = false) Long userId) { Long weekId = ParamUtils.getLong(params.get("weekId")); Map review = new java.util.HashMap<>(); review.put("achieved", params.get("achieved") != null ? params.get("achieved").toString() : null); review.put("experience", params.get("experience") != null ? params.get("experience").toString() : null); review.put("worked", params.get("worked") != null ? params.get("worked").toString() : null); review.put("improve", params.get("improve") != null ? params.get("improve").toString() : null); review.put("nextChallenge", params.get("nextChallenge") != null ? params.get("nextChallenge").toString() : null); return doaService.submitWeekReview(weekId, review, userId, familyId); } @PostMapping("/week/list") public Result> weekList(@RequestBody Map params, @RequestAttribute(value = "familyId", required = false) Long familyId) { Long stageId = ParamUtils.getLong(params.get("stageId")); return Result.success(doaService.listWeeks(stageId, familyId)); } } ``` > **注意:** `ParamUtils.getLong` 是项目已有工具(`FamilyChallengeController` 中已使用)。`Result.noFamily(String)` 也是已有方法。若编译报错,按实际签名调整。 - [ ] **步骤 2:路由重复检查** 运行:`grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep 'doa'` 预期:仅显示 `/doa/stage/create`、`/doa/overview`、`/doa/stage/detail`、`/doa/stage/cancel`、`/doa/week/plan`、`/doa/week/review`、`/doa/week/list`,无重复 - [ ] **步骤 3:编译验证** 运行:`cd cfc-backend && mvn clean compile` 预期:BUILD SUCCESS - [ ] **步骤 4:Commit** ```bash git add cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java git commit -m "feat(doa): 新增DoaController(/api/doa 七接口)" ``` --- ## 任务 5:前端 API 封装 + 页面注册 + 入口 tab **文件:** - 修改:`cfc-frontend/utils/api.js`(在家庭挑战接口块之后追加) - 修改:`cfc-frontend/pages.json`(`pages/health` 分包内追加 4 页) - 修改:`cfc-frontend/pages/health/challenge-manage.vue`(顶部加 tab) - [ ] **步骤 1:api.js 追加 DOA 接口封装** 在 `cfc-frontend/utils/api.js` 中 `export const sendLike` 之后追加: ```js // ===== DOA 个人目标(家庭打卡个人目标模块) ===== export const createDoaStage = (data) => request('/api/doa/stage/create', 'POST', data) export const getDoaOverview = () => request('/api/doa/overview', 'POST', {}) export const getDoaStageDetail = (stageId) => request('/api/doa/stage/detail', 'POST', { stageId: stageId }) export const cancelDoaStage = (stageId) => request('/api/doa/stage/cancel', 'POST', { stageId: stageId }) export const submitDoaWeekPlan = (weekId, plan) => request('/api/doa/week/plan', 'POST', { weekId: weekId, plan: plan }) export const submitDoaWeekReview = (data) => request('/api/doa/week/review', 'POST', data) export const getDoaWeekList = (stageId) => request('/api/doa/week/list', 'POST', { stageId: stageId }) ``` - [ ] **步骤 2:pages.json 注册 4 个页面** 在 `cfc-frontend/pages.json` 的 `"root": "pages/health"` 分包 `pages` 数组中,`challenge-manage` 条目之后追加: ```json { "path": "doa-index", "style": { "navigationBarTitleText": "个人目标" } }, { "path": "doa-stage-create", "style": { "navigationBarTitleText": "创建阶段" } }, { "path": "doa-stage-detail", "style": { "navigationBarTitleText": "阶段详情" } }, { "path": "doa-week-edit", "style": { "navigationBarTitleText": "周计划与复盘" } } ``` - [ ] **步骤 3:challenge-manage.vue 顶部加 tab** 修改 `cfc-frontend/pages/health/challenge-manage.vue`: 1. `