|
@@ -0,0 +1,2165 @@
|
|
|
|
|
+# 家庭挑战参与机制实现计划(含方向性关系评分)
|
|
|
|
|
+
|
|
|
|
|
+> **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 行 `}` 之前)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 迁移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());
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: schema.sql 追加 3 张新表 DDL**
|
|
|
|
|
+
|
|
|
|
|
+在 `schema.sql` 中 `family_challenge_progress` 表定义(3425 行 `)` 之后、`health_interaction` 之前插入:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 家庭挑战参与成员表(参与机制重构:任意成员发起/可选参与/同意后开始)
|
|
|
|
|
+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='家庭挑战评分配置';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
|
|
|
|
|
+git commit -m "feat(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 实体**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.entity;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
|
|
+import lombok.Data;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.Serializable;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+
|
|
|
|
|
+@Data
|
|
|
|
|
+@TableName("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 实体**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.entity;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
|
|
+import lombok.Data;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.Serializable;
|
|
|
|
|
+import java.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 实体**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.entity;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
|
|
+import lombok.Data;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.Serializable;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+
|
|
|
|
|
+@Data
|
|
|
|
|
+@TableName("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` 完全同构):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyChallengeParticipant;
|
|
|
|
|
+
|
|
|
|
|
+public interface FamilyChallengeParticipantMapper extends BaseMapper<FamilyChallengeParticipant> {
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyRelationshipScore;
|
|
|
|
|
+
|
|
|
|
|
+public interface FamilyRelationshipScoreMapper extends BaseMapper<FamilyRelationshipScore> {
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.mapper;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyChallengeScoreConfig;
|
|
|
|
|
+
|
|
|
|
|
+public interface FamilyChallengeScoreConfigMapper extends BaseMapper<FamilyChallengeScoreConfig> {
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 5: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 发放最可靠。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 1: 在 deductEnergyByCode 后新增 awardEnergyByCode**
|
|
|
|
|
+
|
|
|
|
|
+在 `deductEnergyByCode` 方法(1373-1381 行)的 `}` 之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 发放能量 — 通过维度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)。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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` 字段之后、类闭合 `}` 之前追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /** 非表字段:当前登录成员在本挑战的参与状态(pending/agreed/rejected/null=非参与) */
|
|
|
|
|
+ @TableField(exist = false)
|
|
|
|
|
+ private String myStatus;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: FamilyChallengeService 注入新依赖**
|
|
|
|
|
+
|
|
|
|
|
+在类顶部(`pointsService` 字段之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ChallengeScoreService challengeScoreService;
|
|
|
|
|
+
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private FamilyChallengeParticipantMapper familyChallengeParticipantMapper;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并补 import:`com.etotem.cfc.entity.FamilyChallengeParticipant`、`com.etotem.cfc.mapper.FamilyChallengeParticipantMapper`、`java.util.stream.Collectors`(如无)。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: 新增 respondChallenge 方法**
|
|
|
|
|
+
|
|
|
|
|
+在 `updateProgress` 方法之前(`@Transactional public Result<Void> updateProgress` 上方)插入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 同意/拒绝挑战(参与成员动作,即时结算方向评分)。
|
|
|
|
|
+ * 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));
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: createChallenge 改 pending + participant + 发起结算**
|
|
|
|
|
+
|
|
|
|
|
+将 `createChallenge` 方法(358-422 行)整体替换为:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @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());
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 5: getActiveChallenges 包含 pending + myStatus**
|
|
|
|
|
+
|
|
|
|
|
+将 `getActiveChallenges` 方法(39-58 行)替换为:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 6: fillProgress 基于 participant(含 status 字段)**
|
|
|
|
|
+
|
|
|
|
|
+将 `fillProgress` 方法(271-316 行)中"查全部家庭成员"部分替换为基于 participant:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ 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` 变量随之删除。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 7: checkAndSettle 的 all_members 判定改为 agreed participants**
|
|
|
|
|
+
|
|
|
|
|
+在 `checkAndSettle`(182-254 行)中,将 all_members 分支的成员查询替换:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ } 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;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 8: autoCreateChallenges 补写 participant 行**
|
|
|
|
|
+
|
|
|
|
|
+在 `autoCreateChallenges` 方法(86-100 行)中,`familyChallengeMapper.insert(challenge);` 之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 系统创建:写 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);
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 9: updateProgress 新增 participant+agreed 校验**
|
|
|
|
|
+
|
|
|
|
|
+在 `updateProgress`(105-173 行)的成员归属校验(第 3 步)之后插入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 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("仅参与成员可打卡");
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 行):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @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));
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: create 去家长门控 + 传 memberId**
|
|
|
|
|
+
|
|
|
|
|
+替换 `createChallenge`(57-66 行):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @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);
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: 新增 respond 端点**
|
|
|
|
|
+
|
|
|
|
|
+在 `updateProgress` 端点之后插入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @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);
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: 检查路由重复**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+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`,无重复。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 5: 编译验证**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ 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()` 中统一补充:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
|
|
+ when(familyChallengeParticipantMapper.selectList(any())).thenReturn(java.util.Collections.emptyList());
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: 新增 respondChallenge 测试**
|
|
|
|
|
+
|
|
|
|
|
+在测试类末尾追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @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;`。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: 新建 ChallengeScoreServiceTest**
|
|
|
|
|
+
|
|
|
|
|
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/ChallengeScoreServiceTest.java`:
|
|
|
|
|
+
|
|
|
|
|
+```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;`。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: 运行测试**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn test -Dtest=FamilyChallengeServiceTest,ChallengeScoreServiceTest`
|
|
|
|
|
+Expected: 全部 PASS(若 `getOrCreateScore_shouldUseMemberBaselineWhenNoRow` 中 selectOne 连续两次 mock 相同值导致 insert 后重查仍 null 的兜底逻辑与 mock 不匹配,可简化为只断言 insert 被调用,不强求重查分支)
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 5: 全量编译**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 6: Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 封装风格):
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+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 })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: 创建管理页 ChallengeScoreConfig.vue**
|
|
|
|
|
+
|
|
|
|
|
+创建 `cfc-web/src/views/admin/ChallengeScoreConfig.vue`(参照 `EnergyBehaviorConfig.vue` 结构,简化为 3×4 矩阵编辑):
|
|
|
|
|
+
|
|
|
|
|
+```vue
|
|
|
|
|
+<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>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: 注册路由**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-web/src/router/index.js` 中 `energy-behavior-config` 路由(309-314 行)之后插入:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ {
|
|
|
|
|
+ path: 'challenge-score-config',
|
|
|
|
|
+ name: 'ChallengeScoreConfig',
|
|
|
|
|
+ component: () => import('@/views/admin/ChallengeScoreConfig.vue'),
|
|
|
|
|
+ meta: { title: '挑战评分配置', perm: 'energy' }
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: 创建后端管理控制器 AdminChallengeScoreConfigController**
|
|
|
|
|
+
|
|
|
|
|
+创建 `cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminChallengeScoreConfigController.java`(参照 `AdminEnergyBehaviorConfigController` 风格):
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+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("更新成功");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 5: 编译验证(后端)+ 前端结构校验**
|
|
|
|
|
+
|
|
|
|
|
+Run: `cd cfc-backend && mvn clean compile`
|
|
|
|
|
+Expected: BUILD SUCCESS
|
|
|
|
|
+
|
|
|
|
|
+Run(前端 script 块语法校验,从 vue 文件提取 script 后 node --check):
|
|
|
|
|
+```bash
|
|
|
|
|
+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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 数组兜底(`|| []`)。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 1: api.js 新增 respondChallenge**
|
|
|
|
|
+
|
|
|
|
|
+在 `cfc-frontend/utils/api.js` 中 `updateChallengeProgress`(1940 行)之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+export const respondChallenge = (data) => request('/api/health/challenge/respond', 'POST', data)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: FamilyChallengeCard.vue 支持 pending + 同意/拒绝 + rejected 徽标**
|
|
|
|
|
+
|
|
|
|
|
+模板(1-51 行)修改:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <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` 同元素在小程序下不推荐——改为外层包一层:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <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 行)替换为三态:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <!-- 待同意:同意/拒绝按钮(仅我的状态=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`:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ 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 追加:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+.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; }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: challenge-manage.vue 移除角色门控 + 成员多选**
|
|
|
|
|
+
|
|
|
|
|
+onLoad(172-182 行)替换为(移除 parent 门控):
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ onLoad() {
|
|
|
|
|
+ this.loadFamilyId()
|
|
|
|
|
+ this.loadData()
|
|
|
|
|
+ this.loadTemplates()
|
|
|
|
|
+ this.loadMembers()
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+data 新增 `familyMembers: []` 与 `selectedMemberIds: []`(144-163 行 form 附近)。
|
|
|
|
|
+
|
|
|
|
|
+methods 新增:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ 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 之前):
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <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 新增:
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ 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(仅在创建模式传;编辑模式不传):
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ if (!self.editMode) {
|
|
|
|
|
+ data.participantMemberIds = self.selectedMemberIds
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+style 追加:
|
|
|
|
|
+
|
|
|
|
|
+```css
|
|
|
|
|
+.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; }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: index-home/index.vue 处理 respond 事件**
|
|
|
|
|
+
|
|
|
|
|
+模板(108-113 行)FamilyChallengeCard 追加 `@respond="onChallengeRespond"`:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <FamilyChallengeCard
|
|
|
|
|
+ v-for="ch in activeChallenges"
|
|
|
|
|
+ :key="ch.id"
|
|
|
|
|
+ :challenge="ch"
|
|
|
|
|
+ :showCheckin="showCheckinBtn"
|
|
|
|
|
+ @checkin="onChallengeCheckin"
|
|
|
|
|
+ @respond="onChallengeRespond" />
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+methods 新增(`onChallengeCheckin` 之后):
|
|
|
|
|
+
|
|
|
|
|
+```javascript
|
|
|
|
|
+ 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`。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 5: 结构校验**
|
|
|
|
|
+
|
|
|
|
|
+对修改的 4 个前端文件提取 script 块做语法校验(node --check),并全局 grep 确认无 `?.` 出现在新代码中:
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+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**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 2: 路由/Bean 冲突复查**
|
|
|
|
|
+
|
|
|
|
|
+Run:
|
|
|
|
|
+```bash
|
|
|
|
|
+cd cfc-backend && grep -rn 'class AdminChallengeScoreConfigController\|class ChallengeScoreService' src/main/java --include=*.java
|
|
|
|
|
+```
|
|
|
|
|
+Expected: 各 1 处,无同名类冲突(Bean Name:`adminChallengeScoreConfigController`、`challengeScoreService`)
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 3: PROJECT-OVERVIEW.md 同步计划条目**
|
|
|
|
|
+
|
|
|
|
|
+在 `docs/superpowers/PROJECT-OVERVIEW.md` 的"实施计划(plans/)"表格中追加:
|
|
|
|
|
+
|
|
|
|
|
+```markdown
|
|
|
|
|
+| `2026-08-08-family-challenge-participation.md` | 🟡 计划中 | 家庭挑战参与机制重构(任意成员发起/可选参与/同意后开始/方向性评分) |
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并将 specs 表格中 `2026-08-08-family-challenge-participation-design.md` 的状态列保持 `✅ 设计已确认`。
|
|
|
|
|
+
|
|
|
|
|
+- [x] **Step 4: 提交文档**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+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 消费一致。
|