2026-08-08-family-challenge-participation.md 89 KB

家庭挑战参与机制实现计划(含方向性关系评分)

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

Global Constraints

  • 接口统一 @PostMapping,禁止 @GetMapping/@PutMapping/@DeleteMapping
  • DI 用 @Resource,字段名必须与类型默认 Bean Name 一致
  • 响应统一 Result<T> (code/message/data)
  • 数据库迁移唯一入口:DatabaseInitializer.runMigrations();所有迁移幂等(try-catch 忽略已存在);schema.sql 必须同步新增表 DDL
  • 迁移编号递增:当前最大 迁移181 + 迁移20240709-01,本次从 迁移182 起
  • 评分矩阵(用户 2026-08-08 拍板):
    • 发起 initiate:I→每个其他参与成员 trust+0/intimacy+1/comm+1;其他成员→I 不动;能量仅发起人 +1
    • 同意 agree:X→每个其他参与成员 trust+1/intimacy+1/comm+1;反向不动;能量 X 本人 +1
    • 拒绝 reject:I→X trust-1/intimacy-1/comm-1;X→I 仅 comm+1(trust/intimacy 不动);已同意成员 A→X 仅 comm-1(C3);能量 I+A 每人 -1
    • 方向分数落点在 actor 的"出向"行(A→B 表示 A 对 B 的分数)
  • 评分配置默认值:initiate(energy+1,trust+0,intimacy+1,comm+1) / agree(全+1) / reject(energy-1,trust-1,intimacy-1,comm 幅度+1 方向代码固定);数值后台可配置,方向由代码固定
  • 方向分数不设上下限 clamp(spec 未定义;初始基线 = 该成员在 family_members 的成员级分数,缺失默认 60.00)
  • 结算/打卡口径:只统计 status=agreed 的参与成员,排除 rejected
  • Java 8 语法(无 var/switch 表达式),MyBatis-Plus LambdaQueryWrapper
  • 小程序限制:禁止可选链 ?.、禁止 CSS Grid、禁止 :key 表达式、禁止 new Date(string)(用 parseDate())
  • 验证:后端 mvn clean compile 必须通过(唯一验证方式);小程序只做语法/结构校验,禁止自行打包

File Structure

文件 动作 职责
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 修改 同步计划条目

Task 1: 数据库迁移 + schema.sql 同步

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java(runMigrations() 末尾,迁移181 之后、类闭合 } 之前)
  • Modify: 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());
		}
  • Step 2: schema.sql 追加 3 张新表 DDL

在 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='家庭挑战评分配置';
  • Step 3: 编译验证

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/方向分数/评分配置表"
    

Task 2: 后端实体 + Mapper(3 个实体 + 3 个 Mapper)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeParticipant.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyRelationshipScore.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallengeScoreConfig.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeParticipantMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyRelationshipScoreMapper.java
  • Create: cfc-backend/src/main/java/com/etotem/cfc/mapper/FamilyChallengeScoreConfigMapper.java

Interfaces:

  • 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> {
}
  • Step 5: 编译验证

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"
    

Task 3: EnergyService 新增 awardEnergyByCode

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java(deductEnergyByCode 方法之后,约 1381 行)

Interfaces:

  • Produces: 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 发放最可靠。

  • Step 1: 在 deductEnergyByCode 后新增 awardEnergyByCode

在 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)。

  • 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/EnergyService.java
    git commit -m "feat(energy): EnergyService新增awardEnergyByCode按维度code发放"
    

Task 4: ChallengeScoreService(方向分数 + 能量结算核心)

Files:

  • Create: cfc-backend/src/main/java/com/etotem/cfc/service/ChallengeScoreService.java

Interfaces:

  • Consumes: FamilyRelationshipScoreMapper / FamilyChallengeScoreConfigMapper / FamilyMemberMapper / EnergyService(awardEnergyByCode + deductEnergyByCode)
  • Produces:
    • 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方向性评分结算引擎"
    

Task 5: FamilyChallengeService 改造(create 改 pending + respondChallenge + 口径收紧)

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java
  • Modify: cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java(新增 myStatus 非表字段)

Interfaces:

  • Consumes: 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;
  • Step 2: FamilyChallengeService 注入新依赖

在类顶部(pointsService 字段之后)追加:

	@Resource
	private ChallengeScoreService challengeScoreService;

	@Resource
	private FamilyChallengeParticipantMapper familyChallengeParticipantMapper;

并补 import:com.etotem.cfc.entity.FamilyChallengeParticipant、com.etotem.cfc.mapper.FamilyChallengeParticipantMapper、java.util.stream.Collectors(如无)。

  • Step 3: 新增 respondChallenge 方法

在 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));
	}
  • Step 4: createChallenge 改 pending + participant + 发起结算

将 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());
	}
  • Step 5: getActiveChallenges 包含 pending + myStatus

将 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);
		}
	}
  • Step 6: fillProgress 基于 participant(含 status 字段)

将 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 变量随之删除。

  • Step 7: checkAndSettle 的 all_members 判定改为 agreed participants

在 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;
					}
				}
			}
		}
  • Step 8: autoCreateChallenges 补写 participant 行

在 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);
			}
  • Step 9: updateProgress 新增 participant+agreed 校验

在 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("仅参与成员可打卡");
		}
  • Step 10: 编译验证

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+打卡结算口径收紧"
    

Task 6: FamilyChallengeController 改造(respond 端点 + 去门控 + myStatus)

Files:

  • Modify: cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

Interfaces:

  • Consumes: 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));
	}
  • Step 2: create 去家长门控 + 传 memberId

替换 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);
	}
  • Step 3: 新增 respond 端点

在 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);
	}
  • Step 4: 检查路由重复

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,无重复。

  • Step 5: 编译验证

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"
    

Task 7: 后端单元测试更新与新增

Files:

  • Modify: cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
  • Create: cfc-backend/src/test/java/com/etotem/cfc/service/ChallengeScoreServiceTest.java

Interfaces:

  • 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());
	    }
  • Step 2: 新增 respondChallenge 测试

在测试类末尾追加:

	    @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;。

  • Step 3: 新建 ChallengeScoreServiceTest

创建 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;。

  • Step 4: 运行测试

Run: cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest,ChallengeScoreServiceTest Expected: 全部 PASS(若 getOrCreateScore_shouldUseMemberBaselineWhenNoRow 中 selectOne 连续两次 mock 相同值导致 insert 后重查仍 null 的兜底逻辑与 mock 不匹配,可简化为只断言 insert 被调用,不强求重查分支)

  • Step 5: 全量编译

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/评分矩阵)"
    

Task 8: cfc-web 评分配置管理页

Files:

  • Create: cfc-web/src/api/challengeScore.js
  • Create: cfc-web/src/views/admin/ChallengeScoreConfig.vue
  • Modify: cfc-web/src/router/index.js

Interfaces:

  • Consumes: 后端 POST /api/admin/challenge-score-config/list / POST /api/admin/challenge-score-config/update
  • Produces: 配置页路由 /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 })
}
  • Step 2: 创建管理页 ChallengeScoreConfig.vue

创建 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>
  • Step 3: 注册路由

在 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' }
    },
  • Step 4: 创建后端管理控制器 AdminChallengeScoreConfigController

创建 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("更新成功");
    }
}
  • Step 5: 编译验证(后端)+ 前端结构校验

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"
    

Task 9: 小程序前端(api + 卡片同意/拒绝 + 管理页成员多选)

Files:

  • Modify: cfc-frontend/utils/api.js
  • Modify: cfc-frontend/components/FamilyChallengeCard.vue
  • Modify: cfc-frontend/pages/health/challenge-manage.vue
  • Modify: cfc-frontend/pages/index-home/index.vue

小程序硬约束: 禁止 ?.、禁止 :key 表达式(用方法调用)、禁止 new Date(string)(用 parseDate)、Vue 2 Options API、prop 数组兜底(|| [])。

  • Step 1: api.js 新增 respondChallenge

在 cfc-frontend/utils/api.js 中 updateChallengeProgress(1940 行)之后追加:

export const respondChallenge = (data) => request('/api/health/challenge/respond', 'POST', data)
  • Step 2: FamilyChallengeCard.vue 支持 pending + 同意/拒绝 + rejected 徽标

模板(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; }
  • Step 3: challenge-manage.vue 移除角色门控 + 成员多选

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; }
  • Step 4: index-home/index.vue 处理 respond 事件

模板(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。

  • Step 5: 结构校验

对修改的 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展示"
    

Task 10: 全量验证 + 文档同步

Files:

  • Modify: docs/superpowers/PROJECT-OVERVIEW.md
  • Modify: 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

  • Step 2: 路由/Bean 冲突复查

Run:

cd cfc-backend && grep -rn 'class AdminChallengeScoreConfigController\|class ChallengeScoreService' src/main/java --include=*.java

Expected: 各 1 处,无同名类冲突(Bean Name:adminChallengeScoreConfigController、challengeScoreService)

  • Step 3: PROJECT-OVERVIEW.md 同步计划条目

在 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: 家庭挑战参与机制实现计划"
    

Self-Review

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 消费一致。