Przeglądaj źródła

Merge branch 'cfclub' of https://git.iwintrue.com/liaoxg/cfc into cfclub

# Conflicts:
#	cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java
Xiaogang Liao 1 miesiąc temu
rodzic
commit
a1b7a387b4

+ 20 - 7
cfc-backend/src/main/java/com/etotem/cfc/controller/FamilyChallengeController.java

@@ -17,11 +17,12 @@ public class FamilyChallengeController {
     private FamilyChallengeService familyChallengeService;
 
     @PostMapping("/list")
-    public Result<List<FamilyChallenge>> getList(@RequestAttribute(value = "familyId", required = false) Long familyId) {
+    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));
+        return Result.success(familyChallengeService.getActiveChallenges(familyId, currentMemberId));
     }
 
     @PostMapping("/history")
@@ -49,6 +50,18 @@ public class FamilyChallengeController {
         return familyChallengeService.updateProgress(challengeId, currentMemberId, delta);
     }
 
+    @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);
+    }
+
     @PostMapping("/templates")
     public Result<List<Map<String, Object>>> getTemplates() {
         return Result.success(familyChallengeService.getTemplates());
@@ -56,13 +69,13 @@ public class FamilyChallengeController {
 
     @PostMapping("/create")
     public Result<Long> createChallenge(@RequestBody Map<String, Object> params,
-                                        @RequestAttribute(value = "role", required = false) String role,
                                         @RequestAttribute(value = "familyId", required = false) Long familyId,
-                                        @RequestAttribute("userId") Long userId) {
-        if (!"parent".equals(role)) {
-            return Result.error("仅家长可创建挑战");
+                                        @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, params);
+        return familyChallengeService.createChallenge(familyId, userId, currentMemberId, params);
     }
 
     @PostMapping("/update/{id}")

+ 56 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminChallengeScoreConfigController.java

@@ -0,0 +1,56 @@
+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;
+
+@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("更新成功");
+    }
+}

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/FamilyChallenge.java

@@ -56,4 +56,8 @@ public class FamilyChallenge implements Serializable {
 
     /** 创建者ID(系统创建为NULL,用户创建为userId) */
     private Long creatorId;
+
+    /** 非表字段:当前登录成员在本挑战的参与状态(pending/agreed/rejected/null=非参与) */
+    @TableField(exist = false)
+    private String myStatus;
 }

+ 242 - 43
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyChallengeService.java

@@ -4,9 +4,11 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.FamilyChallenge;
+import com.etotem.cfc.entity.FamilyChallengeParticipant;
 import com.etotem.cfc.entity.FamilyChallengeProgress;
 import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.FamilyChallengeMapper;
+import com.etotem.cfc.mapper.FamilyChallengeParticipantMapper;
 import com.etotem.cfc.mapper.FamilyChallengeProgressMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import lombok.extern.slf4j.Slf4j;
@@ -36,17 +38,25 @@ public class FamilyChallengeService {
     @Resource
     private PointsService pointsService;
 
-    public List<FamilyChallenge> getActiveChallenges(Long familyId) {
+    @Resource
+    private ChallengeScoreService challengeScoreService;
+
+    @Resource
+    private FamilyChallengeParticipantMapper familyChallengeParticipantMapper;
+
+    public List<FamilyChallenge> getActiveChallenges(Long familyId, Long currentMemberId) {
         if (familyId == null) return new ArrayList<>();
 
         LambdaQueryWrapper<FamilyChallenge> wrapper = new LambdaQueryWrapper<FamilyChallenge>()
                 .eq(FamilyChallenge::getFamilyId, familyId)
-                .eq(FamilyChallenge::getStatus, "active")
+                .in(FamilyChallenge::getStatus, "active", "pending")
                 .orderByDesc(FamilyChallenge::getCreatedAt);
         List<FamilyChallenge> active = familyChallengeMapper.selectList(wrapper);
 
         for (FamilyChallenge c : active) {
-            if (c.getEndDate() != null && new Date().after(c.getEndDate())) {
+            // 仅 active 且过期的挑战触发结算
+            if ("active".equals(c.getStatus())
+                    && c.getEndDate() != null && new Date().after(c.getEndDate())) {
                 checkAndSettle(c);
             }
         }
@@ -54,9 +64,22 @@ public class FamilyChallengeService {
         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);
+        }
+    }
+
     @Transactional
     public void autoCreateChallenges(Long familyId) {
         Calendar cal = Calendar.getInstance();
@@ -98,10 +121,133 @@ public class FamilyChallengeService {
             challenge.setStatus("active");
             challenge.setCreatedAt(new Date());
             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);
+            }
             log.info("已自动创建挑战: familyId={}, type={}, start={}", familyId, t[0], startDate);
         }
     }
 
+    /**
+     * 同意/拒绝挑战(参与成员动作,即时结算方向评分)。
+     * 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 归属推断:保守处理,仅结算 A→X 与能量
+            if (initiatorId != null) {
+                challengeScoreService.applyReject(challenge.getFamilyId(), memberId, initiatorId, agreedMemberIds, challengeId);
+            } else {
+                // 系统创建挑战:仅已同意成员→拒绝人 扣沟通 + 能量扣减(无发起人维度)
+                challengeScoreService.applyReject(challenge.getFamilyId(), memberId, memberId, agreedMemberIds, challengeId);
+            }
+
+            // 除发起人外无剩余参与成员 → cancelled(Q6)
+            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));
+    }
+
     @Transactional
     public Result<Void> updateProgress(Long challengeId, Long childId, int delta) {
         if (challengeId == null || childId == null) {
@@ -130,6 +276,15 @@ public class FamilyChallengeService {
             return Result.error("无权为该成员打卡");
         }
 
+        // 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("仅参与成员可打卡");
+        }
+
         // 4. 查该成员在本挑战的进度
         LambdaQueryWrapper<FamilyChallengeProgress> wrapper = new LambdaQueryWrapper<FamilyChallengeProgress>()
                 .eq(FamilyChallengeProgress::getChallengeId, challengeId)
@@ -198,10 +353,14 @@ public class FamilyChallengeService {
             // 集体总合目标:全家人合计达到 target_value
             isCompleted = totalProgress >= targetValue;
         } else {
-            // 全员个人目标:家庭内每个成员都达到 target_value
-            LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
-                    .eq(FamilyMember::getFamilyId, challenge.getFamilyId());
-            List<FamilyMember> members = familyMemberMapper.selectList(mw);
+            // 全员个人目标:每个 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 {
@@ -271,9 +430,6 @@ public class FamilyChallengeService {
     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);
 
@@ -290,24 +446,21 @@ public class FamilyChallengeService {
             }
             c.setTotalProgress(total);
 
-            List<FamilyMember> members = familyMembersCache.get(c.getFamilyId());
-            if (members == null) {
-                LambdaQueryWrapper<FamilyMember> mw = new LambdaQueryWrapper<FamilyMember>()
-                        .eq(FamilyMember::getFamilyId, c.getFamilyId());
-                members = familyMemberMapper.selectList(mw);
-                familyMembersCache.put(c.getFamilyId(), members);
-            }
+            // 参与成员(participant 表,含 status),替代原"全部家庭成员"
+            List<FamilyChallengeParticipant> participants = listParticipants(c.getId());
 
             List<Map<String, Object>> memberProgress = new ArrayList<>();
-            if (members != null) {
-                for (FamilyMember m : members) {
-                    Integer v = progressMap.get(m.getId());
+            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", m.getId());
-                    mp.put("nickname", m.getNickname() != null ? m.getNickname() : "成员" + m.getId());
+                    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);
                 }
             }
@@ -356,11 +509,11 @@ public class FamilyChallengeService {
     }
 
     @Transactional
-    public Result<Long> createChallenge(Long familyId, Long creatorId, Map<String, Object> params) {
-        if (familyId == null || creatorId == null) {
-            return Result.error("familyId和creatorId不能为空");
+    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()) {
@@ -369,41 +522,70 @@ public class FamilyChallengeService {
         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;
+        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;
+        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;
+        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(creatorId);
+        challenge.setCreatorId(userId);
         challenge.setChallengeType(params.get("challengeType") != null ? params.get("challengeType").toString() : "custom");
         challenge.setTitle(title);
         challenge.setDescription(description);
@@ -413,11 +595,28 @@ public class FamilyChallengeService {
         challenge.setTargetValue(targetValue);
         challenge.setStartDate(new Date());
         challenge.setEndDate(new Date(System.currentTimeMillis() + durationDays * 24L * 60 * 60 * 1000));
-        challenge.setStatus("active");
+        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());
     }
 

+ 130 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/ChallengeScoreServiceTest.java

@@ -0,0 +1,130 @@
+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.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+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);
+        when(familyMemberMapper.selectById(100L)).thenReturn(member);
+
+        service.applyAgree(10L, 100L, Arrays.asList(100L, 200L), 1L);
+
+        verify(relationshipScoreMapper).insert(any());
+    }
+}

+ 112 - 1
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyChallengeServiceTest.java

@@ -2,8 +2,11 @@ package com.etotem.cfc.service;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.FamilyChallenge;
+import com.etotem.cfc.entity.FamilyChallengeParticipant;
 import com.etotem.cfc.entity.FamilyChallengeProgress;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.mapper.FamilyChallengeMapper;
+import com.etotem.cfc.mapper.FamilyChallengeParticipantMapper;
 import com.etotem.cfc.mapper.FamilyChallengeProgressMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
 import org.junit.jupiter.api.BeforeEach;
@@ -17,6 +20,7 @@ import java.util.Date;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.argThat;
 import static org.mockito.ArgumentMatchers.eq;
@@ -28,14 +32,17 @@ 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;
 
     @BeforeEach
     void setUp() {
         MockitoAnnotations.openMocks(this);
+        when(familyChallengeParticipantMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
     }
 
     @Test
@@ -54,9 +61,15 @@ public class FamilyChallengeServiceTest {
         existing.setProgressValue(5);
         existing.setUpdatedAt(new Date());
 
+        FamilyChallengeParticipant participant = new FamilyChallengeParticipant();
+        participant.setChallengeId(1L);
+        participant.setMemberId(100L);
+        participant.setStatus("agreed");
+
         when(familyChallengeMapper.selectById(1L)).thenReturn(challenge);
         when(familyChallengeProgressMapper.selectOne(any())).thenReturn(existing);
         when(familyMemberMapper.selectCount(any())).thenReturn(1L);
+        when(familyChallengeParticipantMapper.selectOne(any())).thenReturn(participant);
 
         Result<Void> result = service.updateProgress(1L, 100L, 1);
 
@@ -127,7 +140,7 @@ public class FamilyChallengeServiceTest {
         when(familyMemberMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
         when(familyChallengeMapper.update(any(), any())).thenReturn(1);
 
-        service.getActiveChallenges(10L);
+        service.getActiveChallenges(10L, null);
 
         verify(familyChallengeMapper).update(any(), any());
     }
@@ -156,4 +169,102 @@ public class FamilyChallengeServiceTest {
         assertEquals(Integer.valueOf(300), sports.get("targetValue"));
         assertEquals(Integer.valueOf(100), sports.get("rewardPoints"));
     }
+
+    @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");
+
+        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());
+    }
 }

+ 4 - 0
cfc-frontend/pages/diet/index.vue

@@ -129,6 +129,7 @@ export default {
       familyId: null,
       familyMemberId: null,
       ingredients: [],
+      selectedIngredients: [],
       ingredientsLoading: false,
       mealSummary: {},
       trendData: {},
@@ -191,12 +192,14 @@ export default {
       })
     },
     addIngredient(index) {
+      var self = this
       var item = this.ingredients[index]
       addDietIngredient({ food_id: item.foodId, name: item.name }).then(function(res) {
         self.loadIngredients()
       })
     },
     removeIngredient(index) {
+      var self = this
       var item = this.ingredients[index]
       removeDietIngredient({ food_id: item.foodId }).then(function(res) {
         self.loadIngredients()
@@ -268,6 +271,7 @@ export default {
       this.searchResults = []
     },
     addFood(food) {
+      var self = this
       addDietIngredient({ food_id: food.id, name: food.name }).then(function(res) {
         self.closePicker()
         self.loadIngredients()

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-84c2a6757d32d526b96946b032d59668a2af65ea
+1966ce0e17e37122520324427482caaac0517c10

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.860",
+  "version": "1.0.861",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.860",
+      "version": "1.0.861",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.861",
+  "version": "1.0.862",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 18 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,24 @@
 
 ---
 
+## v1.0.862 (2026-08-08)
+
+### Bug 修复
+- 修复 diet/index.vue 运行时错误
+
+### 其他
+- - addIngredient/removeIngredient/addFood 方法缺少 var self = this
+- 
+
+### 新功能
+- 管理端挑战评分配置页+AdminChallengeScoreConfigController
+- respond端点+创建去门控+list返回myStatus
+- create改pending+respondChallenge+打卡结算口径收紧
+
+### 测试
+- 参与机制单元测试(respond/评分矩阵)
+
+
 ## v1.0.861 (2026-08-08)
 
 ### Bug 修复

+ 19 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.861
+> 当前版本: v1.0.862
 
 ## 历史版本
 
@@ -8,6 +8,24 @@
 
 ---
 
+## v1.0.862 (2026-08-08)
+
+### Bug 修复
+- 修复 diet/index.vue 运行时错误
+
+### 其他
+- - addIngredient/removeIngredient/addFood 方法缺少 var self = this
+- 
+
+### 新功能
+- 管理端挑战评分配置页+AdminChallengeScoreConfigController
+- respond端点+创建去门控+list返回myStatus
+- create改pending+respondChallenge+打卡结算口径收紧
+
+### 测试
+- 参与机制单元测试(respond/评分矩阵)
+
+
 ## v1.0.861 (2026-08-08)
 
 ### Bug 修复

+ 9 - 0
cfc-web/src/api/challengeScore.js

@@ -0,0 +1,9 @@
+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 })
+}

+ 6 - 0
cfc-web/src/router/index.js

@@ -311,6 +311,12 @@ const routes = [
       name: 'EnergyBehaviorConfig',
       component: () => import('@/views/admin/EnergyBehaviorConfig.vue'),
       meta: { title: '能量行为配置', perm: 'energy' }
+    },
+    {
+      path: 'challenge-score-config',
+      name: 'ChallengeScoreConfig',
+      component: () => import('@/views/admin/ChallengeScoreConfig.vue'),
+      meta: { title: '挑战评分配置', perm: 'energy' }
     },
       // ========== 文章管理 ==========
       {

+ 113 - 0
cfc-web/src/views/admin/ChallengeScoreConfig.vue

@@ -0,0 +1,113 @@
+<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>