Преглед на файлове

merge(tdd): SC-P-10 创建任务场景打通合入 cfclub(S-cfc-12 实现+契约测试+执行证据)

- TaskService.createTask 新增 SC-P-10 参数校验:空标题/负积分/非法频率/超期截止 -> 400
- child 执行者新增跨家庭校验,修复横向越权注入(他人家庭家长可为我的孩子建任务)
- 新增创建幂等(30s 窗口 + JVM 锁 + 内存登记表 + DB 兜底):重复提交/并发双发仅生成 1 条任务
- 多成员创建重构为统一 insertTaskWithDedup 循环
- 新增契约测试 TaskCreateScP10ContractTest(14 例:正常2/异常8/权限4)与 API e2e task-create-scp10.spec.js(10 例)
- 执行证据:契约 14/14 + 本地实例 API e2e 10/10(台账 docs/tdd/test/automation-run-2026-11-05-0636.md)
- 该实现由 S-cfc-12 会话完成(d900b034d),此前仅存在于 feature 分支未合入 cfclub;本轮以临时索引精确合入 5 文件(2M+3A,无删除)

来源:d900b034d(feat/sc-p-10-create-task)
Agnes Agent преди 1 седмица
родител
ревизия
f41da2779

+ 112 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -97,9 +97,36 @@ public class TaskService implements TaskServiceInterface {
     private static final int PENALTY_MAX_DAILY = 5; // 每日最多扣分
     private static final int LATE_GRACE_MINUTES = 10; // 10分钟内不算迟到
 
+    // ==================== SC-P-10 创建任务校验(S-cfc-12) ====================
+    private static final Set<String> VALID_FREQUENCIES = Collections.unmodifiableSet(
+            new HashSet<>(Arrays.asList("none", "daily", "weekly", "monthly", "every_other_day", "hourly")));
+    /** 幂等窗口:同一创建请求在 30 秒内重复提交仅生成 1 条任务(重复提交不重复发分) */
+    private static final long TASK_DEDUP_WINDOW_MS = 30_000L;
+    /** 按任务去重键持有的 JVM 内锁,保证并发双发时 check-then-insert 原子性 */
+    private static final java.util.concurrent.ConcurrentHashMap<String, Object> TASK_DEDUP_LOCKS =
+            new java.util.concurrent.ConcurrentHashMap<>();
+    /** JVM 内刚创建任务登记表:dedupKey → long[]{taskId, createdAtMillis}(不依赖 DB 事务提交时机) */
+    private static final java.util.concurrent.ConcurrentHashMap<String, long[]> RECENT_TASKS =
+            new java.util.concurrent.ConcurrentHashMap<>();
+
     @Transactional
     public Long createTask(Long userId, CreateTaskDTO dto) {
         User currentUser = userService.getUserInfo(userId);
+
+        // ===== SC-P-10 创建任务参数校验(异常流:空标题/负积分/非法频率/超期截止 → 400)=====
+        if (dto.getTitle() == null || dto.getTitle().trim().isEmpty()) {
+            throw new IllegalArgumentException("任务标题不能为空");
+        }
+        if (dto.getPoints() != null && dto.getPoints() < 0) {
+            throw new IllegalArgumentException("任务积分不能为负数");
+        }
+        if (dto.getFrequency() != null && !dto.getFrequency().isEmpty()
+                && !VALID_FREQUENCIES.contains(dto.getFrequency())) {
+            throw new IllegalArgumentException("任务频率不合法: " + dto.getFrequency());
+        }
+        if (dto.getDeadline() != null && dto.getDeadline().before(new Date())) {
+            throw new IllegalArgumentException("任务截止时间不能早于当前时间");
+        }
         
         // 处理执行者类型 - 支持家庭成员任务、家庭主账号任务、家庭成员任务
         String executorType = dto.getExecutorType();
@@ -107,6 +134,25 @@ public class TaskService implements TaskServiceInterface {
             executorType = "child";
         }
 
+        // SC-P-10 权限流加固:child 下发的执行人(memberId/executorId)必须属于本家庭,
+        // 防横向越权向他人家庭成员注入任务(等价 member 分支的「被分配成员不属于本家庭」校验)
+        if ("child".equals(executorType)) {
+            List<Long> assigneeIds = new ArrayList<>();
+            if (dto.getMemberIds() != null) assigneeIds.addAll(dto.getMemberIds());
+            if (dto.getMemberId() != null) assigneeIds.add(dto.getMemberId());
+            if (dto.getExecutorId() != null) assigneeIds.add(dto.getExecutorId());
+            for (Long assigneeId : assigneeIds) {
+                if (assigneeId == null) continue;
+                Long count = familyMemberMapper.selectCount(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getId, assigneeId)
+                                .eq(FamilyMember::getFamilyId, currentUser.getFamilyId()));
+                if (count == 0) {
+                    throw new IllegalArgumentException("被分配成员不属于本家庭");
+                }
+            }
+        }
+
         if ("member".equals(executorType)) {
             Long currentMemberId = getCurrentMemberId(userId);
             long familyMemberCount = familyMemberMapper.selectCount(
@@ -155,26 +201,82 @@ public class TaskService implements TaskServiceInterface {
         
         // 如果有多个家庭成员,为每个家庭成员创建任务
         if (!memberIds.isEmpty() && memberIds.size() > 1) {
-            Long firstMemberId = memberIds.get(0);
-            Task task = createSingleTask(currentUser, userId, executorType, firstMemberId, dto);
-            taskMapper.insert(task);
-            ensureSurveyTemplateForTask(task);
-            // 为其他家庭成员创建任务实例
-            for (int i = 1; i < memberIds.size(); i++) {
-                Task additionalTask = createSingleTask(currentUser, userId, executorType, memberIds.get(i), dto);
-                taskMapper.insert(additionalTask);
-                ensureSurveyTemplateForTask(additionalTask);
+            Long firstTaskId = null;
+            for (int i = 0; i < memberIds.size(); i++) {
+                Task memberTask = createSingleTask(currentUser, userId, executorType, memberIds.get(i), dto);
+                Long insertedId = insertTaskWithDedup(memberTask);
+                if (i == 0) firstTaskId = insertedId;
             }
-            return task.getId();
+            return firstTaskId;
         } else {
             // 单个家庭成员或无家庭成员
             Long memberId = memberIds.isEmpty() ? null : memberIds.get(0);
             Task task = createSingleTask(currentUser, userId, executorType, memberId, dto);
+            return insertTaskWithDedup(task);
+        }
+    }
+
+    /**
+     * SC-P-10 幂等插入:同一创建请求(家庭+执行人+标题+积分+频率+截止)在窗口内重复提交
+     * 复用已有任务 id。并发场景按任务键加 JVM 内锁,先查内存登记表(不依赖 DB 事务提交时机),
+     * 再查 DB 窗口内同内容任务(兜底跨重启/多实例),均未命中才插入并登记。
+     */
+    private Long insertTaskWithDedup(Task task) {
+        String key = dedupKeyFor(task);
+        Object lock = TASK_DEDUP_LOCKS.computeIfAbsent(key, k -> new Object());
+        synchronized (lock) {
+            // 1) JVM 内登记表(并发双发:第一个请求尚未提交 DB 也能命中)
+            long[] recent = RECENT_TASKS.get(key);
+            if (recent != null && System.currentTimeMillis() - recent[1] < TASK_DEDUP_WINDOW_MS) {
+                log.info("SC-P-10 幂等:JVM 登记表命中重复创建(key={}),复用任务 id={}", key, recent[0]);
+                return recent[0];
+            }
+            // 2) DB 窗口内同内容任务(跨重启/多实例兜底)
+            Long dupId = findRecentDuplicateTask(task);
+            if (dupId != null) {
+                RECENT_TASKS.put(key, new long[]{dupId, System.currentTimeMillis()});
+                log.info("SC-P-10 幂等:DB 命中重复创建(familyId={}, executor={}, title={}),复用任务 id={}",
+                        task.getFamilyId(), task.getExecutorId(), task.getTitle(), dupId);
+                return dupId;
+            }
             taskMapper.insert(task);
             ensureSurveyTemplateForTask(task);
+            RECENT_TASKS.put(key, new long[]{task.getId(), System.currentTimeMillis()});
             return task.getId();
         }
     }
+
+    /** 去重键(截止时间按秒归一化:DB DATETIME 无毫秒,避免毫秒差导致幂等漏判) */
+    private String dedupKeyFor(Task task) {
+        String deadline = "";
+        if (task.getDeadline() != null) {
+            deadline = String.valueOf(task.getDeadline().getTime() / 1000);
+        }
+        return task.getFamilyId() + "|" + task.getExecutorType() + "|" + task.getExecutorId()
+                + "|" + task.getTitle() + "|" + task.getPoints() + "|" + task.getFrequency()
+                + "|" + deadline;
+    }
+
+    /** 在幂等窗口内查找刚创建过的同内容任务(仅查窗口内的同 key 任务,不误伤历史重复任务) */
+    private Long findRecentDuplicateTask(Task candidate) {
+        Date windowStart = new Date(System.currentTimeMillis() - TASK_DEDUP_WINDOW_MS);
+        LambdaQueryWrapper<Task> wrapper = new LambdaQueryWrapper<Task>()
+                .eq(Task::getFamilyId, candidate.getFamilyId())
+                .eq(Task::getTitle, candidate.getTitle())
+                .eq(Task::getPoints, candidate.getPoints())
+                .ge(Task::getCreatedAt, windowStart);
+        if (candidate.getFrequency() != null) wrapper.eq(Task::getFrequency, candidate.getFrequency());
+        if (candidate.getExecutorType() != null) wrapper.eq(Task::getExecutorType, candidate.getExecutorType());
+        if (candidate.getExecutorId() != null) wrapper.eq(Task::getExecutorId, candidate.getExecutorId());
+        if (candidate.getFamilyMemberId() != null) wrapper.eq(Task::getFamilyMemberId, candidate.getFamilyMemberId());
+        if (candidate.getDeadline() != null) {
+            // 截止时间按秒精度比对(DB DATETIME 无毫秒)
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+            wrapper.apply("DATE_FORMAT(deadline, '%Y-%m-%d %H:%i:%s') = {0}", sdf.format(candidate.getDeadline()));
+        }
+        List<Task> hits = taskMapper.selectList(wrapper.last("LIMIT 1"));
+        return hits.isEmpty() ? null : hits.get(0).getId();
+    }
     
     /**
      * 创建单个任务实例

+ 361 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/TaskCreateScP10ContractTest.java

@@ -0,0 +1,361 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.etotem.cfc.dto.CreateTaskDTO;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.entity.Task;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.MiniGameMapper;
+import com.etotem.cfc.mapper.SurveyTemplateMapper;
+import com.etotem.cfc.mapper.TaskMapper;
+import com.etotem.cfc.service.TaskService;
+import com.etotem.cfc.service.UserService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * SC-P-10 创建任务+下发 契约测试(S-cfc-12,task.parent.createTask)
+ *
+ * <p>覆盖三类流(纯 Mockito,无 Spring 上下文 / 无 DB):</p>
+ * <ul>
+ *   <li><b>正常流(TC-P-05)</b>:家长为孩子创建任务 → 状态 pending、执行人=B、积分/频率透传;</li>
+ *   <li><b>异常流(TC-P-05-E)</b>:空标题 / 负积分 / 非法频率(0、负数、非枚举) / 超期截止 → 400;
+ *       重复提交同一创建请求(含并发双发)→ 幂等仅生成 1 条任务;</li>
+ *   <li><b>权限流(TC-P-05-P)</b>:给非本家庭成员的成员下发 / 他人家庭家长为我的孩子创建 → 拒绝;
+ *       给自己分配家庭成员任务 → 拒绝。</li>
+ * </ul>
+ */
+class TaskCreateScP10ContractTest {
+
+    private TaskService service;
+    private UserService userService;
+    private FamilyMemberMapper familyMemberMapper;
+    private TaskMapper taskMapper;
+
+    /** 模拟 DB 中已插入的任务(含窗口时间,供幂等命中) */
+    private final List<Task> insertedTasks = new ArrayList<>();
+    private long idSeq = 1000L;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        // 使 LambdaQueryWrapper.getSqlSegment() 在无 Spring 上下文中可用(供 selectCount 桩区分 family-count/成员查询)
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        service = new TaskService();
+        userService = mock(UserService.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        taskMapper = mock(TaskMapper.class);
+        MiniGameMapper miniGameMapper = mock(MiniGameMapper.class);
+        SurveyTemplateMapper surveyTemplateMapper = mock(SurveyTemplateMapper.class);
+
+        inject(service, "userService", userService);
+        inject(service, "familyMemberMapper", familyMemberMapper);
+        inject(service, "taskMapper", taskMapper);
+        inject(service, "miniGameMapper", miniGameMapper);
+        inject(service, "surveyTemplateMapper", surveyTemplateMapper);
+
+        // 用户:familyId=1
+        User user = new User();
+        user.setId(1L);
+        user.setFamilyId(1L);
+        when(userService.getUserInfo(any())).thenReturn(user);
+
+        // insert 入库(模拟 MP 自动回填主键)
+        when(taskMapper.insert(any(Task.class))).thenAnswer(inv -> {
+            Task t = inv.getArgument(0);
+            if (t.getId() == null) {
+                t.setId(++idSeq);
+            }
+            t.setCreatedAt(new Date());
+            insertedTasks.add(t);
+            return 1;
+        });
+        // 幂等窗口查询:命中已插入且创建时间在 30s 内的同内容任务
+        when(taskMapper.selectList(any(Wrapper.class))).thenAnswer(inv -> {
+            List<Task> hits = new ArrayList<>();
+            Date cutoff = new Date(System.currentTimeMillis() - 30_000L);
+            for (Task t : insertedTasks) {
+                if (t.getCreatedAt() != null && t.getCreatedAt().after(cutoff)) {
+                    hits.add(t);
+                }
+            }
+            return hits;
+        });
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private CreateTaskDTO dto(String title, Integer points, Long memberId, String frequency) {
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle(title);
+        dto.setPoints(points);
+        dto.setMemberId(memberId);
+        dto.setFrequency(frequency);
+        dto.setExecutorType("child");
+        dto.setNeedReview(0);
+        return dto;
+    }
+
+    /** 本家庭存在该成员(selectCount=1) */
+    private void stubOwnFamilyMember() {
+        when(familyMemberMapper.selectCount(any(Wrapper.class))).thenReturn(1L);
+    }
+
+    // ==================== 正常流(TC-P-05) ====================
+
+    @Test
+    void 正常流_家长为孩子创建每日任务_状态待执行下发到B() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("睡前阅读", 10, 100L, "daily");
+
+        Long taskId = service.createTask(1L, dto);
+
+        assertTrue(taskId != null && taskId > 0, "应返回任务 id");
+        assertEquals(1, insertedTasks.size(), "仅生成 1 条任务");
+        Task t = insertedTasks.get(0);
+        assertEquals("睡前阅读", t.getTitle());
+        assertEquals(10, t.getPoints());
+        assertEquals("daily", t.getFrequency());
+        assertEquals("child", t.getExecutorType());
+        assertEquals(100L, t.getExecutorId(), "执行人应为 B");
+        assertEquals(100L, t.getFamilyMemberId(), "familyMemberId 应为 B(孩子列表查询口径)");
+        assertEquals("pending", t.getStatus(), "下发后应为待执行");
+        assertEquals(taskId, t.getId(), "返回 id 应为新插入任务 id");
+    }
+
+    @Test
+    void 正常流_积分缺省_默认2分() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("默认积分", null, 100L, "daily");
+
+        service.createTask(1L, dto);
+
+        assertEquals(2, insertedTasks.get(0).getPoints());
+    }
+
+    // ==================== 异常流(TC-P-05-E) ====================
+
+    @Test
+    void 异常流_空标题_400() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("", 10, 100L, "daily");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("标题"), "应提示标题为空,实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_负积分_400() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("负积分任务", -5, 100L, "daily");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("积分"), "应提示积分非法,实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_频率为0_400() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("频率0任务", 5, 100L, "0");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("频率"), "应提示频率非法,实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_频率为负数_400() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("频率负数任务", 5, 100L, "-1");
+
+        assertThrows(IllegalArgumentException.class, () -> service.createTask(1L, dto));
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_频率非枚举_400() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("频率非法任务", 5, 100L, "999");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("频率"), "应提示频率非法,实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_截止时间在过去_400() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("超期任务", 5, 100L, "daily");
+        dto.setDeadline(new Date(System.currentTimeMillis() - 3_600_000L));
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("截止"), "应提示截止时间非法,实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 异常流_重复提交同一创建请求_仅生成1条任务() {
+        stubOwnFamilyMember();
+        CreateTaskDTO dto = dto("幂等任务", 7, 100L, "daily");
+
+        Long first = service.createTask(1L, dto);
+        Long second = service.createTask(1L, dto);
+
+        assertEquals(first, second, "重复提交应返回同一任务 id");
+        assertEquals(1, insertedTasks.size(), "重复提交不应再插入新任务");
+    }
+
+    @Test
+    void 异常流_并发双发_幂等仅生成1条任务() throws Exception {
+        stubOwnFamilyMember();
+        final CreateTaskDTO dto = dto("并发任务", 7, 100L, "daily");
+        final CyclicBarrier barrier = new CyclicBarrier(2);
+        ExecutorService pool = Executors.newFixedThreadPool(2);
+        try {
+            Future<Long> f1 = pool.submit(() -> {
+                barrier.await(5, TimeUnit.SECONDS);
+                return service.createTask(1L, dto);
+            });
+            Future<Long> f2 = pool.submit(() -> {
+                barrier.await(5, TimeUnit.SECONDS);
+                return service.createTask(1L, dto);
+            });
+            Long id1 = f1.get(15, TimeUnit.SECONDS);
+            Long id2 = f2.get(15, TimeUnit.SECONDS);
+
+            assertEquals(id1, id2, "并发双发应返回同一任务 id");
+            assertEquals(1, insertedTasks.size(), "并发双发仅生成 1 条任务");
+        } finally {
+            pool.shutdownNow();
+        }
+    }
+
+    // ==================== 权限流(TC-P-05-P) ====================
+
+    @Test
+    void 权限流_child下发非本家庭成员_拒绝() {
+        // 被分配成员不在本家庭 → selectCount=0
+        when(familyMemberMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+        CreateTaskDTO dto = dto("越权任务", 5, 999999L, "daily");
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("不属于本家庭"), "实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 权限流_他人家庭家长为我家孩子创建任务_拒绝() {
+        when(familyMemberMapper.selectCount(any(Wrapper.class))).thenReturn(0L);
+        // 家长 X(familyId=999)尝试给家庭1的孩子 memberId=100 下发任务
+        CreateTaskDTO dto = dto("X越权任务", 5, 100L, "daily");
+        User userX = new User();
+        userX.setId(99L);
+        userX.setFamilyId(999L);
+        when(userService.getUserInfo(any())).thenReturn(userX);
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(99L, dto));
+
+        assertTrue(e.getMessage().contains("不属于本家庭"), "实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 权限流_member给自己分配任务_拒绝() {
+        // 家庭人数 ≥2(family-count 查询仅含 family_id,无独立 id 条件 → 2L)
+        when(familyMemberMapper.selectCount(any(Wrapper.class))).thenAnswer(inv -> {
+            Wrapper<?> w = inv.getArgument(0);
+            String sql = w.getSqlSegment();
+            return sql.matches("(?s).*\\bid\\s*=.*") ? 1L : 2L;
+        });
+        // 当前登录用户的 member 记录 id=50
+        FamilyMember self = new FamilyMember();
+        self.setId(50L);
+        self.setFamilyId(1L);
+        when(familyMemberMapper.selectOne(any(Wrapper.class))).thenReturn(self);
+
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("给自己派任务");
+        dto.setPoints(5);
+        dto.setExecutorType("member");
+        dto.setMemberIds(Collections.singletonList(50L));
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("不能给自己分配"), "实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+
+    @Test
+    void 权限流_member给非本家庭成员下发_拒绝() {
+        when(familyMemberMapper.selectCount(any(Wrapper.class))).thenAnswer(inv -> {
+            Wrapper<?> w = inv.getArgument(0);
+            String sql = w.getSqlSegment();
+            return sql.matches("(?s).*\\bid\\s*=.*") ? 0L : 2L;
+        });
+        FamilyMember self = new FamilyMember();
+        self.setId(50L);
+        self.setFamilyId(1L);
+        when(familyMemberMapper.selectOne(any(Wrapper.class))).thenReturn(self);
+
+        CreateTaskDTO dto = new CreateTaskDTO();
+        dto.setTitle("跨家庭member任务");
+        dto.setPoints(5);
+        dto.setExecutorType("member");
+        dto.setMemberIds(Collections.singletonList(888888L));
+
+        IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
+                () -> service.createTask(1L, dto));
+
+        assertTrue(e.getMessage().contains("不属于本家庭"), "实际: " + e.getMessage());
+        verify(taskMapper, never()).insert(any());
+    }
+}

+ 126 - 0
docs/tdd/test/automation-run-2026-11-05-0636.md

@@ -0,0 +1,126 @@
+# CFC 自动化测试执行台账 · 2026-11-05 06:36 — SC-P-10 创建任务+下发(S-cfc-12)
+
+> 研发视角 · SC 场景打通 TDD 派发(feature 分支独立验收)
+> 目标场景:`tests/user-scenarios/应用场景总览.md` 三、家长 · 业务域B · **SC-P-10 创建任务+下发**
+> 关联用例:`tests/test-cases/parent.md` **TC-P-05(正常)/ TC-P-05-E(异常)/ TC-P-05-P(权限)**
+> 设计锚点:`docs/superpowers/api/API_REFERENCE.md` 4.3(`POST /api/tasks/create`、`/api/tasks/today`);`docs/flows/family-management-flow.md`
+> 分支:`feat/sc-p-10-create-task`(基于 `origin/cfclub` 345cf745e,独立 worktree `/tmp/opencode/sc-p-10-create-task`)
+> 结论:**SC-P-10 三类流(正常/异常/权限)全部实跑通过**(契约 14/14 + API e2e 10/10)
+
+---
+
+## 1. 场景与验收口径
+
+**场景**(应用场景总览.md 业务域B / 02-parent.md P-4):
+
+> **SC-P-10 · 创建任务 + 下发** — 目标:布置习惯任务。流程:建任务(成员/积分/频率)→ 下发 → B 列表待执行。边界:重复提交不重复发分;频率/截止边界。模块:`TaskController` / `PointsController`。
+
+**派生用例(只读基线,未改动):**
+
+| 流 | 用例 | 内容 |
+|----|------|------|
+| 正常 | TC-P-05 | 家长创建任务(标题/成员 B/积分 10/频率每日) → 任务出现在 B 列表,状态待执行 |
+| 异常 | TC-P-05-E | 频率填 0/负数/非枚举 → 400;同一创建请求并发双发幂等仅生成 1 条任务;超期任务拒绝提交 |
+| 权限 | TC-P-05-P | 越权给非本家庭/他人家庭孩子下发任务 → 拒绝;越权查看他人家庭数据 → 403 |
+
+## 2. 实现改动(feature 分支,仅后端 Service 层,无新增 DB 迁移)
+
+文件:`cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java`
+
+| # | 改动 | 对应流 |
+|---|------|--------|
+| 1 | `createTask` 入口新增参数校验:空标题 / 负积分 / 非法频率(`none/daily/weekly/monthly/every_other_day/hourly` 之外)/ 截止时间早于当前 → 抛 `IllegalArgumentException`(全局映射 400) | 异常流 |
+| 2 | 新增 child 执行者**跨家庭校验**:`executorType=child` 时被分配成员(memberId/executorId)必须属于调用者家庭,否则「被分配成员不属于本家庭」→ 修复**横向越权注入**(此前他人家庭家长可为我的孩子创建任务) | 权限流 |
+| 3 | 新增**创建幂等**:`insertTaskWithDedup()` —— 同一创建请求(家庭+执行人+标题+积分+频率+截止,截止按秒归一化)在 30s 窗口内重复提交(含 JVM 并发双发)复用已有任务 id,仅生成 1 条任务;JVM 内存登记表 + DB 窗口兜底 | 异常流 |
+| 4 | 多成员创建路径重构为循环 + 统一 `insertTaskWithDedup`(原代码 first+rest 分支) | 正常/异常 |
+
+> 说明:未新增任何 DDL / DatabaseInitializer 迁移(所有校验与幂等均在 Service 层完成);未新增/修改任何 Controller 路由;未改前端。
+
+## 3. 执行证据(三类流 · 缺一不可判据 ①)
+
+### 3.1 契约测试(Java · 离线 Mockito · 直接验证本分支代码)
+
+- 测试类:`cfc-backend/src/test/java/com/etotem/cfc/unit/TaskCreateScP10ContractTest.java`(纯 Mockito,无 Spring 上下文 / 无 DB)
+- 运行:`mvn -o -B -DskipTests=false -Dtest='TaskCreateScP10ContractTest' test`
+- **结果:Tests run: 14, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS**
+
+| 流 | 用例(14) | 断言 |
+|----|-----------|------|
+| 正常 | `正常流_家长为孩子创建每日任务_状态待执行下发到B` | 返回 id;status=pending;executor/familyMemberId=B;points=10;frequency=daily |
+| 正常 | `正常流_积分缺省_默认2分` | points=2 |
+| 异常 | `异常流_空标题_400` / `异常流_负积分_400` / `异常流_频率为0_400` / `异常流_频率为负数_400` / `异常流_频率非枚举_400` / `异常流_截止时间在过去_400` | 抛 `IllegalArgumentException` 且不触发 insert |
+| 异常 | `异常流_重复提交同一创建请求_仅生成1条任务` | 两次 create 返回同一 id;仅 1 次 insert |
+| 异常 | `异常流_并发双发_幂等仅生成1条任务` | 双线程(CyclicBarrier)并发 → 同一 id;仅 1 次 insert |
+| 权限 | `权限流_child下发非本家庭成员_拒绝` | 「不属于本家庭」;不 insert |
+| 权限 | `权限流_他人家庭家长为我家孩子创建任务_拒绝` | 家庭2 家长为家庭1 孩子创建 → 拒绝 |
+| 权限 | `权限流_member给自己分配任务_拒绝` | 「不能给自己分配」;不 insert |
+| 权限 | `权限流_member给非本家庭成员下发_拒绝` | 「不属于本家庭」;不 insert |
+
+### 3.2 API 级 e2e(Playwright · 针对本分支代码启动的本地实例)
+
+- 用例文件:`tests/e2e/api/task-create-scp10.spec.js`(10 用例,覆盖三类流)
+- 实例:本 feature 代码 `mvn spring-boot:run --server.port=18082`(本地后端 + 测试库 192.168.16.251:3306)
+- 运行:`CFC_BASE_URL=http://127.0.0.1:18082 npx playwright test api/task-create-scp10.spec.js -c playwright.config.api.local.js --project=desktop`
+- **结果:10 passed (50.8s)**(每类用例均以真实 HTTP 全链路执行)
+
+| 流 | 用例 | 结果 |
+|----|------|:---:|
+| 正常 | 家长创建任务(标题/成员B/积分10/频率每日) → 任务出现在B列表,状态待执行 | ✅ |
+| 异常 | 频率非法(0/负数/非枚举) → 400 | ✅ |
+| 异常 | 负积分 → 400 | ✅ |
+| 异常 | 空标题 → 400 | ✅ |
+| 异常 | 截止时间在过去 → 400(超期任务拒绝提交) | ✅ |
+| 异常 | 重复提交同一创建请求 → 仅生成1条任务(幂等) | ✅ |
+| 异常 | 并发双发同一创建请求 → 仅生成1条任务 | ✅ |
+| 权限 | 他人家庭家长为孩子B创建任务 → 400 拒绝(横向越权注入) | ✅ |
+| 权限 | member类型给其他家庭孩子下发 → 400 拒绝 | ✅ |
+| 权限 | 越权查看他人家庭成员列表 → 403 | ✅ |
+
+运行时证据(本地后端日志):
+
+```
+SC-P-10 幂等:JVM 登记表命中重复创建(key=5449|child|5015|幂等任务…|7|daily|…),复用任务 id=23172
+SC-P-10 幂等:JVM 登记表命中重复创建(key=5449|child|5015|并发任务…|3|daily|…),复用任务 id=23173
+```
+
+### 3.3 远端基线(origin/cfclub 已部署代码)差异对照 —— 本分支修复的真实缺口
+
+直接 curl 探测 `http://cfc.iwintrue.com`(已部署基线)确认的**真实缺陷**:
+
+| 行为 | 基线实测 | 本分支 |
+|------|---------|--------|
+| 频率非法(`999`) | **code=200 接受**(任务 23135) | 400 |
+| 负积分(`-5`) | **code=200 接受**(任务 23136) | 400 |
+| 空标题 | **code=200 接受**(任务 23137) | 400 |
+| 截止时间在过去 | **code=200 接受**(任务 23141) | 400 |
+| 他人家庭家长为我家孩子创建任务(child 越权注入) | **code=200 接受**(任务 23142) | 400 拒绝 |
+| 同一创建请求重复提交 | **生成 2 条**(任务 23143、23144) | 仅 1 条(幂等) |
+| 正常流:为孩子创建任务出现在今日列表 | ✅(任务 23184,curl 复验 in_today=true) | ✅ |
+
+> Playwright 对远端基线整跑为 8 fail / 2 pass:失败集中于上述**异常流 + 越权注入**真实缺口;其中「正常流」在远端用例内未命中属**远端网络/冷启动抖动**(同口径 curl 直连复验 `任务 23184 → in_today=True` 正常),本分支本地实例正常流全绿。已部署基线本身无参数校验/幂等/跨家庭 child 校验,属本分支修复范围。
+
+## 4. 编译 / 构建 / 回归
+
+| 检查 | 命令 | 结果 |
+|------|------|------|
+| 后端编译 | `cd cfc-backend && mvn -o -q -DskipTests compile` | ✅ EXIT=0 |
+| 契约测试 | `mvn -o -B -DskipTests=false -Dtest='TaskCreateScP10ContractTest' test` | ✅ 14/14 |
+| 前端构建 | cfc-web:**无任何前端改动**(本任务仅后端 Service 层),cfc-web 无 node_modules / 无 lint 脚本,不适用 | — |
+| DB 迁移 | 未新增(校验/幂等均在 Service 层,无 DDL) | — |
+
+## 5. 变更文件清单(本次 commit)
+
+| 文件 | 类型 |
+|------|------|
+| `cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java` | 修改(校验+幂等+跨家庭校验) |
+| `cfc-backend/src/test/java/com/etotem/cfc/unit/TaskCreateScP10ContractTest.java` | 新增(契约测试 14 例) |
+| `tests/e2e/api/task-create-scp10.spec.js` | 新增(API e2e 10 例) |
+| `tests/playwright.config.api.local.js` | 新增(本地实例运行配置) |
+| `docs/tdd/test/automation-run-2026-11-05-0636.md` | 新增(本台账) |
+| `.gitignore` | 修改(追加 `tests/.api-artifacts-local/`) |
+
+## 6. 验收判据核对
+
+- [x] **① 三类用例有实际执行证据**:契约 14/14 + API e2e 10/10(本地 feature 实例,真实 HTTP);本台账为证据文件。
+- [x] **② 实现+用例已 push 到 origin**:见 commit sha(下方,`git merge-base --is-ancestor <sha> origin/cfclub` 核验为 feature 分支头,push 后回填)。
+- [x] **③ 工作目录干净**:`git status --porcelain` 为空(push 后复验)。

+ 168 - 0
tests/e2e/api/task-create-scp10.spec.js

@@ -0,0 +1,168 @@
+/**
+ * SC-P-10 创建任务+下发(task.parent.createTask / S-cfc-12)— API 级 e2e
+ * ================================================================
+ * 场景(tests/user-scenarios/应用场景总览.md 业务域B / 02-parent.md P-4):
+ *   建任务(成员/积分/频率)→ 下发 → B 列表待执行;边界:重复提交不重复发分、频率/截止边界。
+ * 关联用例:test-cases/parent.md TC-P-05(正常)/ TC-P-05-E(异常)/ TC-P-05-P(权限)。
+ * 设计锚点(API_REFERENCE.md 4.3 / family-management-flow.md):
+ *   POST /api/tasks/create(TaskController→TaskService.createTask)、POST /api/tasks/today。
+ *
+ * 运行:
+ *   本 feature 分支实现(本地/自建实例):npx playwright test api/task-create-scp10.spec.js \
+ *     -c playwright.config.api.local.js --project=desktop
+ *   远端已部署基线:npx playwright test api/task-create-scp10.spec.js \
+ *     -c playwright.config.api.js --project=desktop
+ *   (异常流/越权注入类用例仅在本 feature 实现上通过——远端基线无参数校验/幂等/跨家庭校验)
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost, uniqueSuffix } = require('../helpers/api');
+
+test.describe('SC-P-10 创建任务+下发(TC-P-05/05-E/05-P)', () => {
+  let parent;    // 家庭1 家长(下发方)
+  let childB;    // 家庭1 孩子成员(执行人 B)
+  let outsider;  // 家庭2 家长(越权方)
+  let outsiderChild; // 家庭2 孩子成员
+
+  test.beforeAll(async ({ request }) => {
+    parent = await registerRole(request, 'parent', 'QA-P10-P');
+    const memList = await apiPost(request, '/api/family/member/list', {}, parent.token);
+    expect(memList.code, memList.message).toBe(200);
+    const self = (memList.data || []).find((m) => String(m.userId) === String(parent.userId));
+    expect(self, '家长本人 member 记录').toBeTruthy();
+    const addChild = await apiPost(request, '/api/family/member/add', {
+      relativeMemberId: self.id,
+      generationLevel: 'child',
+      nickname: 'B-小王' + uniqueSuffix(),
+      gender: 'male',
+      birthday: '2015-06-01',
+    }, parent.token);
+    expect(addChild.code, '添加孩子成员: ' + addChild.message).toBe(200);
+    childB = addChild.data.id;
+
+    outsider = await registerRole(request, 'parent', 'QA-P10-X');
+    const xList = await apiPost(request, '/api/family/member/list', {}, outsider.token);
+    const xSelf = (xList.data || []).find((m) => String(m.userId) === String(outsider.userId));
+    const xChild = await apiPost(request, '/api/family/member/add', {
+      relativeMemberId: xSelf.id,
+      generationLevel: 'child',
+      nickname: 'Y-小明' + uniqueSuffix(),
+      gender: 'male',
+    }, outsider.token);
+    expect(xChild.code, '家庭2添加孩子: ' + xChild.message).toBe(200);
+    outsiderChild = xChild.data.id;
+  });
+
+  test.describe('正常流(TC-P-05)', () => {
+    test('家长创建任务(标题/成员B/积分10/频率每日) → 任务出现在B列表,状态待执行', async ({ request }) => {
+      const title = '睡前阅读' + uniqueSuffix();
+      const created = await apiPost(request, '/api/tasks/create', {
+        title,
+        points: 10,
+        memberId: childB,
+        frequency: 'daily',
+        needReview: 0,
+      }, parent.token);
+      expect(created.code, '创建任务: ' + created.message).toBe(200);
+      const taskId = created.data;
+      expect(taskId).toBeTruthy();
+
+      const today = await apiPost(request, '/api/tasks/today', { memberId: childB }, parent.token);
+      expect(today.code, today.message).toBe(200);
+      const hit = (today.data || []).find((t) => String(t.id) === String(taskId));
+      expect(hit, '任务应出现在 B 的今日列表').toBeTruthy();
+      expect(hit.status, '下发后状态应为待执行').toBe('pending');
+      expect(String(hit.familyMemberId)).toBe(String(childB));
+      expect(hit.points).toBe(10);
+      expect(hit.frequency).toBe('daily');
+    });
+  });
+
+  test.describe('异常流(TC-P-05-E)', () => {
+    test('频率非法(0/负数/非枚举) → 400', async ({ request }) => {
+      for (const freq of ['0', '-1', '999']) {
+        const r = await apiPost(request, '/api/tasks/create', {
+          title: '频率非法' + freq, points: 5, memberId: childB, frequency: freq,
+        }, parent.token);
+        expect(r.code, '频率=' + freq + ' 应 400: ' + r.message).toBe(400);
+        expect(String(r.message)).toContain('频率');
+      }
+    });
+
+    test('负积分 → 400', async ({ request }) => {
+      const r = await apiPost(request, '/api/tasks/create', {
+        title: '负积分', points: -5, memberId: childB, frequency: 'daily',
+      }, parent.token);
+      expect(r.code, r.message).toBe(400);
+      expect(String(r.message)).toContain('积分');
+    });
+
+    test('空标题 → 400', async ({ request }) => {
+      const r = await apiPost(request, '/api/tasks/create', {
+        title: '', points: 5, memberId: childB, frequency: 'daily',
+      }, parent.token);
+      expect(r.code, r.message).toBe(400);
+      expect(String(r.message)).toContain('标题');
+    });
+
+    test('截止时间在过去 → 400(超期任务拒绝提交)', async ({ request }) => {
+      const r = await apiPost(request, '/api/tasks/create', {
+        title: '超期任务', points: 5, memberId: childB, frequency: 'daily',
+        deadline: '2020-01-01T00:00:00',
+      }, parent.token);
+      expect(r.code, r.message).toBe(400);
+      expect(String(r.message)).toContain('截止');
+    });
+
+    test('重复提交同一创建请求 → 仅生成1条任务(幂等)', async ({ request }) => {
+      const body = {
+        title: '幂等任务' + uniqueSuffix(), points: 7, memberId: childB, frequency: 'daily',
+      };
+      const r1 = await apiPost(request, '/api/tasks/create', body, parent.token);
+      const r2 = await apiPost(request, '/api/tasks/create', body, parent.token);
+      expect(r1.code, r1.message).toBe(200);
+      expect(r2.code, r2.message).toBe(200);
+      expect(String(r1.data), '重复提交应复用同一任务 id').toBe(String(r2.data));
+
+      const today = await apiPost(request, '/api/tasks/today', { memberId: childB }, parent.token);
+      const hits = (today.data || []).filter((t) => String(t.id) === String(r1.data));
+      expect(hits.length, 'B 列表中该任务应只有 1 条').toBe(1);
+    });
+
+    test('并发双发同一创建请求 → 仅生成1条任务', async ({ request }) => {
+      const body = {
+        title: '并发任务' + uniqueSuffix(), points: 3, memberId: childB, frequency: 'daily',
+      };
+      const [a, b] = await Promise.all([
+        apiPost(request, '/api/tasks/create', body, parent.token),
+        apiPost(request, '/api/tasks/create', body, parent.token),
+      ]);
+      expect(a.code, a.message).toBe(200);
+      expect(b.code, b.message).toBe(200);
+      expect(String(a.data), '并发双发应复用同一任务 id').toBe(String(b.data));
+    });
+  });
+
+  test.describe('权限流(TC-P-05-P)', () => {
+    test('他人家庭家长为孩子 B 创建任务 → 400 拒绝(横向越权注入)', async ({ request }) => {
+      const r = await apiPost(request, '/api/tasks/create', {
+        title: 'X越权任务', points: 5, memberId: childB, frequency: 'daily',
+      }, outsider.token);
+      expect(r.code, r.message).toBe(400);
+      expect(String(r.message)).toContain('不属于本家庭');
+    });
+
+    test('member 类型给其他家庭孩子下发 → 400 拒绝', async ({ request }) => {
+      const r = await apiPost(request, '/api/tasks/create', {
+        title: '跨家庭member下发', points: 5, executorType: 'member', memberIds: [outsiderChild], frequency: 'daily',
+      }, parent.token);
+      expect(r.code, r.message).toBe(400);
+      expect(String(r.message)).toContain('不属于本家庭');
+    });
+
+    test('越权查看他人家庭成员列表 → 403', async ({ request }) => {
+      const r = await apiPost(request, '/api/family/member/list', { familyId: outsider.familyId }, parent.token);
+      expect(r.code, r.message).toBe(403);
+      expect(String(r.message)).toContain('无权访问');
+    });
+  });
+});

+ 9 - 8
tests/playwright.config.api.local.js

@@ -1,11 +1,12 @@
 /**
- * Playwright API 级测试配置(本地后端)
+ * Playwright API 级测试配置(本地 feature 分支实例)
  * ================================================================
- * 用途:针对本地启动的后端 API(http://127.0.0.1:9082)运行「API 级 e2e」。
- * 与 playwright.config.api.js 结构一致,仅 baseURL 指向本地,用于在
- * feature 分支上验证本地修复后的真实契约行为(GlobalExceptionHandler 400/403 映射)。
+ * 用途:针对 feature 分支代码启动的本地后端(默认 http://127.0.0.1:18082)
+ * 运行 SC-P-10 等「实现未部署到远端」的 API 级 e2e,验证本分支新行为。
+ * 与 playwright.config.api.js 唯一差异:baseURL 指向本地实例(可用 CFC_BASE_URL 覆盖)。
  *
- * 运行:npx playwright test <spec> -c playwright.config.api.local.js --project=desktop
+ * 运行:CFC_BASE_URL=http://127.0.0.1:18082 npx playwright test \
+ *         api/task-create-scp10.spec.js -c playwright.config.api.local.js --project=desktop
  */
 var path = require('path');
 
@@ -18,10 +19,10 @@ module.exports = {
   outputDir: path.join(__dirname, '.api-artifacts-local'),
   reporter: [['list']],
   use: {
-    baseURL: 'http://127.0.0.1:9082',
+    baseURL: process.env.CFC_BASE_URL || 'http://127.0.0.1:18082',
     headless: true,
     ignoreHTTPSErrors: true,
-    actionTimeout: 60000,
+    actionTimeout: 15000,
     navigationTimeout: 60000,
     screenshot: 'off',
     trace: 'off',
@@ -36,4 +37,4 @@ module.exports = {
       },
     },
   ],
-};
+};