面向 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 修改 | 记录「孩子=不能登录用户」规范 |
文件:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(文件末尾 } 前,seedGutFloraFingerprints() 方法之后)修改:cfc-backend/src/main/resources/schema.sql(文件末尾追加)
[ ] 步骤 1:在 DatabaseInitializer 末尾添加迁移 314
在 runMigrations() 方法末尾(seedGutFloraFingerprints(); 调用之后、方法闭合 } 之前)追加以下代码:
// 迁移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());
}
在 cfc-backend/src/main/resources/schema.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 周记录';
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS(迁移代码是幂等的 try-catch,无需运行时验证)
[ ] 步骤 4:Commit
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)"
文件:
cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.javacfc-backend/src/main/java/com/etotem/cfc/entity/DoaGoal.javacfc-backend/src/main/java/com/etotem/cfc/entity/DoaWeek.javacfc-backend/src/main/java/com/etotem/cfc/mapper/DoaStageMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/DoaGoalMapper.java创建:cfc-backend/src/main/java/com/etotem/cfc/mapper/DoaWeekMapper.java
[ ] 步骤 1:创建 DoaStage 实体
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<DoaGoal> goals;
/** 非表字段:周记录列表 */
@TableField(exist = false)
private List<DoaWeek> weeks;
/** 非表字段:目标成员昵称 */
@TableField(exist = false)
private String memberNickname;
/** 非表字段:已完成周数(复盘过的周) */
@TableField(exist = false)
private Integer reviewedWeeks;
/** 非表字段:周总数 */
@TableField(exist = false)
private Integer totalWeeks;
}
[ ] 步骤 2:创建 DoaGoal 实体
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 实体
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
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.DoaStage;
public interface DoaStageMapper extends BaseMapper<DoaStage> {
}
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.DoaGoal;
public interface DoaGoalMapper extends BaseMapper<DoaGoal> {
}
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.DoaWeek;
public interface DoaWeekMapper extends BaseMapper<DoaWeek> {
}
[ ] 步骤 5:编译验证
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 6:Commit
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"
文件:
cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java依赖注入的 Mapper/Service: DoaStageMapper、DoaGoalMapper、DoaWeekMapper、FamilyMemberMapper(校验家庭归属与代管权限)
[ ] 步骤 1:创建 DoaService(完整代码)
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<String> 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<Long> createStage(Long memberId, Long creatorUserId, Long familyId,
String title, Integer durationWeeks, List<Map<String, Object>> 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<String> domains = new HashSet<>();
for (Map<String, Object> 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<String, Object> 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<Map<String, Object>> getFamilyOverview(Long familyId) {
List<Map<String, Object>> result = new ArrayList<>();
if (familyId == null) return result;
LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
.eq(FamilyMember::getFamilyId, familyId);
SortUtil.applySort(mw);
List<FamilyMember> members = familyMemberMapper.selectList(mw);
for (FamilyMember m : members) {
LambdaQueryWrapper<DoaStage> sw = new LambdaQueryWrapper<DoaStage>()
.eq(DoaStage::getFamilyId, familyId)
.eq(DoaStage::getMemberId, m.getId())
.eq(DoaStage::getStatus, "active")
.orderByDesc(DoaStage::getCreatedAt);
SortUtil.applySort(sw);
List<DoaStage> stages = doaStageMapper.selectList(sw);
Map<String, Object> 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<DoaStage> 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<DoaGoal> gw = new LambdaQueryWrapper<DoaGoal>()
.eq(DoaGoal::getStageId, stage.getId())
.orderByAsc(DoaGoal::getSortOrder);
SortUtil.applySort(gw);
stage.setGoals(doaGoalMapper.selectList(gw));
LambdaQueryWrapper<DoaWeek> ww = new LambdaQueryWrapper<DoaWeek>()
.eq(DoaWeek::getStageId, stage.getId())
.orderByAsc(DoaWeek::getWeekNo);
SortUtil.applySort(ww);
List<DoaWeek> 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<Void> 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<Void> 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<Void> submitWeekReview(Long weekId, Map<String, String> 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<DoaWeek> listWeeks(Long stageId, Long familyId) {
DoaStage stage = doaStageMapper.selectById(stageId);
if (stage == null || !familyId.equals(stage.getFamilyId())) return new ArrayList<>();
LambdaQueryWrapper<DoaWeek> ww = new LambdaQueryWrapper<DoaWeek>()
.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<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
.eq(FamilyMember::getUserId, userId)
.eq(FamilyMember::getFamilyId, familyId);
SortUtil.applySort(mw);
List<FamilyMember> 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用法一致。
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS(若有 Result 方法名不一致的编译错误,按 Result.java 实际签名调整)
[ ] 步骤 3:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java
git commit -m "feat(doa): 新增DoaService(创建阶段/家庭总览/详情/周计划/周复盘)"
文件:
创建:cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java
[ ] 步骤 1:创建 DoaController(完整代码)
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<Long> createStage(@RequestBody Map<String, Object> 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<Map<String, Object>> goals = params.get("goals") instanceof List
? (List<Map<String, Object>>) params.get("goals") : null;
return doaService.createStage(memberId, userId, familyId, title, durationWeeks, goals);
}
@PostMapping("/overview")
public Result<List<Map<String, Object>>> 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<DoaStage> stageDetail(@RequestBody Map<String, Object> 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<Void> cancelStage(@RequestBody Map<String, Object> 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<Void> submitPlan(@RequestBody Map<String, Object> 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<Void> submitReview(@RequestBody Map<String, Object> params,
@RequestAttribute(value = "familyId", required = false) Long familyId,
@RequestAttribute(value = "userId", required = false) Long userId) {
Long weekId = ParamUtils.getLong(params.get("weekId"));
Map<String, String> 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<List<DoaWeek>> weekList(@RequestBody Map<String, Object> 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)也是已有方法。若编译报错,按实际签名调整。
运行: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,无重复
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/DoaController.java
git commit -m "feat(doa): 新增DoaController(/api/doa 七接口)"
文件:
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 之后追加:
// ===== 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 })
在 cfc-frontend/pages.json 的 "root": "pages/health" 分包 pages 数组中,challenge-manage 条目之后追加:
{
"path": "doa-index",
"style": {
"navigationBarTitleText": "个人目标"
}
},
{
"path": "doa-stage-create",
"style": {
"navigationBarTitleText": "创建阶段"
}
},
{
"path": "doa-stage-detail",
"style": {
"navigationBarTitleText": "阶段详情"
}
},
{
"path": "doa-week-edit",
"style": {
"navigationBarTitleText": "周计划与复盘"
}
}
修改 cfc-frontend/pages/health/challenge-manage.vue:
<template> 中 <view v-if="!loading" class="content"> 之前插入:
<!-- 顶部双 Tab:共同挑战 / 个人目标 -->
<view class="page-tabs">
<view class="page-tab active-tab" @click="goToDoa">🎯 共同挑战</view>
<view class="page-tab" @click="goToDoaIndex">📋 个人目标</view>
</view>
<script> methods 中新增:
goToDoa() {
// 当前即共同挑战页
},
goToDoaIndex() {
uni.navigateTo({ url: '/pages/health/doa-index' })
}
<style scoped> 中新增:
.page-tabs {
display: flex;
background: #fff;
border-radius: 16rpx;
margin-bottom: 20rpx;
overflow: hidden;
border: 2rpx solid #E2E8F0;
}
.page-tab {
flex: 1;
text-align: center;
padding: 20rpx 0;
font-size: 28rpx;
font-weight: 600;
color: #64748B;
background: #fff;
}
.active-tab {
background: #10B981;
color: #fff;
}
注意: 若
challenge-manage.vue已有page-tabs/page-tab类名,改用新类名doa-tabs/doa-tab避免冲突。
运行:cd cfc-frontend && node -e "const s=require('fs').readFileSync('utils/api.js','utf8'); new Function(s)"
预期:无报错(仅校验语法,不打包;打包必须用 HBuilderX)
[ ] 步骤 5:Commit
git add cfc-frontend/utils/api.js cfc-frontend/pages.json cfc-frontend/pages/health/challenge-manage.vue
git commit -m "feat(doa): 前端API封装+页面注册+挑战页双tab入口"
文件:
创建:cfc-frontend/pages/health/doa-index.vue
[ ] 步骤 1:创建 doa-index.vue(完整代码)
<template>
<view class="page">
<view v-if="loading" class="loading-box">加载中...</view>
<view v-else class="content">
<!-- 创建按钮 -->
<view class="action-bar">
<button class="btn-new" @click="goCreate">+ 创建阶段</button>
</view>
<!-- 家庭成员 DOA 卡片 -->
<view v-for="m in members" :key="getMemberKey(m)" class="member-card">
<view class="member-header">
<text class="member-name">{{ m.nickname }}</text>
<text v-if="m.memberType === 'local'" class="member-tag">未登录成员</text>
<text class="member-status">{{ m.currentStage ? '进行中' : '未开始' }}</text>
</view>
<!-- 无阶段 -->
<view v-if="!m.currentStage" class="member-empty">
<text class="empty-hint">还没有阶段目标</text>
<text v-if="canManage(m)" class="empty-link" @click="goCreateFor(m.memberId)">为他创建</text>
</view>
<!-- 有阶段 -->
<view v-else class="stage-box" @click="goDetail(m.currentStage.id)">
<text class="stage-title">{{ m.currentStage.title || '阶段目标' }}</text>
<text class="stage-dates">{{ formatDate(m.currentStage.startDate) }} ~ {{ formatDate(m.currentStage.endDate) }}</text>
<!-- 目标领域标签 -->
<view class="goal-row">
<text v-for="g in m.currentStage.goals" :key="g.id" class="goal-tag" :class="'domain-' + g.domain">
{{ domainName(g.domain) }} · {{ g.content }}
</text>
</view>
<!-- 周进度 -->
<view class="week-progress">
<text class="week-text">复盘 {{ m.currentStage.reviewedWeeks || 0 }}/{{ m.currentStage.totalWeeks || 0 }} 周</text>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="members.length === 0" class="empty">
<text class="empty-icon">🎯</text>
<text class="empty-desc">创建你的第一个阶段目标,开启 DOA 之旅!</text>
<button class="btn-empty" @click="goCreate">立即创建</button>
</view>
</view>
</view>
</template>
<script>
import { getDoaOverview, getFamilyMemberList } from '../../utils/api.js'
export default {
data() {
return {
loading: true,
members: [],
selfMemberId: 0
}
},
onLoad() {
this.selfMemberId = uni.getStorageSync('currentMemberId') || 0
this.loadData()
this._onLoadFired = true
},
onShow() {
if (this._onLoadFired) {
this._onLoadFired = false
} else {
this.loadData()
}
},
methods: {
getMemberKey(m) {
return m.memberId
},
async loadData() {
this.loading = true
try {
var res = await getDoaOverview()
this.members = res.data || []
} catch (e) {
this.members = []
} finally {
this.loading = false
}
},
domainName(d) {
var map = { health: '健康', wealth: '事业', family: '家庭关系', social: '社会关系', help: '帮助他人' }
return map[d] || d
},
formatDate(dateStr) {
if (!dateStr) return ''
return String(dateStr).slice(0, 10)
},
canManage(m) {
// 自己 或 未登录成员 → 可代管
return m.memberId === this.selfMemberId || m.memberType === 'local'
},
goCreate() {
uni.navigateTo({ url: '/pages/health/doa-stage-create' })
},
goCreateFor(memberId) {
uni.navigateTo({ url: '/pages/health/doa-stage-create?memberId=' + memberId })
},
goDetail(stageId) {
uni.navigateTo({ url: '/pages/health/doa-stage-detail?stageId=' + stageId })
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
.loading-box { text-align: center; padding: 100rpx 0; color: #94A3B8; font-size: 28rpx; }
.action-bar { display: flex; justify-content: flex-end; margin-bottom: 20rpx; }
.btn-new {
background: linear-gradient(135deg, #10B981, #34D399);
color: #fff; font-size: 26rpx; font-weight: 600;
padding: 14rpx 32rpx; border-radius: 40rpx; border: none;
}
.member-card {
background: #fff; border-radius: 16rpx; padding: 24rpx;
margin-bottom: 16rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
}
.member-header { display: flex; align-items: center; margin-bottom: 12rpx; }
.member-name { font-size: 30rpx; font-weight: 700; color: #1E293B; flex: 1; }
.member-tag { font-size: 20rpx; color: #B45309; background: #FEF3C7; padding: 4rpx 12rpx; border-radius: 20rpx; margin-right: 12rpx; }
.member-status { font-size: 22rpx; color: #16A34A; }
.member-empty { padding: 20rpx 0; text-align: center; }
.empty-hint { font-size: 24rpx; color: #94A3B8; display: block; }
.empty-link { font-size: 24rpx; color: #10B981; margin-top: 8rpx; display: inline-block; }
.stage-box { background: #F8FAFC; border-radius: 12rpx; padding: 20rpx; }
.stage-title { font-size: 28rpx; font-weight: 700; color: #1E293B; display: block; }
.stage-dates { font-size: 22rpx; color: #64748B; margin-top: 4rpx; display: block; }
.goal-row { margin-top: 12rpx; }
.goal-tag {
font-size: 22rpx; color: #475569; background: #fff;
padding: 8rpx 16rpx; border-radius: 10rpx; margin: 6rpx 8rpx 0 0;
display: inline-block; border: 2rpx solid #E2E8F0;
}
.domain-health { border-color: #FF8C42 !important; color: #C2410C !important; }
.domain-wealth { border-color: #F59E0B !important; color: #B45309 !important; }
.domain-family { border-color: #10B981 !important; color: #047857 !important; }
.domain-social { border-color: #6366F1 !important; color: #4338CA !important; }
.domain-help { border-color: #FF6B9D !important; color: #BE185D !important; }
.week-progress { margin-top: 12rpx; }
.week-text { font-size: 22rpx; color: #10B981; font-weight: 600; }
.empty { display: flex; flex-direction: column; align-items: center; padding: 100rpx 0; }
.empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: #94A3B8; margin-bottom: 32rpx; }
.btn-empty {
background: #10B981; color: #fff; font-size: 28rpx;
font-weight: 600; padding: 16rpx 48rpx; border-radius: 40rpx; border: none;
}
</style>
小程序限制检查: 无可选链、无 CSS Grid、
:key使用方法调用getMemberKey(m)(m.memberId简单值,也可以用:key="m.memberId",此处方法调用同样合规)、无new Date(string)。
运行:cd cfc-frontend && node -e "const fs=require('fs');const s=fs.readFileSync('pages/health/doa-index.vue','utf8');const m=s.match(/<script>([\s\S]*?)<\/script>/);new Function(m[1].replace(/import[^;]+;/g,'').replace(/export default/,'var x='))"
预期:无报错
[ ] 步骤 3:Commit
git add cfc-frontend/pages/health/doa-index.vue
git commit -m "feat(doa): 新增doa-index家庭总览页"
文件:
创建:cfc-frontend/pages/health/doa-stage-create.vue
[ ] 步骤 1:创建 doa-stage-create.vue(完整代码)
<template>
<view class="page">
<!-- 目标成员(代管时显示,仅未登录成员可选) -->
<view class="field" v-if="!isSelfOnly">
<text class="field-label">目标成员</text>
<view class="member-grid">
<view
v-for="m in members"
:key="m.id"
class="member-item"
:class="{ active: form.memberId === m.id }"
@click="selectMember(m)"
>
<text>{{ m.nickname }}</text>
</view>
</view>
</view>
<!-- 阶段主题 -->
<view class="field">
<text class="field-label">阶段主题(可选)</text>
<input class="field-input" v-model="form.title" placeholder="如:暑期习惯养成" maxlength="50" />
</view>
<!-- 周期选择 -->
<view class="field">
<text class="field-label">阶段周期 <text class="required">*</text></text>
<view class="radio-group">
<view class="radio-btn" :class="{ active: form.durationWeeks === 2 }" @click="form.durationWeeks = 2">2周</view>
<view class="radio-btn" :class="{ active: form.durationWeeks === 4 }" @click="form.durationWeeks = 4">4周</view>
<view class="radio-btn" :class="{ active: form.durationWeeks === 8 }" @click="form.durationWeeks = 8">8周</view>
<view class="radio-btn" :class="{ active: form.durationWeeks === 12 }" @click="form.durationWeeks = 12">12周</view>
</view>
</view>
<!-- 目标列表(1-3条) -->
<view class="field">
<text class="field-label">阶段目标(1-3 个,不同领域)</text>
<view v-for="(goal, idx) in form.goals" :key="getGoalKey(goal, idx)" class="goal-editor">
<view class="goal-domain-row">
<text class="goal-label">目标 {{ idx + 1 }} 领域</text>
<text class="goal-remove" @click="removeGoal(idx)">✕</text>
</view>
<view class="radio-group">
<view
v-for="d in domains"
:key="d.value"
class="radio-btn small"
:class="{ active: goal.domain === d.value }"
@click="goal.domain = d.value"
>{{ d.label }}</view>
</view>
<input class="field-input" v-model="goal.content" placeholder="量化目标,如:体重降到 172 以下" maxlength="100" />
</view>
<view class="add-goal" v-if="form.goals.length < 3" @click="addGoal">+ 添加目标</view>
</view>
<button class="btn-submit" @click="submit">创建阶段</button>
</view>
</template>
<script>
import { createDoaStage, getFamilyMemberList } from '../../utils/api.js'
export default {
data() {
return {
members: [],
selfMemberId: 0,
targetMemberId: 0,
domains: [
{ value: 'health', label: '健康' },
{ value: 'wealth', label: '事业' },
{ value: 'family', label: '家庭关系' },
{ value: 'social', label: '社会关系' },
{ value: 'help', label: '帮助他人' }
],
form: {
memberId: 0,
title: '',
durationWeeks: 4,
goals: [{ domain: 'health', content: '' }]
}
}
},
computed: {
isSelfOnly() {
// 只为自己创建时(未传入 memberId 且无可代管成员)
return this.targetMemberId === 0 && !this.hasLocalMembers
}
},
onLoad(options) {
this.selfMemberId = uni.getStorageSync('currentMemberId') || 0
if (options.memberId) {
this.targetMemberId = parseInt(options.memberId, 10)
this.form.memberId = parseInt(options.memberId, 10)
}
this.loadMembers()
},
methods: {
getGoalKey(goal, idx) {
return goal.domain + '_' + idx
},
async loadMembers() {
try {
var res = await getFamilyMemberList({ visibleOnly: true })
var list = res.data || []
this.members = list
this.hasLocalMembers = list.some(function(m) { return !m.userId })
// 若从总览"为他创建"进入且目标成员未登录 → 直接锁定
if (this.targetMemberId > 0) {
this.form.memberId = this.targetMemberId
}
} catch (e) {
this.members = []
this.hasLocalMembers = false
}
},
selectMember(m) {
this.form.memberId = m.id
},
addGoal() {
if (this.form.goals.length >= 3) return
this.form.goals.push({ domain: 'health', content: '' })
},
removeGoal(idx) {
if (this.form.goals.length <= 1) return
this.form.goals.splice(idx, 1)
},
async submit() {
// 目标成员校验
var memberId = this.form.memberId || this.selfMemberId
// 目标校验
var validGoals = []
var usedDomains = {}
var hasError = false
for (var i = 0; i < this.form.goals.length; i++) {
var g = this.form.goals[i]
if (!g.content.trim()) {
uni.showToast({ title: '请填写目标 ' + (i + 1) + ' 的内容', icon: 'none' })
hasError = true
break
}
if (usedDomains[g.domain]) {
uni.showToast({ title: '目标领域不能重复', icon: 'none' })
hasError = true
break
}
usedDomains[g.domain] = true
validGoals.push({ domain: g.domain, content: g.content.trim() })
}
if (hasError) return
if (validGoals.length === 0) {
uni.showToast({ title: '请至少填写1个目标', icon: 'none' })
return
}
var data = {
title: this.form.title.trim(),
durationWeeks: this.form.durationWeeks,
goals: validGoals
}
if (memberId && memberId !== this.selfMemberId) {
data.memberId = memberId
}
try {
var res = await createDoaStage(data)
if (res.code === 200) {
uni.showToast({ title: '创建成功', icon: 'success' })
setTimeout(function() {
uni.navigateBack()
}, 600)
} else {
uni.showToast({ title: res.message || '创建失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '创建失败', icon: 'none' })
}
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
.field { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 16rpx; }
.field-label { font-size: 26rpx; color: #475569; font-weight: 600; margin-bottom: 12rpx; display: block; }
.required { color: #EF4444; }
.field-input {
background: #F8FAFC; border: 1rpx solid #E2E8F0; border-radius: 12rpx;
padding: 16rpx 18rpx; font-size: 28rpx; color: #1E293B; margin-top: 12rpx;
}
.member-grid { display: flex; flex-wrap: wrap; gap: 12rpx; }
.member-item {
padding: 10rpx 22rpx; border-radius: 24rpx; font-size: 24rpx;
color: #475569; background: #F1F5F9; border: 2rpx solid transparent;
}
.member-item.active { background: #DCFCE7; color: #16A34A; border-color: #10B981; }
.radio-group { display: flex; gap: 12rpx; margin-top: 12rpx; flex-wrap: wrap; }
.radio-btn {
flex: 1; text-align: center; padding: 12rpx 0; border-radius: 12rpx;
font-size: 26rpx; color: #475569; background: #F1F5F9;
}
.radio-btn.small { flex: none; padding: 8rpx 18rpx; font-size: 22rpx; }
.radio-btn.active { background: #10B981; color: #fff; font-weight: 600; }
.goal-editor { background: #F8FAFC; border-radius: 12rpx; padding: 16rpx; margin-top: 12rpx; }
.goal-domain-row { display: flex; justify-content: space-between; align-items: center; }
.goal-label { font-size: 24rpx; color: #475569; font-weight: 600; }
.goal-remove { font-size: 28rpx; color: #EF4444; padding: 4rpx 12rpx; }
.add-goal {
margin-top: 16rpx; text-align: center; padding: 16rpx 0;
border: 2rpx dashed #10B981; border-radius: 12rpx;
color: #10B981; font-size: 26rpx; font-weight: 600;
}
.btn-submit {
background: linear-gradient(135deg, #10B981, #34D399);
color: #fff; font-size: 30rpx; font-weight: 600;
border-radius: 42rpx; border: none; height: 84rpx; line-height: 84rpx;
margin-top: 8rpx;
}
</style>
注意:
hasLocalMembers在 data 中未初始化会导致 undefined,已在loadMembers中赋值;isSelfOnly计算属性引用它,初始为 undefined 时表现为 false,符合"展示成员选择"的兜底行为。若希望无成员时不显示选择区,可在data()中初始化hasLocalMembers: false。
运行:同任务 6 步骤 2 的 node 校验方法(提取 script 块 + new Function)
预期:无报错
[ ] 步骤 3:Commit
git add cfc-frontend/pages/health/doa-stage-create.vue
git commit -m "feat(doa): 新增doa-stage-create创建阶段页"
文件:
创建:cfc-frontend/pages/health/doa-stage-detail.vue
[ ] 步骤 1:创建 doa-stage-detail.vue(完整代码)
<template>
<view class="page">
<view v-if="loading" class="loading-box">加载中...</view>
<view v-else-if="stage" class="content">
<!-- 阶段头部 -->
<view class="stage-header">
<text class="stage-title">{{ stage.title || '阶段目标' }}</text>
<text class="stage-dates">{{ formatDate(stage.startDate) }} ~ {{ formatDate(stage.endDate) }}</text>
<view class="progress-row">
<text class="progress-text">复盘进度 {{ stage.reviewedWeeks || 0 }}/{{ stage.totalWeeks || 0 }}</text>
</view>
</view>
<!-- 目标列表 -->
<view class="section">
<text class="section-title">🎯 阶段目标</text>
<view v-for="g in stage.goals" :key="g.id" class="goal-item">
<text class="goal-tag" :class="'domain-' + g.domain">{{ domainName(g.domain) }}</text>
<text class="goal-content">{{ g.content }}</text>
</view>
</view>
<!-- 周时间线 -->
<view class="section">
<text class="section-title">📅 每周计划与复盘</text>
<view v-for="w in stage.weeks" :key="getWeekKey(w)" class="week-card">
<view class="week-header" @click="toggleWeek(w.id)">
<text class="week-no">第 {{ w.weekNo }} 周</text>
<text class="week-dates">{{ formatDate(w.weekStart) }} ~ {{ formatDate(w.weekEnd) }}</text>
<text class="week-status" :class="'ws-' + w.status">{{ weekStatusText(w.status) }}</text>
</view>
<view v-if="expandedWeekId === w.id" class="week-body">
<!-- 计划 -->
<view class="week-block">
<text class="block-title">📋 本周计划</text>
<text class="block-text">{{ w.plan || '(未填写)' }}</text>
</view>
<!-- 复盘 -->
<view class="week-block" v-if="w.status === 'reviewed'">
<text class="block-title">✅ 复盘总结</text>
<view class="review-item">
<text class="review-label">达成:</text>
<text class="review-text">{{ w.achieved || '—' }}</text>
</view>
<view class="review-item">
<text class="review-label">体验:</text>
<text class="review-text">{{ w.experience || '—' }}</text>
</view>
<view class="review-item">
<text class="review-label">行得通:</text>
<text class="review-text worked">{{ w.worked || '—' }}</text>
</view>
<view class="review-item">
<text class="review-label">要提升:</text>
<text class="review-text improve">{{ w.improve || '—' }}</text>
</view>
<view class="review-item">
<text class="review-label">下周挑战:</text>
<text class="review-text">{{ w.nextChallenge || '—' }}</text>
</view>
</view>
<!-- 编辑按钮(本人或代管) -->
<view v-if="canEdit" class="week-actions">
<button class="btn-edit" @click="editWeek(w)">编辑{{ w.status === 'reviewed' ? '' : '计划' }}</button>
</view>
</view>
</view>
</view>
<!-- 取消阶段 -->
<button v-if="canEdit && stage.status === 'active'" class="btn-cancel" @click="cancelStage">取消阶段</button>
</view>
</view>
</template>
<script>
import { getDoaStageDetail, cancelDoaStage } from '../../utils/api.js'
export default {
data() {
return {
loading: true,
stage: null,
stageId: 0,
selfMemberId: 0,
expandedWeekId: 0,
canEdit: false
}
},
onLoad(options) {
this.stageId = parseInt(options.stageId || '0', 10)
this.selfMemberId = uni.getStorageSync('currentMemberId') || 0
this.loadDetail()
},
methods: {
getWeekKey(w) {
return w.id
},
async loadDetail() {
this.loading = true
try {
var res = await getDoaStageDetail(this.stageId)
if (res.code === 200 && res.data) {
this.stage = res.data
// 编辑权限:自己 或 未登录成员(memberNickname 存在且非本人 → 后端已校验,前端保守判断)
this.canEdit = this.stage.memberId === this.selfMemberId || !this.stage.memberUserId
} else {
uni.showToast({ title: res.message || '加载失败', icon: 'none' })
setTimeout(function() { uni.navigateBack() }, 1000)
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
domainName(d) {
var map = { health: '健康', wealth: '事业', family: '家庭关系', social: '社会关系', help: '帮助他人' }
return map[d] || d
},
formatDate(dateStr) {
if (!dateStr) return ''
return String(dateStr).slice(0, 10)
},
weekStatusText(s) {
if (s === 'reviewed') return '已复盘'
if (s === 'planned') return '已计划'
return '待计划'
},
toggleWeek(id) {
this.expandedWeekId = this.expandedWeekId === id ? 0 : id
},
editWeek(w) {
uni.navigateTo({ url: '/pages/health/doa-week-edit?weekId=' + w.id + '&stageId=' + this.stageId })
},
cancelStage() {
var self = this
uni.showModal({
title: '确认取消',
content: '确定要取消这个阶段吗?周记录将保留但不可再编辑。',
success: function(res) {
if (res.confirm) {
cancelDoaStage(self.stageId).then(function(r) {
if (r.code === 200) {
uni.showToast({ title: '已取消', icon: 'success' })
setTimeout(function() { uni.navigateBack() }, 600)
} else {
uni.showToast({ title: r.message || '取消失败', icon: 'none' })
}
})
}
}
})
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
.loading-box { text-align: center; padding: 100rpx 0; color: #94A3B8; font-size: 28rpx; }
.stage-header {
background: linear-gradient(135deg, #10B981, #34D399);
border-radius: 20rpx; padding: 32rpx; margin-bottom: 20rpx; color: #fff;
}
.stage-title { font-size: 34rpx; font-weight: 700; display: block; }
.stage-dates { font-size: 24rpx; opacity: 0.9; margin-top: 8rpx; display: block; }
.progress-row { margin-top: 16rpx; }
.progress-text { font-size: 24rpx; font-weight: 600; }
.section { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
.section-title { font-size: 28rpx; font-weight: 700; color: #1E293B; display: block; margin-bottom: 16rpx; }
.goal-item { display: flex; align-items: center; margin-bottom: 12rpx; }
.goal-tag {
font-size: 20rpx; padding: 6rpx 14rpx; border-radius: 8rpx; margin-right: 12rpx;
border: 2rpx solid #E2E8F0; color: #475569; flex-shrink: 0;
}
.domain-health { border-color: #FF8C42 !important; color: #C2410C !important; }
.domain-wealth { border-color: #F59E0B !important; color: #B45309 !important; }
.domain-family { border-color: #10B981 !important; color: #047857 !important; }
.domain-social { border-color: #6366F1 !important; color: #4338CA !important; }
.domain-help { border-color: #FF6B9D !important; color: #BE185D !important; }
.goal-content { font-size: 26rpx; color: #334155; flex: 1; }
.week-card { background: #F8FAFC; border-radius: 12rpx; padding: 20rpx; margin-bottom: 12rpx; }
.week-header { display: flex; align-items: center; }
.week-no { font-size: 26rpx; font-weight: 700; color: #1E293B; flex: 1; }
.week-dates { font-size: 20rpx; color: #94A3B8; margin-right: 12rpx; }
.week-status { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 20rpx; }
.ws-pending { background: #F1F5F9; color: #64748B; }
.ws-planned { background: #DBEAFE; color: #1D4ED8; }
.ws-reviewed { background: #DCFCE7; color: #16A34A; }
.week-body { margin-top: 16rpx; border-top: 2rpx solid #E2E8F0; padding-top: 16rpx; }
.week-block { margin-bottom: 16rpx; }
.block-title { font-size: 24rpx; font-weight: 700; color: #475569; display: block; margin-bottom: 8rpx; }
.block-text { font-size: 24rpx; color: #334155; white-space: pre-wrap; display: block; }
.review-item { margin-bottom: 8rpx; }
.review-label { font-size: 22rpx; color: #64748B; }
.review-text { font-size: 22rpx; color: #334155; }
.review-text.worked { color: #16A34A; }
.review-text.improve { color: #DC2626; }
.week-actions { margin-top: 12rpx; }
.btn-edit {
background: #10B981; color: #fff; font-size: 24rpx; font-weight: 600;
padding: 12rpx 32rpx; border-radius: 32rpx; border: none; width: auto;
}
.btn-cancel {
background: #fff; color: #EF4444; font-size: 26rpx; font-weight: 600;
border: 2rpx solid #FCA5A5; border-radius: 42rpx; height: 80rpx; line-height: 80rpx;
margin-top: 8rpx;
}
</style>
注意: 后端
getStageDetail返回的DoaStage实体不含memberUserId字段——前端canEdit判断用!this.stage.memberUserId会永远为 true。修正方案:后端DoaService.getStageDetail中已通过targetMember.getUserId()拿到该值,应在返回前设置到 VO。由于实体没有该字段,最简做法是在getStageDetail返回的DoaStage增加@TableField(exist = false) private Long memberUserId;并在 service 填充。实施时必须同步任务 2/3: 在DoaStage.java加memberUserId非表字段,在getStageDetail()与getFamilyOverview()中stage.setMemberUserId(m.getUserId())。
在 DoaStage.java 追加非表字段:
/** 非表字段:目标成员的 userId(null=不能登录成员,可代管) */
@TableField(exist = false)
private Long memberUserId;
在 DoaService.getStageDetail() 中(已有 FamilyMember m = familyMemberMapper.selectById(stage.getMemberId()); 处):
stage.setMemberNickname(m != null ? m.getNickname() : "成员" + stage.getMemberId());
stage.setMemberUserId(m != null ? m.getUserId() : null);
在 DoaService.getFamilyOverview() 中(遍历成员循环内):
memberMap.put("memberType", m.getUserId() == null ? "local" : "login");
// currentStage 填充时同时设置 memberUserId(fillStageDetail 内无法访问 member,需在外层设置)
getFamilyOverview 中 currentStage 的填充需补一行(在 fillStageDetail(current) 之后):
current.setMemberUserId(m.getUserId());
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
[ ] 步骤 4:Commit
git add cfc-backend/src/main/java/com/etotem/cfc/entity/DoaStage.java cfc-backend/src/main/java/com/etotem/cfc/service/DoaService.java cfc-frontend/pages/health/doa-stage-detail.vue
git commit -m "feat(doa): 新增doa-stage-detail详情页+实体memberUserId字段"
文件:
创建:cfc-frontend/pages/health/doa-week-edit.vue
[ ] 步骤 1:创建 doa-week-edit.vue(完整代码)
<template>
<view class="page">
<view v-if="!week" class="loading-box">加载中...</view>
<view v-else class="content">
<text class="page-hint">第 {{ week.weekNo }} 周 · {{ formatDate(week.weekStart) }} ~ {{ formatDate(week.weekEnd) }}</text>
<!-- A表:周行动宣告计划 -->
<view class="section">
<text class="section-title">📋 本周行动计划(A表)</text>
<text class="section-desc">写下本周要做的具体行动,每行一个(参考:明确人事时地)</text>
<textarea class="field-textarea" v-model="form.plan" placeholder="1、完成站桩15分钟 2、准备早餐、拍照记录 3、..."></textarea>
<button class="btn-save" @click="savePlan">保存计划</button>
</view>
<!-- B表:周复盘五问 -->
<view class="section" v-if="week.status === 'reviewed' || week.status === 'planned' || week.status === 'pending'">
<text class="section-title">✅ 本周复盘(B表)</text>
<view class="review-field">
<text class="review-label">本周达成了什么?</text>
<textarea class="field-textarea" v-model="form.achieved" placeholder="如:体重降到 171.5"></textarea>
</view>
<view class="review-field">
<text class="review-label">有什么体验?</text>
<textarea class="field-textarea" v-model="form.experience" placeholder="如:愉悦、疲惫"></textarea>
</view>
<view class="review-field">
<text class="review-label">行得通的是什么?</text>
<textarea class="field-textarea" v-model="form.worked" placeholder="总结有效的方法"></textarea>
</view>
<view class="review-field">
<text class="review-label">要提升 / 行不通的是什么?</text>
<textarea class="field-textarea" v-model="form.improve" placeholder="总结待改进之处"></textarea>
</view>
<view class="review-field">
<text class="review-label">下一周要挑战自己什么?</text>
<textarea class="field-textarea" v-model="form.nextChallenge" placeholder="写下下周的新挑战"></textarea>
</view>
<button class="btn-save review-btn" @click="saveReview">提交复盘</button>
</view>
</view>
</view>
</template>
<script>
import { getDoaStageDetail, submitDoaWeekPlan, submitDoaWeekReview } from '../../utils/api.js'
export default {
data() {
return {
weekId: 0,
stageId: 0,
week: null,
form: {
plan: '',
achieved: '',
experience: '',
worked: '',
improve: '',
nextChallenge: ''
}
}
},
onLoad(options) {
this.weekId = parseInt(options.weekId || '0', 10)
this.stageId = parseInt(options.stageId || '0', 10)
this.loadWeek()
},
methods: {
formatDate(dateStr) {
if (!dateStr) return ''
return String(dateStr).slice(0, 10)
},
async loadWeek() {
try {
var res = await getDoaStageDetail(this.stageId)
if (res.code === 200 && res.data && res.data.weeks) {
for (var i = 0; i < res.data.weeks.length; i++) {
if (res.data.weeks[i].id === this.weekId) {
this.week = res.data.weeks[i]
this.form.plan = this.week.plan || ''
this.form.achieved = this.week.achieved || ''
this.form.experience = this.week.experience || ''
this.form.worked = this.week.worked || ''
this.form.improve = this.week.improve || ''
this.form.nextChallenge = this.week.nextChallenge || ''
break
}
}
}
if (!this.week) {
uni.showToast({ title: '周记录不存在', icon: 'none' })
setTimeout(function() { uni.navigateBack() }, 1000)
}
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
savePlan() {
var self = this
submitDoaWeekPlan(this.weekId, this.form.plan).then(function(res) {
if (res.code === 200) {
uni.showToast({ title: '计划已保存', icon: 'success' })
self.week.status = 'planned'
} else {
uni.showToast({ title: res.message || '保存失败', icon: 'none' })
}
})
},
saveReview() {
var self = this
var data = {
weekId: this.weekId,
achieved: this.form.achieved,
experience: this.form.experience,
worked: this.form.worked,
improve: this.form.improve,
nextChallenge: this.form.nextChallenge
}
submitDoaWeekReview(data).then(function(res) {
if (res.code === 200) {
uni.showToast({ title: '复盘已提交', icon: 'success' })
self.week.status = 'reviewed'
setTimeout(function() { uni.navigateBack() }, 600)
} else {
uni.showToast({ title: res.message || '提交失败', icon: 'none' })
}
})
}
}
}
</script>
<style scoped>
.page { min-height: 100vh; background: #F5F7FA; padding: 24rpx; }
.loading-box { text-align: center; padding: 100rpx 0; color: #94A3B8; font-size: 28rpx; }
.page-hint { font-size: 26rpx; color: #475569; display: block; margin-bottom: 16rpx; font-weight: 600; }
.section { background: #fff; border-radius: 16rpx; padding: 24rpx; margin-bottom: 20rpx; }
.section-title { font-size: 28rpx; font-weight: 700; color: #1E293B; display: block; }
.section-desc { font-size: 22rpx; color: #94A3B8; margin-top: 4rpx; display: block; }
.field-textarea {
background: #F8FAFC; border: 1rpx solid #E2E8F0; border-radius: 12rpx;
padding: 16rpx 18rpx; font-size: 26rpx; color: #1E293B;
height: 160rpx; width: 100%; box-sizing: border-box; margin-top: 12rpx;
}
.review-field { margin-top: 16rpx; }
.review-label { font-size: 24rpx; color: #475569; font-weight: 600; display: block; }
.btn-save {
background: #10B981; color: #fff; font-size: 28rpx; font-weight: 600;
border-radius: 40rpx; border: none; height: 76rpx; line-height: 76rpx;
margin-top: 20rpx;
}
.review-btn { background: #6366F1; }
</style>
[ ] 步骤 2:语法校验
运行:同任务 6 步骤 2 的 node 校验方法 预期:无报错
[ ] 步骤 3:Commit
git add cfc-frontend/pages/health/doa-week-edit.vue
git commit -m "feat(doa): 新增doa-week-edit周计划复盘编辑页"
文件:
docs/superpowers/api/API_REFERENCE.mdAGENTS.md(根)修改:cfc-backend/AGENTS.md
[ ] 步骤 1:API_REFERENCE.md 登记 /api/doa/* 接口
在 docs/superpowers/api/API_REFERENCE.md 家庭健康互动相关章节后追加:
## DOA 个人目标(家庭打卡个人目标模块)
| 接口 | 路径 | 说明 |
|------|------|------|
| 创建阶段 | POST /api/doa/stage/create | 创建阶段(memberId?+title+durationWeeks+goals[1-3]) |
| 家庭总览 | POST /api/doa/overview | 各成员当前 active 阶段 |
| 阶段详情 | POST /api/doa/stage/detail | stage + goals + weeks |
| 取消阶段 | POST /api/doa/stage/cancel | 仅 active 可取消 |
| 提交周计划 | POST /api/doa/week/plan | A表周行动宣告 |
| 提交周复盘 | POST /api/doa/week/review | B表五问 |
| 周记录列表 | POST /api/doa/week/list | 按阶段查周记录 |
在根 AGENTS.md 的「UNIQUE STYLES」或「数据库」相关章节追加:
## 用户模型统一口径
- **能登录用户**:`family_members.userId` 非空且关联 User 有 openid(VO 层 `memberType=login`)
- **不能登录用户**(历史称"孩子"):`family_members.userId` 为空或关联 User 无 openid(VO 层 `memberType=local`),由同家庭能登录用户代管其数据(任务/健康/DOA 等)
- 新功能禁止再引入"孩子"概念,统一用"能登录用户/不能登录用户"表述
在 cfc-backend/AGENTS.md 的身份标识约定(userId/memberId/childId 段落)后追加同样口径说明(与根 AGENTS.md 一致)。
[ ] 步骤 4:Commit
git add docs/superpowers/api/API_REFERENCE.md AGENTS.md cfc-backend/AGENTS.md
git commit -m "docs: 登记DOA接口+统一能登录/不能登录用户口径规范"
运行:cd cfc-backend && mvn clean compile
预期:BUILD SUCCESS
运行:cd cfc-frontend && node scripts/audit-duplicate-api-calls.js
预期:exit code 0(新增的 4 个页面无重复 API 调用:doa-index 仅 overview 一次、doa-stage-create 仅 member list 一次、doa-stage-detail 仅 detail 一次、doa-week-edit 仅 detail 一次)
运行:grep -rn '@Mapping' cfc-backend/src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | grep '/doa'
预期:7 个 doa 路径各出现一次
按规格第 9 节验收标准逐条验证: