For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [x]) syntax for tracking.
Goal: 家庭挑战从"家长创建即全员参与"重构为"任意成员发起 → 可选参与成员 → pending 待同意 → 全体同意后 active → 方向性关系评分即时结算",并配套后台评分配置页。
Architecture: 后端新增 3 张表(participant 参与成员表、relationship_scores 方向分数表、score_config 评分配置表)与 3 个实体/Mapper;新建 ChallengeScoreService 统一结算方向分数+能量;改造 FamilyChallengeService(create 改 pending + respondChallenge + 打卡/结算口径收紧);控制器去家长门控并新增 respond 端点;cfc-web 新增评分配置管理页;小程序首页卡片支持同意/拒绝、管理页支持参与成员多选。
Tech Stack: Spring Boot 2.7.18 + MyBatis-Plus + Java 8 / uni-app Vue 2 小程序 / Vue 2 + Element UI
@PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMapping@Resource,字段名必须与类型默认 Bean Name 一致Result<T> (code/message/data)DatabaseInitializer.runMigrations();所有迁移幂等(try-catch 忽略已存在);schema.sql 必须同步新增表 DDL迁移181 + 迁移20240709-01,本次从 迁移182 起family_members 的成员级分数,缺失默认 60.00)agreed 的参与成员,排除 rejected?.、禁止 CSS Grid、禁止 :key 表达式、禁止 new Date(string)(用 parseDate())mvn clean compile 必须通过(唯一验证方式);小程序只做语法/结构校验,禁止自行打包| 文件 | 动作 | 职责 |
|---|---|---|
cfc-backend/.../entity/FamilyChallengeParticipant.java |
新建 | 挑战参与成员实体(pending/agreed/rejected) |
cfc-backend/.../entity/FamilyRelationshipScore.java |
新建 | 方向性关系分数实体(A→B 一行) |
cfc-backend/.../entity/FamilyChallengeScoreConfig.java |
新建 | 评分配置实体(action+scoreType) |
cfc-backend/.../mapper/FamilyChallengeParticipantMapper.java |
新建 | BaseMapper |
cfc-backend/.../mapper/FamilyRelationshipScoreMapper.java |
新建 | BaseMapper |
cfc-backend/.../mapper/FamilyChallengeScoreConfigMapper.java |
新建 | BaseMapper |
cfc-backend/.../service/ChallengeScoreService.java |
新建 | 方向分数读取/初始化/增减 + 能量发放/扣减(核心结算引擎) |
cfc-backend/.../service/FamilyChallengeService.java |
修改 | createChallenge 改 pending+participant+发起结算;新增 respondChallenge;updateProgress/结算/列表口径收紧 |
cfc-backend/.../controller/FamilyChallengeController.java |
修改 | 新增 respond 端点;create 去家长门控+memberId;list 返回 myStatus |
cfc-backend/.../controller/admin/AdminChallengeScoreConfigController.java |
新建 | 评分配置 CRUD(list/update) |
cfc-backend/.../service/EnergyService.java |
修改 | 新增 awardEnergyByCode(对称于 deductEnergyByCode) |
cfc-backend/.../config/DatabaseInitializer.java |
修改 | 迁移182-185:3 新表 + 默认配置 + 存量挑战补 participant |
cfc-backend/src/main/resources/schema.sql |
修改 | 同步 3 张新表 DDL |
cfc-backend/src/test/.../service/FamilyChallengeServiceTest.java |
修改 | 更新 mock 依赖 + 新增 respondChallenge/createChallenge 校验测试 |
cfc-backend/src/test/.../service/ChallengeScoreServiceTest.java |
新建 | 方向分数/能量结算矩阵测试 |
cfc-web/src/api/challengeScore.js |
新建 | 管理端 API 封装 |
cfc-web/src/views/admin/ChallengeScoreConfig.vue |
新建 | 评分配置管理页 |
cfc-web/src/router/index.js |
修改 | 注册配置页路由 |
cfc-frontend/utils/api.js |
修改 | 新增 respondChallenge |
cfc-frontend/components/FamilyChallengeCard.vue |
修改 | pending 渲染 + 同意/拒绝按钮 + rejected 徽标 |
cfc-frontend/pages/health/challenge-manage.vue |
修改 | 移除角色门控 + 参与成员多选 + pending 展示 |
cfc-frontend/pages/index-home/index.vue |
修改 | 处理 respond 事件 |
docs/superpowers/PROJECT-OVERVIEW.md |
修改 | 同步计划条目 |
Files:
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() 末尾,迁移181 之后、类闭合 } 之前)cfc-backend/src/main/resources/schema.sql(family_challenge_progress 定义之后追加 3 张表)Interfaces:
Produces: 表 family_challenge_participant / family_relationship_scores / family_challenge_score_config(含 12 行默认配置数据);存量 family_challenge 补齐 participant 行
[x] Step 1: DatabaseInitializer 追加迁移182-185
在 DatabaseInitializer.java 的 // 迁移181: ... ensureColumn 行之后(8120 行 } 之前)追加:
// 迁移182: 创建 family_challenge_participant 表(挑战参与成员,任意成员发起/可选参与)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_participant (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"challenge_id BIGINT NOT NULL COMMENT '挑战ID', " +
"member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
"status VARCHAR(20) NOT NULL COMMENT 'pending/agreed/rejected', " +
"agreed_at DATETIME NULL COMMENT '同意/拒绝时间', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
"UNIQUE KEY uk_challenge_member (challenge_id, member_id), " +
"INDEX idx_member (member_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战参与成员'");
log.info("已创建family_challenge_participant表");
} catch (Exception e) {
log.warn("创建 family_challenge_participant 表失败(可能已存在): " + e.getMessage());
}
// 迁移183: 创建 family_relationship_scores 表(方向性关系分数,A对B ≠ B对A)
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_relationship_scores (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"family_id BIGINT NOT NULL COMMENT '家庭ID', " +
"from_member_id BIGINT NOT NULL COMMENT '分数持有方(A对B的行) ', " +
"to_member_id BIGINT NOT NULL COMMENT '分数对象方', " +
"trust_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的信任', " +
"intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的亲近', " +
"communication_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的沟通', " +
"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
"UNIQUE KEY uk_from_to (from_member_id, to_member_id), " +
"INDEX idx_family (family_id)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='方向性关系分数'");
log.info("已创建family_relationship_scores表");
} catch (Exception e) {
log.warn("创建 family_relationship_scores 表失败(可能已存在): " + e.getMessage());
}
// 迁移184: 创建 family_challenge_score_config 表(挑战评分配置,后台可配置)+ 默认数据
try {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS family_challenge_score_config (" +
"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
"action VARCHAR(20) NOT NULL COMMENT 'initiate/agree/reject', " +
"score_type VARCHAR(20) NOT NULL COMMENT 'energy/trust/intimacy/communication', " +
"value INT NOT NULL DEFAULT 0 COMMENT '增减值(可正可负,默认±1)', " +
"enabled TINYINT DEFAULT 1, " +
"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
"UNIQUE KEY uk_action_type (action, score_type)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战评分配置'");
jdbcTemplate.execute("INSERT IGNORE INTO family_challenge_score_config (action, score_type, value, enabled) VALUES " +
"('initiate','energy',1,1),('initiate','trust',0,1),('initiate','intimacy',1,1),('initiate','communication',1,1)," +
"('agree','energy',1,1),('agree','trust',1,1),('agree','intimacy',1,1),('agree','communication',1,1)," +
"('reject','energy',-1,1),('reject','trust',-1,1),('reject','intimacy',-1,1),('reject','communication',1,1)");
log.info("已创建family_challenge_score_config表并写入默认配置");
} catch (Exception e) {
log.warn("创建 family_challenge_score_config 表失败(可能已存在): " + e.getMessage());
}
// 迁移185: 存量挑战补全 participant 行(全员 agreed,兼容旧数据,不破坏)
try {
jdbcTemplate.execute("INSERT IGNORE INTO family_challenge_participant (challenge_id, member_id, status, agreed_at) " +
"SELECT fc.id, fm.id, 'agreed', NOW() FROM family_challenge fc " +
"JOIN family_members fm ON fm.family_id = fc.family_id " +
"WHERE NOT EXISTS (SELECT 1 FROM family_challenge_participant fcp WHERE fcp.challenge_id = fc.id)");
log.info("已为存量挑战补全participant行");
} catch (Exception e) {
log.warn("存量挑战补全participant失败(可能已处理): " + e.getMessage());
}
在 schema.sql 中 family_challenge_progress 表定义(3425 行 ) 之后、health_interaction 之前插入:
-- 家庭挑战参与成员表(参与机制重构:任意成员发起/可选参与/同意后开始)
CREATE TABLE IF NOT EXISTS family_challenge_participant (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
challenge_id BIGINT NOT NULL COMMENT '挑战ID',
member_id BIGINT NOT NULL COMMENT '家庭成员ID',
status VARCHAR(20) NOT NULL COMMENT 'pending/agreed/rejected',
agreed_at DATETIME NULL COMMENT '同意/拒绝时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_challenge_member (challenge_id, member_id),
INDEX idx_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战参与成员';
-- 方向性关系分数表(A对B ≠ B对A)
CREATE TABLE IF NOT EXISTS family_relationship_scores (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
family_id BIGINT NOT NULL COMMENT '家庭ID',
from_member_id BIGINT NOT NULL COMMENT '分数持有方(A对B的行)',
to_member_id BIGINT NOT NULL COMMENT '分数对象方',
trust_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的信任',
intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的亲近',
communication_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的沟通',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_from_to (from_member_id, to_member_id),
INDEX idx_family (family_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='方向性关系分数';
-- 家庭挑战评分配置表(后台可配置)
CREATE TABLE IF NOT EXISTS family_challenge_score_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
action VARCHAR(20) NOT NULL COMMENT 'initiate/agree/reject',
score_type VARCHAR(20) NOT NULL COMMENT 'energy/trust/intimacy/communication',
value INT NOT NULL DEFAULT 0 COMMENT '增减值(可正可负,默认±1)',
enabled TINYINT DEFAULT 1,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_action_type (action, score_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭挑战评分配置';
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[x] Step 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(challenge): 参与机制迁移-新增participant/方向分数/评分配置表"
Files:
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeParticipant.javacfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationshipScore.javacfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeScoreConfig.javacfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeParticipantMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyRelationshipScoreMapper.javacfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeScoreConfigMapper.javaInterfaces:
Produces: 实体 FamilyChallengeParticipant(challengeId/memberId/status/agreedAt)、FamilyRelationshipScore(familyId/fromMemberId/toMemberId/trustScore/intimacyScore/communicationScore,BigDecimal)、FamilyChallengeScoreConfig(action/scoreType/value/enabled)
[x] Step 1: 创建 FamilyChallengeParticipant 实体
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("family_challenge_participant")
public class FamilyChallengeParticipant implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long challengeId;
private Long memberId;
/** 状态: pending/agreed/rejected */
private String status;
/** 同意/拒绝时间 */
private Date agreedAt;
private Date createdAt;
private Date updatedAt;
}
[x] Step 2: 创建 FamilyRelationshipScore 实体
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.math.BigDecimal;
import java.util.Date;
@Data
@TableName("family_relationship_scores")
public class FamilyRelationshipScore implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
private Long familyId;
/** 分数持有方(A对B的行) */
private Long fromMemberId;
/** 分数对象方 */
private Long toMemberId;
/** A对B的信任 */
private BigDecimal trustScore;
/** A对B的亲近 */
private BigDecimal intimacyScore;
/** A对B的沟通 */
private BigDecimal communicationScore;
private Date createdAt;
private Date updatedAt;
}
[x] Step 3: 创建 FamilyChallengeScoreConfig 实体
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("family_challenge_score_config")
public class FamilyChallengeScoreConfig implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
/** 动作: initiate/agree/reject */
private String action;
/** 分数类型: energy/trust/intimacy/communication */
private String scoreType;
/** 增减值(可正可负,默认±1) */
private Integer value;
/** 是否启用: 1=启用 */
private Integer enabled;
private Date updatedAt;
}
[x] Step 4: 创建 3 个 Mapper
每个文件独立创建(与现有 FamilyChallengeProgressMapper 完全同构):
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FamilyChallengeParticipant;
public interface FamilyChallengeParticipantMapper extends BaseMapper<FamilyChallengeParticipant> {
}
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FamilyRelationshipScore;
public interface FamilyRelationshipScoreMapper extends BaseMapper<FamilyRelationshipScore> {
}
package com.etotem.cfc.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.etotem.cfc.entity.FamilyChallengeScoreConfig;
public interface FamilyChallengeScoreConfigMapper extends BaseMapper<FamilyChallengeScoreConfig> {
}
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[x] Step 6: Commit
git add cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeParticipant.java cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationshipScore.java cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeScoreConfig.java cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeParticipantMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyRelationshipScoreMapper.java cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeScoreConfigMapper.java
git commit -m "feat(challenge): 新增participant/方向分数/评分配置实体与Mapper"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java(deductEnergyByCode 方法之后,约 1381 行)Interfaces:
public Map<String, Integer> awardEnergyByCode(Long memberId, String dimensionCode, Integer amount, String reason) — 按维度 code 发放能量(对称于 deductEnergyByCode),返回发放结果 map(空=未发放)Context: 现有 awardEnergy(memberId, sourceType, sourceId, ...) 依赖 energy_source_config / behavior_config 配置,挑战 ±1 能量直接按维度 code 发放最可靠。
在 deductEnergyByCode 方法(1373-1381 行)的 } 之后追加:
/**
* 发放能量 — 通过维度code(自动查ID),无日上限/配置约束,供低频行为直接发放
*
* @return 发放结果 Map<dimensionCode, amount>(空 = 维度不存在未发放)
*/
@Transactional
public Map<String, Integer> awardEnergyByCode(Long memberId, String dimensionCode, Integer amount, String reason) {
if (amount == null || amount <= 0) return new HashMap<>();
EnergyDimension dim = getDimByCode(dimensionCode);
if (dim == null) {
log.warn("EnergyService.awardEnergyByCode: dimension not found, code={}", dimensionCode);
return new HashMap<>();
}
Map<Long, Integer> allocations = new LinkedHashMap<>();
allocations.put(dim.getId(), amount);
return executeAward(memberId, "challenge", null, amount, reason, null, allocations);
}
注意:executeAward 已是 private 方法(1229 行),签名 (Long memberId, String sourceType, Long sourceId, Integer totalAmount, String description, Integer expireDays, Map<Long,Integer> allocations),可直接调用。需确认 HashMap/LinkedHashMap 已 import(文件头部已有 java.util.* 或单个 import——如缺失则补 import)。
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[x] Step 3: Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java
git commit -m "feat(energy): EnergyService新增awardEnergyByCode按维度code发放"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/ChallengeScoreService.javaInterfaces:
FamilyRelationshipScoreMapper / FamilyChallengeScoreConfigMapper / FamilyMemberMapper / EnergyService(awardEnergyByCode + deductEnergyByCode)public void applyInitiate(Long familyId, Long initiatorId, List<Long> participantIds, Long challengeId)public void applyAgree(Long familyId, Long memberId, List<Long> participantIds, Long challengeId)public void applyReject(Long familyId, Long rejectorId, Long initiatorId, List<Long> agreedMemberIds, Long challengeId)评分矩阵(用户拍板,必须逐格对齐):
| 动作 | 落点 | trust | intimacy | communication | energy |
|---|---|---|---|---|---|
| initiate | I→每个其他参与成员 | +0 | +1 | +1 | — |
| initiate | 其他成员→I | 0 | 0 | 0 | — |
| initiate | I 本人 | — | — | — | +1 |
| agree | X→每个其他参与成员 | +1 | +1 | +1 | — |
| agree | 其他成员→X | 0 | 0 | 0 | — |
| agree | X 本人 | — | — | — | +1 |
| reject | I→X | -1 | -1 | -1 | — |
| reject | X→I | 0 | 0 | +1 | — |
| reject | 已同意成员 A→X(C3) | 0 | 0 | -1 | — |
| reject | I + A 每人 | — | — | — | -1 |
[x] Step 1: 创建 ChallengeScoreService
package com.etotem.cfc.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.entity.FamilyChallengeScoreConfig;
import com.etotem.cfc.entity.FamilyMember;
import com.etotem.cfc.entity.FamilyRelationshipScore;
import com.etotem.cfc.mapper.FamilyChallengeScoreConfigMapper;
import com.etotem.cfc.mapper.FamilyMemberMapper;
import com.etotem.cfc.mapper.FamilyRelationshipScoreMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 家庭挑战方向性关系评分结算引擎。
*
* 方向性语义:A→B 表示"A 对 B 的分数",存于 family_relationship_scores 行 (A→B)。
* 动作执行者(actor)永远是被加减的主体,落点在其"出向"行上;反向行不动。
* 数值从 family_challenge_score_config 读取(后台可配置),方向由本类代码固定。
*/
@Slf4j
@Service
public class ChallengeScoreService {
/** 配置默认值兜底(与 spec §3.4 一致):action -> scoreType -> value */
private static final Map<String, Map<String, Integer>> DEFAULT_CONFIG = new HashMap<>();
static {
Map<String, Integer> initiate = new HashMap<>();
initiate.put("energy", 1);
initiate.put("trust", 0);
initiate.put("intimacy", 1);
initiate.put("communication", 1);
DEFAULT_CONFIG.put("initiate", initiate);
Map<String, Integer> agree = new HashMap<>();
agree.put("energy", 1);
agree.put("trust", 1);
agree.put("intimacy", 1);
agree.put("communication", 1);
DEFAULT_CONFIG.put("agree", agree);
Map<String, Integer> reject = new HashMap<>();
reject.put("energy", -1);
reject.put("trust", -1);
reject.put("intimacy", -1);
reject.put("communication", 1); // 幅度,方向由代码固定
DEFAULT_CONFIG.put("reject", reject);
}
@Resource
private FamilyRelationshipScoreMapper relationshipScoreMapper;
@Resource
private FamilyChallengeScoreConfigMapper scoreConfigMapper;
@Resource
private FamilyMemberMapper familyMemberMapper;
@Resource
private EnergyService energyService;
/** 发起结算:I→每个其他参与成员 trust+0/intimacy+1/comm+1;能量仅发起人 +1 */
@Transactional
public void applyInitiate(Long familyId, Long initiatorId, List<Long> participantIds, Long challengeId) {
if (familyId == null || initiatorId == null || participantIds == null) return;
int intimacy = configValue("initiate", "intimacy");
int communication = configValue("initiate", "communication");
for (Long otherId : participantIds) {
if (otherId == null || otherId.equals(initiatorId)) continue;
adjustScore(familyId, initiatorId, otherId,
configValue("initiate", "trust"), intimacy, communication);
}
awardEnergy(initiatorId, challengeId, configValue("initiate", "energy"), "发起家庭挑战");
}
/** 同意结算:X→每个其他参与成员 trust+1/intimacy+1/comm+1;能量 X 本人 +1 */
@Transactional
public void applyAgree(Long familyId, Long memberId, List<Long> participantIds, Long challengeId) {
if (familyId == null || memberId == null || participantIds == null) return;
int trust = configValue("agree", "trust");
int intimacy = configValue("agree", "intimacy");
int communication = configValue("agree", "communication");
for (Long otherId : participantIds) {
if (otherId == null || otherId.equals(memberId)) continue;
adjustScore(familyId, memberId, otherId, trust, intimacy, communication);
}
awardEnergy(memberId, challengeId, configValue("agree", "energy"), "同意家庭挑战");
}
/**
* 拒绝结算(矩阵见类注释):
* I→X: trust-1/intimacy-1/comm-1;X→I: 仅 comm+1;已同意成员A→X: 仅 comm-1(C3);能量 I+A 每人 -1
*/
@Transactional
public void applyReject(Long familyId, Long rejectorId, Long initiatorId, List<Long> agreedMemberIds, Long challengeId) {
if (familyId == null || rejectorId == null || initiatorId == null) return;
int commAbs = Math.abs(configValue("reject", "communication"));
// I→X: trust/intimacy/communication 全扣
adjustScore(familyId, initiatorId, rejectorId,
configValue("reject", "trust"),
configValue("reject", "intimacy"),
-commAbs);
// X→I: 仅 communication +1(trust/intimacy 不动)
adjustScore(familyId, rejectorId, initiatorId, 0, 0, commAbs);
// 已同意成员 A→X: 仅 communication -1(C3,trust/intimacy 不扣)
if (agreedMemberIds != null) {
for (Long a : agreedMemberIds) {
if (a == null || a.equals(rejectorId)) continue;
adjustScore(familyId, a, rejectorId, 0, 0, -commAbs);
}
}
// 能量:发起人 + 已同意成员 每人 -1
int energyVal = configValue("reject", "energy");
deductEnergy(initiatorId, challengeId, energyVal, "成员拒绝家庭挑战");
if (agreedMemberIds != null) {
for (Long a : agreedMemberIds) {
if (a == null || a.equals(rejectorId)) continue;
deductEnergy(a, challengeId, energyVal, "成员拒绝家庭挑战");
}
}
}
// ==================== 私有辅助 ====================
/** 读取配置值;无配置行/禁用时用内置默认 */
private int configValue(String action, String scoreType) {
try {
FamilyChallengeScoreConfig cfg = scoreConfigMapper.selectOne(
new LambdaQueryWrapper<FamilyChallengeScoreConfig>()
.eq(FamilyChallengeScoreConfig::getAction, action)
.eq(FamilyChallengeScoreConfig::getScoreType, scoreType));
if (cfg != null && cfg.getValue() != null && (cfg.getEnabled() == null || cfg.getEnabled() == 1)) {
return cfg.getValue();
}
} catch (Exception e) {
log.warn("查询挑战评分配置失败: action={}, scoreType={}, err={}", action, scoreType, e.getMessage());
}
Map<String, Integer> actionCfg = DEFAULT_CONFIG.get(action);
Integer v = actionCfg != null ? actionCfg.get(scoreType) : null;
return v != null ? v : 0;
}
/** 对 (from→to) 方向行增减三项分数(delta 为 0 时跳过该列) */
private void adjustScore(Long familyId, Long fromId, Long toId,
int trustDelta, int intimacyDelta, int communicationDelta) {
FamilyRelationshipScore score = getOrCreateScore(familyId, fromId, toId);
if (trustDelta != 0) {
score.setTrustScore(safeAdd(score.getTrustScore(), trustDelta));
}
if (intimacyDelta != 0) {
score.setIntimacyScore(safeAdd(score.getIntimacyScore(), intimacyDelta));
}
if (communicationDelta != 0) {
score.setCommunicationScore(safeAdd(score.getCommunicationScore(), communicationDelta));
}
relationshipScoreMapper.updateById(score);
}
/**
* 获取 (from→to) 方向行;不存在则创建。
* 初始基线:取 from 成员在 family_members 的成员级分数(无方向),缺失默认 60.00。
* 并发兜底:insert 撞唯一键时重查返回已有行。
*/
private FamilyRelationshipScore getOrCreateScore(Long familyId, Long fromId, Long toId) {
FamilyRelationshipScore existing = relationshipScoreMapper.selectOne(
new LambdaQueryWrapper<FamilyRelationshipScore>()
.eq(FamilyRelationshipScore::getFromMemberId, fromId)
.eq(FamilyRelationshipScore::getToMemberId, toId));
if (existing != null) return existing;
FamilyRelationshipScore score = new FamilyRelationshipScore();
score.setFamilyId(familyId);
score.setFromMemberId(fromId);
score.setToMemberId(toId);
FamilyMember from = familyMemberMapper.selectById(fromId);
score.setTrustScore(from != null && from.getTrustScore() != null
? from.getTrustScore() : new BigDecimal("60.00"));
score.setIntimacyScore(from != null && from.getIntimacyScore() != null
? from.getIntimacyScore() : new BigDecimal("60.00"));
score.setCommunicationScore(from != null && from.getCommunicationScore() != null
? from.getCommunicationScore() : new BigDecimal("60.00"));
try {
relationshipScoreMapper.insert(score);
return score;
} catch (Exception e) {
FamilyRelationshipScore retry = relationshipScoreMapper.selectOne(
new LambdaQueryWrapper<FamilyRelationshipScore>()
.eq(FamilyRelationshipScore::getFromMemberId, fromId)
.eq(FamilyRelationshipScore::getToMemberId, toId));
if (retry != null) return retry;
throw e;
}
}
private BigDecimal safeAdd(BigDecimal base, int delta) {
BigDecimal v = base != null ? base : BigDecimal.ZERO;
return v.add(BigDecimal.valueOf(delta));
}
private void awardEnergy(Long memberId, Long challengeId, int amount, String reason) {
if (amount <= 0) return;
try {
energyService.awardEnergyByCode(memberId, "action", amount, reason + "(挑战ID:" + challengeId + ")");
} catch (Exception e) {
log.error("挑战能量发放失败: memberId={}, challengeId={}", memberId, challengeId, e);
}
}
private void deductEnergy(Long memberId, Long challengeId, int amount, String reason) {
if (amount >= 0) return;
try {
int result = energyService.deductEnergyByCode(memberId, "action", -amount, reason + "(挑战ID:" + challengeId + ")");
if (result == -1) {
log.warn("挑战能量扣减跳过(余额不足): memberId={}, challengeId={}", memberId, challengeId);
}
} catch (Exception e) {
log.error("挑战能量扣减失败: memberId={}, challengeId={}", memberId, challengeId, e);
}
}
}
[x] Step 2: 编译验证
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[x] Step 3: Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/ChallengeScoreService.java
git commit -m "feat(challenge): ChallengeScoreService方向性评分结算引擎"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.javacfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java(新增 myStatus 非表字段)Interfaces:
ChallengeScoreService / FamilyChallengeParticipantMapper(@Resource 注入)Produces:
public Result<Long> createChallenge(Long familyId, Long userId, Long memberId, Map<String,Object> params)(签名变更:新增 memberId)public Result<Void> respondChallenge(Long challengeId, Long memberId, String action)(新增)public List<FamilyChallenge> getActiveChallenges(Long familyId, Long currentMemberId)(签名变更:新增 currentMemberId)public List<FamilyChallenge> getChallengeHistory(Long familyId)(内部口径调整)public Result<Void> updateProgress(Long challengeId, Long childId, int delta)(新增 participant 校验)listParticipants(Long challengeId) / setMyStatus(List<FamilyChallenge>, Long currentMemberId)[x] Step 1: FamilyChallenge 实体新增 myStatus 字段
在 FamilyChallenge.java 的 creatorId 字段之后、类闭合 } 之前追加:
/** 非表字段:当前登录成员在本挑战的参与状态(pending/agreed/rejected/null=非参与) */
@TableField(exist = false)
private String myStatus;
在类顶部(pointsService 字段之后)追加:
@Resource
private ChallengeScoreService challengeScoreService;
@Resource
private FamilyChallengeParticipantMapper familyChallengeParticipantMapper;
并补 import:com.etotem.cfc.entity.FamilyChallengeParticipant、com.etotem.cfc.mapper.FamilyChallengeParticipantMapper、java.util.stream.Collectors(如无)。
在 updateProgress 方法之前(@Transactional public Result<Void> updateProgress 上方)插入:
/**
* 同意/拒绝挑战(参与成员动作,即时结算方向评分)。
* agree:status→agreed → 结算同意评分 → 除 rejected 外全部 agreed → 挑战转 active
* reject:status→rejected → 结算拒绝评分 → 除发起人外无剩余参与成员 → 挑战自动 cancelled(Q6)
*/
@Transactional
public Result<Void> respondChallenge(Long challengeId, Long memberId, String action) {
if (challengeId == null || memberId == null) {
return Result.error("参数错误");
}
if (!"agree".equals(action) && !"reject".equals(action)) {
return Result.error("action必须是agree或reject");
}
FamilyChallenge challenge = familyChallengeMapper.selectById(challengeId);
if (challenge == null) {
return Result.error("挑战不存在");
}
if (!"pending".equals(challenge.getStatus())) {
return Result.error("挑战当前不可操作");
}
// 调用者必须是该挑战 participant 且 status=pending(幂等:已处理过则拒绝)
FamilyChallengeParticipant participant = familyChallengeParticipantMapper.selectOne(
new LambdaQueryWrapper<FamilyChallengeParticipant>()
.eq(FamilyChallengeParticipant::getChallengeId, challengeId)
.eq(FamilyChallengeParticipant::getMemberId, memberId));
if (participant == null) {
return Result.error("你不在该挑战参与名单中");
}
if (!"pending".equals(participant.getStatus())) {
return Result.error("该挑战已处理过,请勿重复操作");
}
List<FamilyChallengeParticipant> allParticipants = listParticipants(challengeId);
List<Long> agreedMemberIds = new ArrayList<>();
for (FamilyChallengeParticipant p : allParticipants) {
if ("agreed".equals(p.getStatus())) agreedMemberIds.add(p.getMemberId());
}
if ("agree".equals(action)) {
participant.setStatus("agreed");
participant.setAgreedAt(new Date());
familyChallengeParticipantMapper.updateById(participant);
List<Long> participantIds = new ArrayList<>();
for (FamilyChallengeParticipant p : allParticipants) {
participantIds.add(p.getMemberId());
}
challengeScoreService.applyAgree(challenge.getFamilyId(), memberId, participantIds, challengeId);
// 除 rejected 外全部 agreed → active
boolean allAgreed = true;
for (FamilyChallengeParticipant p : allParticipants) {
if ("rejected".equals(p.getStatus())) continue;
if (!"agreed".equals(p.getStatus()) && !p.getMemberId().equals(memberId)) {
allAgreed = false;
break;
}
}
if (allAgreed) {
challenge.setStatus("active");
familyChallengeMapper.updateById(challenge);
}
} else {
// reject
participant.setStatus("rejected");
participant.setAgreedAt(new Date());
familyChallengeParticipantMapper.updateById(participant);
Long initiatorId = challenge.getCreatorId() != null
? resolveMemberIdByUserId(challenge.getCreatorId()) : null;
// 发起人为 null(系统创建)时按 memberId 归属推断:取 agreed 成员中第一个?保守处理:不结算 I→X/X→I,仅结算 A→X 与能量
if (initiatorId != null) {
challengeScoreService.applyReject(challenge.getFamilyId(), memberId, initiatorId, agreedMemberIds, challengeId);
} else {
// 系统创建挑战:仅已同意成员→拒绝人 扣沟通 + 能量扣减(无发起人维度)
challengeScoreService.applyReject(challenge.getFamilyId(), memberId, memberId, agreedMemberIds, challengeId);
}
// 除发起人外无剩余参与成员 → cancelled(Q6)
boolean onlyInitiatorLeft = true;
int nonRejected = 0;
for (FamilyChallengeParticipant p : allParticipants) {
if (!"rejected".equals(p.getStatus())) nonRejected++;
}
// 非 rejected 的只剩发起人本人(且发起人是 participant)
if (nonRejected <= 1) {
challenge.setStatus("cancelled");
familyChallengeMapper.updateById(challenge);
}
}
return Result.success();
}
/** 解析 user_id → 家庭成员ID(发起人) */
private Long resolveMemberIdByUserId(Long userId) {
if (userId == null) return null;
// 发起人 creatorId 存的是 userId;查其家庭成员
List<FamilyMember> members = familyMemberMapper.selectList(
new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getUserId, userId));
return members.isEmpty() ? null : members.get(0).getId();
}
private List<FamilyChallengeParticipant> listParticipants(Long challengeId) {
return familyChallengeParticipantMapper.selectList(
new LambdaQueryWrapper<FamilyChallengeParticipant>()
.eq(FamilyChallengeParticipant::getChallengeId, challengeId)
.orderByAsc(FamilyChallengeParticipant::getId));
}
将 createChallenge 方法(358-422 行)整体替换为:
@Transactional
public Result<Long> createChallenge(Long familyId, Long userId, Long memberId, Map<String, Object> params) {
if (familyId == null || userId == null || memberId == null) {
return Result.error("familyId、userId和memberId不能为空");
}
// 验证title
String title = params.get("title") != null ? params.get("title").toString() : null;
if (title == null || title.trim().isEmpty()) {
return Result.error("title不能为空");
}
if (title.length() > 200) {
return Result.error("title不能超过200个字符");
}
// 验证targetMode
String targetMode = params.get("targetMode") != null ? params.get("targetMode").toString() : "all_members";
if (!"aggregate".equals(targetMode) && !"all_members".equals(targetMode)) {
return Result.error("targetMode必须是aggregate或all_members");
}
// 验证targetValue
Integer targetValue = params.get("targetValue") != null ?
Integer.parseInt(params.get("targetValue").toString()) : 0;
if (targetValue <= 0) {
return Result.error("targetValue必须大于0");
}
// 验证rewardPoints
Integer rewardPoints = params.get("rewardPoints") != null ?
Integer.parseInt(params.get("rewardPoints").toString()) : 0;
if (rewardPoints < 0) {
return Result.error("rewardPoints不能小于0");
}
// 验证durationDays
Integer durationDays = params.get("durationDays") != null ?
Integer.parseInt(params.get("durationDays").toString()) : 7;
if (durationDays <= 0) {
return Result.error("durationDays必须大于0");
}
// 验证description
String description = params.get("description") != null ? params.get("description").toString() : "";
// 参与成员校验:默认全员;必须包含发起人本人且均属本家庭
List<Long> participantIds = new ArrayList<>();
Object rawIds = params.get("participantMemberIds");
if (rawIds == null) {
// 未传 → 默认本家庭全部成员
List<FamilyMember> members = familyMemberMapper.selectList(
new LambdaQueryWrapper<FamilyMember>().eq(FamilyMember::getFamilyId, familyId));
for (FamilyMember m : members) {
participantIds.add(m.getId());
}
} else if (rawIds instanceof java.util.Collection) {
for (Object o : (java.util.Collection<?>) rawIds) {
if (o == null) continue;
participantIds.add(Long.valueOf(o.toString()));
}
}
if (participantIds.isEmpty() || !participantIds.contains(memberId)) {
return Result.error("参与成员必须包含发起人本人");
}
// 均属本家庭
for (Long pid : participantIds) {
LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
.eq(FamilyMember::getId, pid)
.eq(FamilyMember::getFamilyId, familyId);
if (familyMemberMapper.selectCount(mw) == 0) {
return Result.error("参与成员不属于本家庭");
}
}
// 创建挑战(pending 状态,参与成员同意后转 active)
FamilyChallenge challenge = new FamilyChallenge();
challenge.setFamilyId(familyId);
challenge.setCreatorId(userId);
challenge.setChallengeType(params.get("challengeType") != null ? params.get("challengeType").toString() : "custom");
challenge.setTitle(title);
challenge.setDescription(description);
challenge.setDurationDays(durationDays);
challenge.setRewardPoints(rewardPoints);
challenge.setTargetMode(targetMode);
challenge.setTargetValue(targetValue);
challenge.setStartDate(new Date());
challenge.setEndDate(new Date(System.currentTimeMillis() + durationDays * 24L * 60 * 60 * 1000));
challenge.setStatus("pending");
challenge.setCreatedAt(new Date());
familyChallengeMapper.insert(challenge);
// 写 participant 行:发起人 agreed,其余 pending
for (Long pid : participantIds) {
FamilyChallengeParticipant participant = new FamilyChallengeParticipant();
participant.setChallengeId(challenge.getId());
participant.setMemberId(pid);
if (pid.equals(memberId)) {
participant.setStatus("agreed");
participant.setAgreedAt(new Date());
} else {
participant.setStatus("pending");
}
familyChallengeParticipantMapper.insert(participant);
}
// 立即结算发起评分(§4.4)
challengeScoreService.applyInitiate(familyId, memberId, participantIds, challenge.getId());
return Result.success(challenge.getId());
}
将 getActiveChallenges 方法(39-58 行)替换为:
public List<FamilyChallenge> getActiveChallenges(Long familyId, Long currentMemberId) {
if (familyId == null) return new ArrayList<>();
LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
.eq(FamilyChallenge::getFamilyId, familyId)
.in(FamilyChallenge::getStatus, "active", "pending")
.orderByDesc(FamilyChallenge::getCreatedAt);
List<FamilyChallenge> active = familyChallengeMapper.selectList(wrapper);
for (FamilyChallenge c : active) {
// 仅 active 且过期的挑战触发结算
if ("active".equals(c.getStatus())
&& c.getEndDate() != null && new Date().after(c.getEndDate())) {
checkAndSettle(c);
}
}
active = familyChallengeMapper.selectList(wrapper);
fillProgress(active);
setMyStatus(active, currentMemberId);
return active;
}
/** 为每个挑战设置当前成员参与状态(myStatus) */
private void setMyStatus(List<FamilyChallenge> challenges, Long currentMemberId) {
if (challenges == null || challenges.isEmpty() || currentMemberId == null) return;
for (FamilyChallenge c : challenges) {
FamilyChallengeParticipant p = familyChallengeParticipantMapper.selectOne(
new LambdaQueryWrapper<FamilyChallengeParticipant>()
.eq(FamilyChallengeParticipant::getChallengeId, c.getId())
.eq(FamilyChallengeParticipant::getMemberId, currentMemberId));
c.setMyStatus(p != null ? p.getStatus() : null);
}
}
将 fillProgress 方法(271-316 行)中"查全部家庭成员"部分替换为基于 participant:
private void fillProgress(List<FamilyChallenge> challenges) {
if (challenges == null || challenges.isEmpty()) return;
Map<Long, List<FamilyMember>> familyMembersCache = new HashMap<>();
for (FamilyChallenge c : challenges) {
normalizeTarget(c);
LambdaQueryWrapper<FamilyChallengeProgress> pw = new LambdaQueryWrapper<FamilyChallengeProgress>()
.eq(FamilyChallengeProgress::getChallengeId, c.getId());
List<FamilyChallengeProgress> progresses = familyChallengeProgressMapper.selectList(pw);
int total = 0;
Map<Long, Integer> progressMap = new HashMap<>();
for (FamilyChallengeProgress p : progresses) {
int v = p.getProgressValue() != null ? p.getProgressValue() : 0;
total += v;
progressMap.put(p.getChildId(), v);
}
c.setTotalProgress(total);
// 参与成员(participant 表,含 status),替代原"全部家庭成员"
List<FamilyChallengeParticipant> participants = listParticipants(c.getId());
List<Map<String, Object>> memberProgress = new ArrayList<>();
if (participants != null) {
for (FamilyChallengeParticipant part : participants) {
FamilyMember m = familyMemberMapper.selectById(part.getMemberId());
Integer v = progressMap.get(part.getMemberId());
int value = v != null ? v : 0;
Map<String, Object> mp = new HashMap<>();
mp.put("memberId", part.getMemberId());
mp.put("nickname", m != null && m.getNickname() != null ? m.getNickname() : "成员" + part.getMemberId());
mp.put("progressValue", value);
mp.put("completed", value >= c.getTargetValue());
mp.put("status", part.getStatus());
memberProgress.add(mp);
}
}
c.setMemberProgress(memberProgress);
}
}
注意:将原 familyMembersCache 逻辑一并移除(participant 已含全部需要展示的成员);familyMembersCache 变量随之删除。
在 checkAndSettle(182-254 行)中,将 all_members 分支的成员查询替换:
} else {
// 全员个人目标:每个 agreed 参与成员都达到 target_value
List<FamilyChallengeParticipant> participants = listParticipants(challenge.getId());
List<FamilyMember> members = new ArrayList<>();
for (FamilyChallengeParticipant p : participants) {
if (!"agreed".equals(p.getStatus())) continue;
FamilyMember m = familyMemberMapper.selectById(p.getMemberId());
if (m != null) members.add(m);
}
if (members.isEmpty()) {
isCompleted = false;
} else {
Map<Long, Integer> progressMap = new HashMap<>();
for (FamilyChallengeProgress p : progresses) {
progressMap.put(p.getChildId(),
p.getProgressValue() != null ? p.getProgressValue() : 0);
}
isCompleted = true;
for (FamilyMember m : members) {
Integer v = progressMap.get(m.getId());
if (v == null || v < targetValue) {
isCompleted = false;
break;
}
}
}
}
在 autoCreateChallenges 方法(86-100 行)中,familyChallengeMapper.insert(challenge); 之后追加:
// 系统创建:写 participant 行(全员 agreed,与存量迁移口径一致)
LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
.eq(FamilyMember::getFamilyId, familyId);
List<FamilyMember> members = familyMemberMapper.selectList(mw);
for (FamilyMember m : members) {
FamilyChallengeParticipant participant = new FamilyChallengeParticipant();
participant.setChallengeId(challenge.getId());
participant.setMemberId(m.getId());
participant.setStatus("agreed");
participant.setAgreedAt(new Date());
familyChallengeParticipantMapper.insert(participant);
}
在 updateProgress(105-173 行)的成员归属校验(第 3 步)之后插入:
// 3.5 参与成员校验:仅 agreed 参与成员可打卡(pending/rejected 拒绝)
FamilyChallengeParticipant participant = familyChallengeParticipantMapper.selectOne(
new LambdaQueryWrapper<FamilyChallengeParticipant>()
.eq(FamilyChallengeParticipant::getChallengeId, challengeId)
.eq(FamilyChallengeParticipant::getMemberId, childId));
if (participant == null || !"agreed".equals(participant.getStatus())) {
return Result.error("仅参与成员可打卡");
}
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS(现有测试类若引用了旧签名 getActiveChallenges/createChallenge,会在 Task 7 一并修正——但编译阶段会先失败,故此处同步把 Task 7 的 mock 调整提前或在 Task 7 修复。如编译失败因测试类旧签名,立即在 Task 7 Step 1 修复测试类签名。)
[x] Step 11: Commit
git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java
git commit -m "feat(challenge): create改pending+respondChallenge+打卡结算口径收紧"
Files:
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.javaInterfaces:
FamilyChallengeService.respondChallenge / getActiveChallenges(familyId, currentMemberId) / createChallenge(familyId, userId, memberId, params)Produces: POST /api/health/challenge/respond(body: {challengeId, action})
[x] Step 1: list 端点传 currentMemberId
替换 getList(19-25 行):
@PostMapping("/list")
public Result<List<FamilyChallenge>> getList(@RequestAttribute(value = "familyId", required = false) Long familyId,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (familyId == null) {
return Result.noFamily("请先创建或加入家庭");
}
return Result.success(familyChallengeService.getActiveChallenges(familyId, currentMemberId));
}
替换 createChallenge(57-66 行):
@PostMapping("/create")
public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
@RequestAttribute(value = "familyId", required = false) Long familyId,
@RequestAttribute(value = "userId", required = false) Long userId,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (currentMemberId == null) {
return Result.error("未识别当前成员");
}
return familyChallengeService.createChallenge(familyId, userId, currentMemberId, params);
}
在 updateProgress 端点之后插入:
@PostMapping("/respond")
public Result<Void> respondChallenge(@RequestBody Map<String, Object> params,
@RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
if (currentMemberId == null) {
return Result.error("未识别当前成员");
}
Long challengeId = params.get("challengeId") != null
? Long.valueOf(params.get("challengeId").toString()) : null;
String action = params.get("action") != null ? params.get("action").toString() : null;
return familyChallengeService.respondChallenge(challengeId, currentMemberId, action);
}
Run:
cd cfc-backend && grep -rn '@Mapping' src/main/java/com/etotem/cfc/controller/ | grep -oP '@\w+Mapping\("\K[^"]*' | sort -u | Select-String -Pattern "challenge"
Expected: 仅 challenge/list、challenge/history、challenge/progress、challenge/templates、challenge/create、challenge/update/{id}、challenge/delete/{id}、challenge/respond,无重复。
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[x] Step 6: Commit
git add cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java
git commit -m "feat(challenge): respond端点+创建去门控+list返回myStatus"
Files:
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.javacfc-backend/src/test/java/com/etotem/cfc/service/ChallengeScoreServiceTest.javaInterfaces:
Consumes: FamilyChallengeService(新签名 getActiveChallenges/createChallenge、respondChallenge)、ChallengeScoreService(applyInitiate/applyAgree/applyReject)
[x] Step 1: 修复 FamilyChallengeServiceTest 旧签名 + 新增 mock
将测试类(26-33 行)mock 区替换为(新增 ChallengeScoreService 与 FamilyChallengeParticipantMapper mock):
public class FamilyChallengeServiceTest {
@Mock private FamilyChallengeMapper familyChallengeMapper;
@Mock private FamilyChallengeProgressMapper familyChallengeProgressMapper;
@Mock private FamilyChallengeParticipantMapper familyChallengeParticipantMapper;
@Mock private FamilyMemberMapper familyMemberMapper;
@Mock private HealthScoreService healthScoreService;
@Mock private PointsService pointsService;
@Mock private ChallengeScoreService challengeScoreService;
@InjectMocks private FamilyChallengeService service;
并将 getActiveChallenges_shouldSettleExpiredChallenges(115-132 行)调用改为新签名:service.getActiveChallenges(10L, null);,同时补 when(familyChallengeParticipantMapper.selectOne(any())).thenReturn(null);(getActiveChallenges 中 setMyStatus 会查询 participant;currentMemberId=null 时 setMyStatus 直接 return,无需 mock,但 fillProgress 会调 listParticipants → selectList)。
注意:fillProgress 现在调用 familyChallengeParticipantMapper.selectList(any())——需要在每个相关测试补 when(familyChallengeParticipantMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());,否则 NPE。在 setUp() 中统一补充:
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
when(familyChallengeParticipantMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
}
在测试类末尾追加:
@Test
void respondChallenge_agree_shouldTransitionToActiveWhenAllAgreed() {
FamilyChallenge challenge = new FamilyChallenge();
challenge.setId(1L);
challenge.setFamilyId(10L);
challenge.setStatus("pending");
challenge.setTargetMode("aggregate");
challenge.setTargetValue(300);
challenge.setCreatorId(9L);
FamilyChallengeParticipant p = new FamilyChallengeParticipant();
p.setId(1L);
p.setChallengeId(1L);
p.setMemberId(100L);
p.setStatus("pending");
FamilyChallengeParticipant initiator = new FamilyChallengeParticipant();
initiator.setId(2L);
initiator.setChallengeId(1L);
initiator.setMemberId(200L);
initiator.setStatus("agreed");
FamilyMember member = new FamilyMember();
member.setId(200L);
member.setUserId(9L);
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
when(familyChallengeParticipantMapper.selectOne(any())).thenReturn(p);
when(familyChallengeParticipantMapper.selectList(any()))
.thenReturn(java.util.Arrays.asList(initiator, p));
Result<Void> result = service.respondChallenge(1L, 100L, "agree");
assertEquals(Integer.valueOf(200), result.getCode());
verify(challengeScoreService).applyAgree(eq(10L), eq(100L), anyList(), eq(1L));
verify(familyChallengeParticipantMapper).updateById(argThat(pp -> "agreed".equals(pp.getStatus())));
verify(familyChallengeMapper).updateById(argThat(c -> "active".equals(c.getStatus())));
}
@Test
void respondChallenge_reject_shouldCancelWhenOnlyInitiatorLeft() {
FamilyChallenge challenge = new FamilyChallenge();
challenge.setId(1L);
challenge.setFamilyId(10L);
challenge.setStatus("pending");
challenge.setTargetMode("aggregate");
challenge.setTargetValue(300);
challenge.setCreatorId(9L);
FamilyChallengeParticipant p = new FamilyChallengeParticipant();
p.setId(1L);
p.setChallengeId(1L);
p.setMemberId(100L);
p.setStatus("pending");
FamilyChallengeParticipant initiator = new FamilyChallengeParticipant();
initiator.setId(2L);
initiator.setChallengeId(1L);
initiator.setMemberId(200L);
initiator.setStatus("agreed");
FamilyMember member = new FamilyMember();
member.setId(200L);
member.setUserId(9L);
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
when(familyChallengeParticipantMapper.selectOne(any())).thenReturn(p);
when(familyChallengeParticipantMapper.selectList(any()))
.thenReturn(java.util.Arrays.asList(initiator, p));
when(familyMemberMapper.selectList(any())).thenReturn(java.util.Arrays.asList(member));
Result<Void> result = service.respondChallenge(1L, 100L, "reject");
assertEquals(Integer.valueOf(200), result.getCode());
verify(challengeScoreService).applyReject(eq(10L), eq(100L), eq(200L), anyList(), eq(1L));
verify(familyChallengeMapper).updateById(argThat(c -> "cancelled".equals(c.getStatus())));
}
@Test
void respondChallenge_shouldRejectWhenAlreadyProcessed() {
FamilyChallenge challenge = new FamilyChallenge();
challenge.setId(1L);
challenge.setFamilyId(10L);
challenge.setStatus("pending");
challenge.setTargetMode("aggregate");
challenge.setTargetValue(300);
FamilyChallengeParticipant p = new FamilyChallengeParticipant();
p.setId(1L);
p.setChallengeId(1L);
p.setMemberId(100L);
p.setStatus("agreed"); // 已处理过
when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
when(familyChallengeParticipantMapper.selectOne(any())).thenReturn(p);
Result<Void> result = service.respondChallenge(1L, 100L, "agree");
assertEquals(Integer.valueOf(500), result.getCode());
verify(challengeScoreService, never()).applyAgree(any(), any(), any(), any());
}
补充静态导入:import static org.mockito.ArgumentMatchers.anyList; 与 import com.etotem.cfc.entity.FamilyChallengeParticipant;、import com.etotem.cfc.entity.FamilyMember;、import com.etotem.cfc.mapper.FamilyChallengeParticipantMapper;。
创建 cfc-backend/src/test/java/com/etotem/cfc/service/ChallengeScoreServiceTest.java:
package com.etotem.cfc.service;
import com.etotem.cfc.entity.FamilyChallengeScoreConfig;
import com.etotem.cfc.entity.FamilyMember;
import com.etotem.cfc.entity.FamilyRelationshipScore;
import com.etotem.cfc.mapper.FamilyChallengeScoreConfigMapper;
import com.etotem.cfc.mapper.FamilyMemberMapper;
import com.etotem.cfc.mapper.FamilyRelationshipScoreMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.math.BigDecimal;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
public class ChallengeScoreServiceTest {
@Mock private FamilyRelationshipScoreMapper relationshipScoreMapper;
@Mock private FamilyChallengeScoreConfigMapper scoreConfigMapper;
@Mock private FamilyMemberMapper familyMemberMapper;
@Mock private EnergyService energyService;
@InjectMocks private ChallengeScoreService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
// 无配置行 → 用内置默认
when(scoreConfigMapper.selectOne(any())).thenReturn(null);
}
private FamilyRelationshipScore freshScore() {
FamilyRelationshipScore s = new FamilyRelationshipScore();
s.setId(1L);
s.setFamilyId(10L);
s.setFromMemberId(100L);
s.setToMemberId(200L);
s.setTrustScore(new BigDecimal("60.00"));
s.setIntimacyScore(new BigDecimal("60.00"));
s.setCommunicationScore(new BigDecimal("60.00"));
return s;
}
@Test
void applyAgree_shouldAddTrustIntimacyCommToOneDirectionOnly() {
FamilyRelationshipScore score = freshScore();
when(relationshipScoreMapper.selectOne(any())).thenReturn(score);
service.applyAgree(10L, 100L, Arrays.asList(100L, 200L), 1L);
assertEquals(new BigDecimal("61.00"), score.getTrustScore());
assertEquals(new BigDecimal("61.00"), score.getIntimacyScore());
assertEquals(new BigDecimal("61.00"), score.getCommunicationScore());
verify(relationshipScoreMapper).updateById(score);
verify(energyService).awardEnergyByCode(eq(100L), eq("action"), eq(1), anyString());
}
@Test
void applyReject_shouldFollowConfirmedMatrix() {
// I→X: trust-1/intimacy-1/comm-1
FamilyRelationshipScore iToX = freshScore();
iToX.setFromMemberId(100L); // I=100
iToX.setToMemberId(200L); // X=200
// X→I: 仅 comm+1
FamilyRelationshipScore xToI = freshScore();
xToI.setFromMemberId(200L);
xToI.setToMemberId(100L);
// A→X: 仅 comm-1
FamilyRelationshipScore aToX = freshScore();
aToX.setFromMemberId(300L);
aToX.setToMemberId(200L);
when(relationshipScoreMapper.selectOne(any())).thenReturn(iToX, xToI, aToX);
service.applyReject(10L, 200L, 100L, Arrays.asList(300L), 1L);
// I→X
assertEquals(new BigDecimal("59.00"), iToX.getTrustScore());
assertEquals(new BigDecimal("59.00"), iToX.getIntimacyScore());
assertEquals(new BigDecimal("59.00"), iToX.getCommunicationScore());
// X→I
assertEquals(new BigDecimal("60.00"), xToI.getTrustScore());
assertEquals(new BigDecimal("60.00"), xToI.getIntimacyScore());
assertEquals(new BigDecimal("61.00"), xToI.getCommunicationScore());
// A→X(C3:只扣沟通)
assertEquals(new BigDecimal("60.00"), aToX.getTrustScore());
assertEquals(new BigDecimal("60.00"), aToX.getIntimacyScore());
assertEquals(new BigDecimal("59.00"), aToX.getCommunicationScore());
// 能量:I(100) 与 A(300) 各 -1
verify(energyService).deductEnergyByCode(eq(100L), eq("action"), eq(1), anyString());
verify(energyService).deductEnergyByCode(eq(300L), eq("action"), eq(1), anyString());
}
@Test
void applyInitiate_shouldNotAddTrust() {
FamilyRelationshipScore score = freshScore();
when(relationshipScoreMapper.selectOne(any())).thenReturn(score);
service.applyInitiate(10L, 100L, Arrays.asList(100L, 200L), 1L);
assertEquals(new BigDecimal("60.00"), score.getTrustScore()); // 不加信任
assertEquals(new BigDecimal("61.00"), score.getIntimacyScore());
assertEquals(new BigDecimal("61.00"), score.getCommunicationScore());
}
@Test
void getOrCreateScore_shouldUseMemberBaselineWhenNoRow() {
FamilyMember member = new FamilyMember();
member.setId(100L);
member.setTrustScore(new BigDecimal("70.00"));
member.setIntimacyScore(new BigDecimal("65.00"));
member.setCommunicationScore(new BigDecimal("55.00"));
when(relationshipScoreMapper.selectOne(any())).thenReturn(null, null); // 第一次查无、撞键重查仍无 → insert
when(relationshipScoreMapper.selectOne(any())).thenReturn(null);
when(familyMemberMapper.selectById(100L)).thenReturn(member);
service.applyAgree(10L, 100L, Arrays.asList(100L, 200L), 1L);
verify(relationshipScoreMapper).insert(any());
}
}
补充静态导入:import static org.mockito.ArgumentMatchers.anyString;、import static org.mockito.ArgumentMatchers.eq;、import static org.mockito.Mockito.verify;。
Run: cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest,ChallengeScoreServiceTest
Expected: 全部 PASS(若 getOrCreateScore_shouldUseMemberBaselineWhenNoRow 中 selectOne 连续两次 mock 相同值导致 insert 后重查仍 null 的兜底逻辑与 mock 不匹配,可简化为只断言 insert 被调用,不强求重查分支)
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
[x] Step 6: Commit
git add cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java cfc-backend/src/test/java/com/etotem/cfc/service/ChallengeScoreServiceTest.java
git commit -m "test(challenge): 参与机制单元测试(respond/评分矩阵)"
Files:
cfc-web/src/api/challengeScore.jscfc-web/src/views/admin/ChallengeScoreConfig.vuecfc-web/src/router/index.jsInterfaces:
POST /api/admin/challenge-score-config/list / POST /api/admin/challenge-score-config/updateProduces: 配置页路由 /admin/challenge-score-config(name: ChallengeScoreConfig)
[x] Step 1: 创建 API 封装
创建 cfc-web/src/api/challengeScore.js(参照 cfc-web/src/api/energy.js 的 request 封装风格):
import request from '@/utils/request'
export function getChallengeScoreConfigList() {
return request({ url: '/api/admin/challenge-score-config/list', method: 'post' })
}
export function updateChallengeScoreConfig(data) {
return request({ url: '/api/admin/challenge-score-config/update', method: 'post', data })
}
创建 cfc-web/src/views/admin/ChallengeScoreConfig.vue(参照 EnergyBehaviorConfig.vue 结构,简化为 3×4 矩阵编辑):
<template>
<div class="challenge-score-config admin-page">
<div class="header">
<h2>挑战评分配置</h2>
<div class="header-tip">家庭挑战 发起/同意/拒绝 的方向性关系评分与能量增减(可正可负)</div>
</div>
<el-table :data="matrix" v-loading="loading" border stripe>
<el-table-column label="动作" width="120">
<template slot-scope="{ row }">
<el-tag size="mini" :type="row.action === 'reject' ? 'danger' : 'success'">{{ actionLabel(row.action) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="能量(行维度)" width="140">
<template slot-scope="{ row }">
<el-input-number v-model="row.energy" :min="-10" :max="10" size="mini" />
</template>
</el-table-column>
<el-table-column label="信任(方向)" width="140">
<template slot-scope="{ row }">
<el-input-number v-model="row.trust" :min="-10" :max="10" size="mini" />
</template>
</el-table-column>
<el-table-column label="亲近(方向)" width="140">
<template slot-scope="{ row }">
<el-input-number v-model="row.intimacy" :min="-10" :max="10" size="mini" />
</template>
</el-table-column>
<el-table-column label="沟通(方向)" width="140">
<template slot-scope="{ row }">
<el-input-number v-model="row.communication" :min="-10" :max="10" size="mini" />
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="{ row }">
<el-button size="mini" type="primary" @click="saveRow(row)">保存</el-button>
</template>
</el-table-column>
</el-table>
<div class="footer-tip">注:reject 的沟通方向由代码固定(拒绝人→发起人 +、发起人→拒绝人 -、已同意成员→拒绝人 -),此处的值为增减幅度。信任/亲近为单向扣减(发起人→拒绝人)。</div>
</div>
</template>
<script>
import { getChallengeScoreConfigList, updateChallengeScoreConfig } from '@/api/challengeScore'
export default {
name: 'ChallengeScoreConfig',
data() {
return {
loading: false,
matrix: []
}
},
created() {
this.loadList()
},
methods: {
actionLabel: function(action) {
var map = { initiate: '发起', agree: '同意', reject: '拒绝' }
return map[action] || action
},
loadList: function() {
var self = this
self.loading = true
getChallengeScoreConfigList().then(function(res) {
var list = (res && res.data) || []
var matrix = []
var actions = ['initiate', 'agree', 'reject']
for (var i = 0; i < actions.length; i++) {
var row = { action: actions[i], energy: 0, trust: 0, intimacy: 0, communication: 0 }
for (var j = 0; j < list.length; j++) {
var item = list[j]
if (item.action !== row.action) continue
if (item.scoreType === 'energy') row.energy = item.value
if (item.scoreType === 'trust') row.trust = item.value
if (item.scoreType === 'intimacy') row.intimacy = item.value
if (item.scoreType === 'communication') row.communication = item.value
}
matrix.push(row)
}
self.matrix = matrix
}).catch(function() {
self.matrix = []
}).finally(function() {
self.loading = false
})
},
saveRow: function(row) {
var self = this
var items = [
{ action: row.action, scoreType: 'energy', value: row.energy },
{ action: row.action, scoreType: 'trust', value: row.trust },
{ action: row.action, scoreType: 'intimacy', value: row.intimacy },
{ action: row.action, scoreType: 'communication', value: row.communication }
]
var promises = items.map(function(item) {
return updateChallengeScoreConfig(item)
})
Promise.all(promises).then(function() {
self.$message.success(row.action + ' 配置已保存')
}).catch(function() {
self.$message.error('保存失败')
})
}
}
}
</script>
<style scoped>
.header-tip { color: #909399; font-size: 13px; margin-top: 4px; }
.footer-tip { margin-top: 12px; color: #909399; font-size: 12px; line-height: 1.6; }
</style>
在 cfc-web/src/router/index.js 中 energy-behavior-config 路由(309-314 行)之后插入:
{
path: 'challenge-score-config',
name: 'ChallengeScoreConfig',
component: () => import('@/views/admin/ChallengeScoreConfig.vue'),
meta: { title: '挑战评分配置', perm: 'energy' }
},
创建 cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminChallengeScoreConfigController.java(参照 AdminEnergyBehaviorConfigController 风格):
package com.etotem.cfc.controller.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.etotem.cfc.common.Result;
import com.etotem.cfc.entity.FamilyChallengeScoreConfig;
import com.etotem.cfc.mapper.FamilyChallengeScoreConfigMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Tag(name = "管理端-挑战评分配置")
@RestController
@RequestMapping("/api/admin/challenge-score-config")
public class AdminChallengeScoreConfigController {
@Resource
private FamilyChallengeScoreConfigMapper configMapper;
@Operation(summary = "获取全部挑战评分配置")
@PostMapping("/list")
public Result<List<FamilyChallengeScoreConfig>> list() {
List<FamilyChallengeScoreConfig> list = configMapper.selectList(
new LambdaQueryWrapper<FamilyChallengeScoreConfig>()
.orderByAsc(FamilyChallengeScoreConfig::getAction)
.orderByAsc(FamilyChallengeScoreConfig::getScoreType));
return Result.success(list);
}
@Operation(summary = "更新挑战评分配置(按 action+scoreType)")
@PostMapping("/update")
public Result<String> update(@RequestBody FamilyChallengeScoreConfig config) {
if (config.getAction() == null || config.getScoreType() == null) {
return Result.error("action和scoreType不能为空");
}
FamilyChallengeScoreConfig existing = configMapper.selectOne(
new LambdaQueryWrapper<FamilyChallengeScoreConfig>()
.eq(FamilyChallengeScoreConfig::getAction, config.getAction())
.eq(FamilyChallengeScoreConfig::getScoreType, config.getScoreType()));
if (existing == null) {
// 不存在则按默认创建
config.setEnabled(config.getEnabled() != null ? config.getEnabled() : 1);
config.setUpdatedAt(new Date());
configMapper.insert(config);
return Result.success("已创建配置");
}
if (config.getValue() != null) existing.setValue(config.getValue());
if (config.getEnabled() != null) existing.setEnabled(config.getEnabled());
existing.setUpdatedAt(new Date());
configMapper.updateById(existing);
return Result.success("更新成功");
}
}
Run: cd cfc-backend && mvn clean compile
Expected: BUILD SUCCESS
Run(前端 script 块语法校验,从 vue 文件提取 script 后 node --check):
cd cfc-web && node -e "const fs=require('fs');const s=fs.readFileSync('src/views/admin/ChallengeScoreConfig.vue','utf8');const m=s.match(/<script>([\s\S]*?)<\/script>/);require('child_process').execSync('node --check',{input:m[1]})"
Expected: 无输出(语法通过)
[x] Step 6: Commit
git add cfc-web/src/api/challengeScore.js cfc-web/src/views/admin/ChallengeScoreConfig.vue cfc-web/src/router/index.js cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminChallengeScoreConfigController.java
git commit -m "feat(admin): 挑战评分配置管理页+后端配置CRUD"
Files:
cfc-frontend/utils/api.jscfc-frontend/components/FamilyChallengeCard.vuecfc-frontend/pages/health/challenge-manage.vuecfc-frontend/pages/index-home/index.vue小程序硬约束: 禁止 ?.、禁止 :key 表达式(用方法调用)、禁止 new Date(string)(用 parseDate)、Vue 2 Options API、prop 数组兜底(|| [])。
在 cfc-frontend/utils/api.js 中 updateChallengeProgress(1940 行)之后追加:
export const respondChallenge = (data) => request('/api/health/challenge/respond', 'POST', data)
模板(1-51 行)修改:
<view class="card-header">
<text class="card-title">{{ challenge.title }}</text>
<text class="card-status" :class="statusClass">
{{ statusText }}
</text>
</view>
<text class="card-desc">{{ challenge.description || '' }}</text>
<!-- 待同意:显示确认进度(pending 状态挑战) -->
<view class="pending-section" v-if="challenge.status === 'pending'">
<text class="pending-hint">等待成员确认(已同意 {{ agreedCount }}/{{ totalCount }})</text>
</view>
<!-- 成员状态徽标:rejected 成员显示"已拒绝" -->
<view class="member-status-row" v-if="challenge.memberProgress && challenge.memberProgress.length > 0">
<text class="member-status-tag member-rejected" v-for="mp in challenge.memberProgress" :key="getMemberKey(mp)" v-if="mp.status === 'rejected'">{{ mp.nickname }}已拒绝</text>
</view>
<!-- 目标展示(两种模式) -->
<view class="target-section" v-if="challenge.targetMode">
...
说明:目标展示区(target-section)保持原样;v-if 与 v-for 同元素在小程序下不推荐——改为外层包一层:
<view class="member-status-row" v-if="hasRejected">
<text class="member-status-tag member-rejected" v-for="mp in rejectedMembers" :key="getMemberKey(mp)">{{ mp.nickname }} 已拒绝</text>
</view>
卡片动作区(47-49 行)替换为三态:
<!-- 待同意:同意/拒绝按钮(仅我的状态=pending 时显示) -->
<view class="card-action" v-if="challenge.status === 'pending' && challenge.myStatus === 'pending'">
<view class="respond-btn respond-agree" @click="onRespond('agree')">✅ 同意</view>
<view class="respond-btn respond-reject" @click="onRespond('reject')">✖ 拒绝</view>
</view>
<!-- 进行中:打卡 -->
<view class="card-action" v-if="showCheckin && challenge.status === 'active'">
<view class="checkin-btn" @click="onCheckin">✅ 打卡</view>
</view>
script 部分(54-104 行)修改:新增 computed statusText、agreedCount、totalCount、hasRejected、rejectedMembers;methods 新增 getMemberKey、onRespond:
computed: {
statusClass: function() {
return 'status-' + (this.challenge && this.challenge.status ? this.challenge.status : 'active')
},
statusText: function() {
if (!this.challenge) return ''
if (this.challenge.status === 'active') return '进行中'
if (this.challenge.status === 'pending') return '待同意'
if (this.challenge.status === 'cancelled') return '已取消'
return '已完成'
},
agreedCount: function() {
var list = this.challenge && this.challenge.memberProgress ? this.challenge.memberProgress : []
var count = 0
for (var i = 0; i < list.length; i++) {
if (list[i].status === 'agreed') count++
}
return count
},
totalCount: function() {
var list = this.challenge && this.challenge.memberProgress ? this.challenge.memberProgress : []
return list.length
},
hasRejected: function() {
return this.rejectedMembers.length > 0
},
rejectedMembers: function() {
var list = this.challenge && this.challenge.memberProgress ? this.challenge.memberProgress : []
var result = []
for (var i = 0; i < list.length; i++) {
if (list[i].status === 'rejected') result.push(list[i])
}
return result
},
targetModeLabel: function() {
if (!this.challenge) return ''
if (this.challenge.targetMode === 'aggregate') return '🎯 全家目标'
return '🎯 每人目标'
},
formatTargetValue: function() {
if (!this.challenge) return ''
return this.challenge.targetValue
},
totalPercent: function() {
var total = this.challenge && this.challenge.totalProgress ? this.challenge.totalProgress : 0
var target = this.challenge && this.challenge.targetValue ? this.challenge.targetValue : 1
if (target <= 0) return 0
var p = Math.round(total / target * 100)
return Math.min(p, 100)
}
},
methods: {
formatDate: function(dateStr) {
if (!dateStr) return ''
return String(dateStr).slice(0, 10)
},
memberPercent: function(value) {
var target = this.challenge && this.challenge.targetValue ? this.challenge.targetValue : 1
if (target <= 0) return 0
var p = Math.round((value || 0) / target * 100)
return Math.min(p, 100)
},
getMemberKey: function(mp) {
return mp.memberId
},
onRespond: function(action) {
var self = this
var tip = action === 'agree' ? '确认同意参与该挑战吗?' : '确认拒绝该挑战吗?'
uni.showModal({
title: '确认操作',
content: tip,
success: function(res) {
if (res.confirm) {
self.$emit('respond', { challengeId: self.challenge.id, action: action })
}
}
})
},
onCheckin: function() {
var self = this
uni.showModal({
title: '确认打卡',
content: '确认完成今日打卡吗?',
success: function(res) {
if (res.confirm) {
self.$emit('checkin', { challengeId: self.challenge.id })
}
}
})
}
}
style 追加:
.pending-section { background: #FEF3C7; border-radius: 10rpx; padding: 10rpx 12rpx; margin-bottom: 6rpx; }
.pending-hint { font-size: 22rpx; color: #B45309; font-weight: 600; }
.member-status-row { display: flex; flex-wrap: wrap; gap: 8rpx; margin-bottom: 6rpx; }
.member-status-tag { font-size: 18rpx; padding: 2rpx 10rpx; border-radius: 999rpx; }
.member-rejected { background: #FEE2E2; color: #DC2626; }
.respond-btn { padding: 10rpx 24rpx; border-radius: 999rpx; font-size: 22rpx; font-weight: 600; margin-left: 12rpx; }
.respond-agree { background: #10B981; color: #FFFFFF; }
.respond-reject { background: #FFFFFF; color: #DC2626; border: 2rpx solid #FCA5A5; }
onLoad(172-182 行)替换为(移除 parent 门控):
onLoad() {
this.loadFamilyId()
this.loadData()
this.loadTemplates()
this.loadMembers()
},
data 新增 familyMembers: [] 与 selectedMemberIds: [](144-163 行 form 附近)。
methods 新增:
loadMembers: function() {
var self = this
listFamilyMembers().then(function(res) {
var list = (res && res.data) || []
self.familyMembers = Array.isArray(list) ? list : []
// 默认全选
self.selectedMemberIds = []
for (var i = 0; i < self.familyMembers.length; i++) {
self.selectedMemberIds.push(self.familyMembers[i].id)
}
}).catch(function() {
self.familyMembers = []
self.selectedMemberIds = []
})
},
import 补充(139 行):加入 listFamilyMembers。
创建弹窗表单(openCreateModal/createFromTemplate)后,弹窗 modal-body 中追加参与成员多选(放在"奖励积分"之后、modal-footer 之前):
<view class="form-item">
<text class="form-label">参与成员</text>
<view class="member-picker">
<text class="member-pick-item" :class="{ active: isMemberSelected(m.id) }"
v-for="m in familyMembers" :key="m.id"
@click="toggleMember(m.id)">{{ m.nickname }}</text>
</view>
</view>
methods 新增:
isMemberSelected: function(memberId) {
return this.selectedMemberIds.indexOf(memberId) >= 0
},
toggleMember: function(memberId) {
var idx = this.selectedMemberIds.indexOf(memberId)
if (idx >= 0) {
this.selectedMemberIds.splice(idx, 1)
} else {
this.selectedMemberIds.push(memberId)
}
},
submitForm(285-328 行)的 data 构造加入 participantMemberIds(仅在创建模式传;编辑模式不传):
if (!self.editMode) {
data.participantMemberIds = self.selectedMemberIds
}
style 追加:
.member-picker { display: flex; flex-wrap: wrap; gap: 12rpx; }
.member-pick-item { padding: 8rpx 20rpx; border-radius: 999rpx; background: #F1F5F9; color: #475569; font-size: 22rpx; border: 2rpx solid transparent; }
.member-pick-item.active { background: #DCFCE7; color: #16A34A; border-color: #10B981; }
模板(108-113 行)FamilyChallengeCard 追加 @respond="onChallengeRespond":
<FamilyChallengeCard
v-for="ch in activeChallenges"
:key="ch.id"
:challenge="ch"
:showCheckin="showCheckinBtn"
@checkin="onChallengeCheckin"
@respond="onChallengeRespond" />
methods 新增(onChallengeCheckin 之后):
onChallengeRespond: function(payload) {
var self = this
respondChallenge({ challengeId: payload.challengeId, action: payload.action }).then(function(res) {
if (res && res.code === 200) {
uni.showToast({ title: payload.action === 'agree' ? '已同意' : '已拒绝', icon: 'success' })
self.loadChallenges()
} else {
uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
self.loadChallenges()
}
}).catch(function() {
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
})
},
import 补充:respondChallenge。
对修改的 4 个前端文件提取 script 块做语法校验(node --check),并全局 grep 确认无 ?. 出现在新代码中:
Run:
cd cfc-frontend && node -e "const fs=require('fs');['components/FamilyChallengeCard.vue','pages/health/challenge-manage.vue','pages/index-home/index.vue'].forEach(f=>{const s=fs.readFileSync(f,'utf8');const m=s.match(/<script>([\s\S]*?)<\/script>/);require('child_process').execSync('node --check',{input:m[1]});console.log('OK',f)})"
Expected: 3 行 OK 输出
[x] Step 6: Commit
git add cfc-frontend/utils/api.js cfc-frontend/components/FamilyChallengeCard.vue cfc-frontend/pages/health/challenge-manage.vue cfc-frontend/pages/index-home/index.vue
git commit -m "feat(challenge): 小程序同意/拒绝+参与成员多选+pending展示"
Files:
docs/superpowers/PROJECT-OVERVIEW.mdModify: docs/superpowers/plans/2026-08-08-family-challenge-participation.md(状态标记)
[x] Step 1: 后端全量编译 + 测试
Run: cd cfc-backend && mvn clean compile && mvn test -Dtest=FamilyChallengeServiceTest,ChallengeScoreServiceTest
Expected: BUILD SUCCESS + 相关测试 PASS
Run:
cd cfc-backend && grep -rn 'class AdminChallengeScoreConfigController\|class ChallengeScoreService' src/main/java --include=*.java
Expected: 各 1 处,无同名类冲突(Bean Name:adminChallengeScoreConfigController、challengeScoreService)
在 docs/superpowers/PROJECT-OVERVIEW.md 的"实施计划(plans/)"表格中追加:
| `2026-08-08-family-challenge-participation.md` | 🟡 计划中 | 家庭挑战参与机制重构(任意成员发起/可选参与/同意后开始/方向性评分) |
并将 specs 表格中 2026-08-08-family-challenge-participation-design.md 的状态列保持 ✅ 设计已确认。
[x] Step 4: 提交文档
git add docs/superpowers/PROJECT-OVERVIEW.md docs/superpowers/plans/2026-08-08-family-challenge-participation.md
git commit -m "docs: 家庭挑战参与机制实现计划"
Spec 覆盖核对:
| spec 要求 | 对应任务 |
|---|---|
| §3.1-3.3 三张新表 + status 状态机 | Task 1(迁移182-185) |
| §3.4 评分配置默认值 | Task 1 迁移184 INSERT;Task 4 DEFAULT_CONFIG 兜底 |
| §4.2 同意矩阵 | Task 4 applyAgree |
| §4.3 拒绝矩阵(含 C3 只扣沟通) | Task 4 applyReject |
| §4.4 发起矩阵 | Task 4 applyInitiate |
| §5.1 create 改造(pending+participant+发起结算) | Task 5 Step 4 |
| §5.2 respond 端点(agree/reject + 自动 active/cancelled) | Task 5 Step 3 + Task 6 Step 3 |
| §5.3 list 返回 myStatus + pending 进度 | Task 5 Step 5 + Task 6 Step 1 |
| §5.4 打卡校验(仅 agreed 参与成员) | Task 5 Step 9 |
| §5.5 结算口径(仅 agreed) | Task 5 Step 7 |
| §5.6 评分配置管理端 | Task 8 |
| §7 前端:管理页去门控/成员多选、卡片同意拒绝、api.js | Task 9 |
| §8 后端改动清单 | Task 2-8 |
| §9 验收标准 | Task 10 全量验证 |
| 存量 active 挑战兼容(迁移补 participant) | Task 1 迁移185 |
占位符扫描:无 TBD/TODO;所有代码步骤含完整实现。
类型一致性:respondChallenge(challengeId, memberId, action) 在 Service(Task 5)与 Controller(Task 6)、测试(Task 7)签名一致;getActiveChallenges(familyId, currentMemberId) 在 Service/Controller/测试一致;createChallenge(familyId, userId, memberId, params) 三处一致;ChallengeScoreService 三方法签名在 Task 4 定义、Task 5/7 消费一致;EnergyService.awardEnergyByCode 在 Task 3 定义、Task 4 消费一致。