Sfoglia il codice sorgente

feat(workbench): 方案新增/编辑(归属校验防越权)

Xiaogang Liao 2 giorni fa
parent
commit
0ac063c1bd

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminFamilyPlanController.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
+import com.etotem.cfc.service.HealthPlanService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+@Tag(name = "管理端-单家庭工作台-方案", description = "家庭维度方案新增/编辑(familyId 来自 URL,强制 admin)")
+@RestController
+@RequestMapping("/api/admin")
+public class AdminFamilyPlanController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Operation(summary = "方案新增")
+    @PostMapping("/families/{id}/plans/create")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Long> createPlan(@PathVariable Long id,
+                                   @RequestAttribute(value = "userId", required = false) Long userId,
+                                   @RequestBody FamilyPlanCreateDTO dto) {
+        try {
+            return Result.success(healthPlanService.createFamilyPlan(id, userId, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "方案编辑")
+    @PostMapping("/families/{id}/plans/update")
+    @PreAuthorize("hasRole('admin')")
+    public Result<Boolean> updatePlan(@PathVariable Long id,
+                                      @RequestBody FamilyPlanCreateDTO dto) {
+        if (dto.getPlanId() == null) {
+            return Result.error("planId不能为空");
+        }
+        try {
+            return Result.success(healthPlanService.updateFamilyPlan(dto.getPlanId(), id, dto));
+        } catch (RuntimeException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyPlanCreateDTO.java

@@ -0,0 +1,19 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+/** 单家庭工作台-方案新增/编辑入参(family_id 由服务端固定,不取自请求体) */
+@Data
+public class FamilyPlanCreateDTO {
+    private Long planId;          // update 必填(create 忽略)
+    private String planType;
+    private String memberName;
+    private String memberIds;
+    private String dimensions;
+    private String goal;
+    private String planContent;
+    private String planJson;
+    private Integer totalDays;
+    private Long teacherId;
+    private String status;        // create 缺省 active
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthPlanService.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.service;
 
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
 import com.etotem.cfc.dto.PlanApproveResultDTO;
 import com.etotem.cfc.entity.HealthPlan;
 import com.etotem.cfc.entity.Task;
@@ -142,4 +143,10 @@ public interface HealthPlanService {
      * @return {planId, taskCount: 直接+实例删除总数};方案不存在抛异常,归属不匹配返回 null
      */
     Map<String, Object> deletePlanCascade(Long planId, Long familyId);
+
+    /** 工作台-方案新增:family_id 固定来自 URL,status 缺省 active */
+    Long createFamilyPlan(Long familyId, Long adminUserId, FamilyPlanCreateDTO dto);
+
+    /** 工作台-方案编辑:方案存在且 family_id 与 URL 一致,否则拒绝 */
+    boolean updateFamilyPlan(Long planId, Long familyId, FamilyPlanCreateDTO dto);
 }

+ 43 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/HealthPlanServiceImpl.java

@@ -24,6 +24,7 @@ import com.etotem.cfc.mapper.ProductMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.dto.CartItemDTO;
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
 import com.etotem.cfc.service.AttributionAnalysisService;
 import com.etotem.cfc.service.CartService;
 import com.etotem.cfc.service.HealthPlanService;
@@ -2397,4 +2398,46 @@ public class HealthPlanServiceImpl implements HealthPlanService {
         cal.set(Calendar.MILLISECOND, 0);
         return cal.getTime();
     }
+
+    @Override
+    public Long createFamilyPlan(Long familyId, Long adminUserId, FamilyPlanCreateDTO dto) {
+        HealthPlan p = new HealthPlan();
+        p.setFamilyId(familyId);
+        p.setPlanType(dto.getPlanType());
+        p.setMemberName(dto.getMemberName());
+        p.setMemberIds(dto.getMemberIds());
+        p.setDimensions(dto.getDimensions());
+        p.setGoal(dto.getGoal());
+        p.setPlanContent(dto.getPlanContent());
+        p.setPlanJson(dto.getPlanJson());
+        p.setTotalDays(dto.getTotalDays());
+        p.setTeacherId(dto.getTeacherId());
+        p.setStatus(dto.getStatus() != null && !dto.getStatus().isEmpty() ? dto.getStatus() : "active");
+        p.setCreatedAt(new Date());
+        p.setUpdatedAt(new Date());
+        healthPlanMapper.insert(p);
+        return p.getId();
+    }
+
+    @Override
+    public boolean updateFamilyPlan(Long planId, Long familyId, FamilyPlanCreateDTO dto) {
+        HealthPlan p = healthPlanMapper.selectById(planId);
+        if (p == null) throw new RuntimeException("方案不存在");
+        if (p.getFamilyId() == null || !p.getFamilyId().equals(familyId)) {
+            throw new RuntimeException("方案不属于该家庭");
+        }
+        if (dto.getPlanType() != null) p.setPlanType(dto.getPlanType());
+        if (dto.getMemberName() != null) p.setMemberName(dto.getMemberName());
+        if (dto.getMemberIds() != null) p.setMemberIds(dto.getMemberIds());
+        if (dto.getDimensions() != null) p.setDimensions(dto.getDimensions());
+        if (dto.getGoal() != null) p.setGoal(dto.getGoal());
+        if (dto.getPlanContent() != null) p.setPlanContent(dto.getPlanContent());
+        if (dto.getPlanJson() != null) p.setPlanJson(dto.getPlanJson());
+        if (dto.getTotalDays() != null) p.setTotalDays(dto.getTotalDays());
+        if (dto.getTeacherId() != null) p.setTeacherId(dto.getTeacherId());
+        if (dto.getStatus() != null) p.setStatus(dto.getStatus());
+        p.setUpdatedAt(new Date());
+        return healthPlanMapper.updateById(p) > 0;
+    }
+
 }

+ 2 - 1
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminWorkbenchAuthContractTest.java

@@ -25,6 +25,7 @@ class AdminWorkbenchAuthContractTest {
     private static final List<Class<?>> CONTROLLERS = Arrays.asList(
             AdminFamilyWorkbenchController.class,
             AdminFamilyTaskController.class,
+            AdminFamilyPlanController.class,
             AdminHealthPlanController.class);
 
     @Test
@@ -44,6 +45,6 @@ class AdminWorkbenchAuthContractTest {
                 assertTrue(path.matches("/families/\\{.*\\}/.*"), m.getName() + " 路径必须含 {id} 变量: " + path);
             }
         }
-        assertTrue(checked >= 6, "应至少覆盖 7 个家庭维度端点,实际 " + checked);
+        assertTrue(checked >= 8, "应至少覆盖 8 个家庭维度端点,实际 " + checked);
     }
 }

+ 86 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/FamilyWorkbenchPlanWriteTest.java

@@ -0,0 +1,86 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.dto.FamilyPlanCreateDTO;
+import com.etotem.cfc.entity.HealthPlan;
+import com.etotem.cfc.mapper.HealthPlanMapper;
+import com.etotem.cfc.service.impl.HealthPlanServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+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.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 方案新增/编辑契约:create 固定 family_id/status 默认 active;update 校验归属(防跨家庭越权)。
+ */
+class FamilyWorkbenchPlanWriteTest {
+
+    @Mock private HealthPlanMapper planMapper;
+    @InjectMocks private HealthPlanServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        MockitoAnnotations.openMocks(this);
+    }
+
+    private FamilyPlanCreateDTO dto() {
+        FamilyPlanCreateDTO d = new FamilyPlanCreateDTO();
+        d.setPlanType("gut");
+        d.setMemberName("小明");
+        d.setGoal("改善肠道菌群");
+        d.setTotalDays(30);
+        return d;
+    }
+
+    @Test
+    void 创建方案_familyId固定_status默认active_返回id() {
+        when(planMapper.insert(any(HealthPlan.class))).thenAnswer(inv -> {
+            HealthPlan p = inv.getArgument(0);
+            p.setId(321L);
+            return 1;
+        });
+        Long id = service.createFamilyPlan(5L, 88L, dto());
+        ArgumentCaptor<HealthPlan> cap = ArgumentCaptor.forClass(HealthPlan.class);
+        verify(planMapper).insert(cap.capture());
+        HealthPlan p = cap.getValue();
+        assertEquals(5L, p.getFamilyId());
+        assertEquals("active", p.getStatus());
+        assertEquals("gut", p.getPlanType());
+        assertEquals("改善肠道菌群", p.getGoal());
+        assertEquals(Integer.valueOf(30), p.getTotalDays());
+        assertEquals(321L, id);
+    }
+
+    @Test
+    void 编辑方案_跨家庭_拒绝() {
+        HealthPlan other = new HealthPlan();
+        other.setId(55L);
+        other.setFamilyId(9L);
+        when(planMapper.selectById(55L)).thenReturn(other);
+        RuntimeException e = assertThrows(RuntimeException.class,
+                () -> service.updateFamilyPlan(55L, 5L, dto()));
+        assertTrue(e.getMessage().contains("方案不属于该家庭"));
+    }
+
+    @Test
+    void 编辑方案_同家庭_字段更新() {
+        HealthPlan p = new HealthPlan();
+        p.setId(55L);
+        p.setFamilyId(5L);
+        p.setGoal("旧目标");
+        when(planMapper.selectById(55L)).thenReturn(p);
+        when(planMapper.updateById(any(HealthPlan.class))).thenReturn(1);
+        boolean ok = service.updateFamilyPlan(55L, 5L, dto());
+        verify(planMapper).updateById(p);
+        assertTrue(ok);
+        assertEquals("改善肠道菌群", p.getGoal());
+    }
+}