Quellcode durchsuchen

fix(api): 补齐接口契约缺口 9 个 NOT_FOUND 端点

前端调用但后端缺失的 9 个端点,仅在后端补路径/别名,复用既有 Service 与 DTO:

- POST /api/guide/families                委托 GuideFamilyQueryController#myFamilies
- POST /api/guide/packages/my             按 body.guideId 或当前登录规划师等级取套餐
- POST /api/rewards                       委托 RewardController#createReward
- POST /api/user/join-family              复用 FamilyJoinRequestService.createJoinRequest(inviteCode/token 兼容)
- POST /api/wishes/{id}/price             委托 WishController#setPrice
- POST /api/guide/bind/confirm            对齐 BindController#acceptBind(家庭校验 + 规划师认证闸门 + confirmBind)
- POST /api/packages                      无 body 取我的模板列表 / 有 body 走 CreatePackageDTO 创建
- POST /api/health/report/bodyfat-save    直接落库 BodyFatScaleService.saveMetrics
- POST /api/health-plan/available-teachers 新增 HealthPlanPublicController 路径别名

改动文件:
- controller/guide/GuideFamilyQueryController.java
- controller/guide/GuideController.java
- controller/reward/RewardController.java
- controller/UserController.java
- controller/wish/WishController.java
- controller/family/BindInviteController.java
- controller/TaskTemplatePackageController.java
- controller/HealthReportController.java
- controller/HealthPlanPublicController.java(新增)

不新增数据库迁移;cfc-backend mvn -DskipTests compile 通过。
Sisyphus Agent vor 2 Wochen
Ursprung
Commit
55e84632fc

+ 42 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthPlanPublicController.java

@@ -0,0 +1,42 @@
+package com.etotem.cfc.controller;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.service.HealthPlanService;
+import com.etotem.cfc.util.ParamUtils;
+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.List;
+import java.util.Map;
+
+/**
+ * 健康方案——前端契约兼容入口。
+ *
+ * 前端(cfc-frontend)实际以 POST /api/health-plan/available-teachers 拉取可用规划师,
+ * 而既有实现在 {@link HealthPlanController}(基路径 /api/health/plan)。
+ * 此处仅做路径别名,逻辑直接复用 HealthPlanService,不重复业务实现。
+ */
+@Tag(name = "健康方案-前端契约入口", description = "健康方案前端路径别名")
+@RestController
+@RequestMapping("/api/health-plan")
+public class HealthPlanPublicController {
+
+    @Resource
+    private HealthPlanService healthPlanService;
+
+    @Operation(summary = "获取可用规划师列表(路径别名,等同 /api/health/plan/available-teachers)")
+    @PostMapping("/available-teachers")
+    public Result<List<Map<String, Object>>> availableTeachers(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute(value = "familyId", required = false) Long familyId) {
+        if (familyId == null && params != null) {
+            familyId = ParamUtils.getLong(params.get("familyId"));
+        }
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+        return Result.success(healthPlanService.getAvailableTeachers(familyId));
+    }
+}

+ 19 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1113,6 +1113,25 @@ public class HealthReportController {
         return Result.success(result);
     }
 
+    /**
+     * 体脂秤指标直接入库(前端识别/人工修正后提交 {memberId, metrics:[{name,value,unit}]})
+     */
+    @Operation(summary = "保存体脂秤指标(无需上传图片)")
+    @PostMapping("/report/bodyfat-save")
+    public Result<Map<String, Object>> saveBodyFat(@RequestBody Map<String, Object> body,
+                                                   @RequestAttribute("userId") Long userId) {
+        Long memberId = ParamUtils.getLong(body.get("memberId"));
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        @SuppressWarnings("unchecked")
+        List<Map<String, Object>> metrics = (List<Map<String, Object>>) body.get("metrics");
+        if (metrics == null || metrics.isEmpty()) {
+            return Result.error("无可保存的指标");
+        }
+        return Result.success(bodyFatScaleService.saveMetrics(memberId, metrics));
+    }
+
     /**
      * 两阶段入库 Phase 1: 按 draftId 触发解析。读取已保存的文件,
      * 执行 LangGraph / 本地 Java 解析,更新草稿载荷后返回完整解析结果。

+ 21 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/TaskTemplatePackageController.java

@@ -6,6 +6,8 @@ import com.etotem.cfc.dto.CreatePackageItemDTO;
 import com.etotem.cfc.entity.TaskTemplatePackage;
 import com.etotem.cfc.entity.TaskTemplateItem;
 import com.etotem.cfc.service.TaskTemplatePackageService;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -20,9 +22,28 @@ import java.util.Map;
 @RequestMapping("/api/packages")
 public class TaskTemplatePackageController {
 
+    /** body -> DTO 转换用;未知字段忽略,避免前端多传字段导致 400 */
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
+            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
     @Resource
     private TaskTemplatePackageService packageService;
 
+    /**
+     * 根路径:无 body(或空对象)→ 取我的任务模板列表;有 body → 创建任务模板。
+     * 前端 getMyPackages() / createPackage(data) 均打 POST /api/packages。
+     */
+    @Operation(summary = "任务模板根路径(无 body 取列表 / 有 body 创建)")
+    @PostMapping
+    public Result<?> root(@RequestBody(required = false) Object body,
+                          @RequestAttribute("userId") Long userId) {
+        if (body == null || (body instanceof Map && ((Map<?, ?>) body).isEmpty())) {
+            return this.getMyPackages(userId);
+        }
+        CreatePackageDTO dto = OBJECT_MAPPER.convertValue(body, CreatePackageDTO.class);
+        return this.createPackage(userId, dto);
+    }
+
     /**
      * 创建任务模板
      */

+ 40 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/UserController.java

@@ -48,6 +48,9 @@ public class UserController {
     @Resource
     private FamilyMapper familyMapper;
 
+    @Resource
+    private com.etotem.cfc.service.FamilyJoinRequestService familyJoinRequestService;
+
     @Resource
     private FamilyMemberMapper familyMemberMapper;
 
@@ -85,6 +88,43 @@ public class UserController {
         return Result.success(user);
     }
 
+    @PostMapping("/join-family")
+    @Operation(summary = "通过邀请码/令牌申请加入家庭")
+    public Result<Map<String, Object>> joinFamily(HttpServletRequest request,
+                                                  @RequestBody(required = false) Map<String, String> body) {
+        Long userId = (Long) request.getAttribute("userId");
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+
+        String inviteCode = body != null ? body.get("inviteCode") : null;
+        String familyRole = body != null ? body.get("familyRole") : null;
+
+        // 兼容邀请令牌(token):直接透传原始 token,由服务层解析并绑定邀请人(审批人)
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            String token = body != null ? body.get("token") : null;
+            if (token != null && !token.trim().isEmpty()) {
+                inviteCode = token;
+            }
+        }
+
+        if (inviteCode == null || inviteCode.trim().isEmpty()) {
+            return Result.error("邀请码不能为空");
+        }
+
+        try {
+            com.etotem.cfc.entity.FamilyJoinRequest joinRequest =
+                familyJoinRequestService.createJoinRequest(userId, inviteCode, familyRole);
+            Family family = familyMapper.selectById(joinRequest.getTargetFamilyId());
+            Map<String, Object> result = new HashMap<>();
+            result.put("requestId", joinRequest.getId());
+            result.put("familyName", family != null ? family.getName() : "");
+            return Result.success("申请已提交,等待邀请人确认", result);
+        } catch (Exception e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
     @PostMapping("/update")
     @Operation(summary = "更新用户信息")
     public Result<Boolean> updateUser(HttpServletRequest request, @RequestBody Map<String, Object> params) {

+ 59 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/family/BindInviteController.java

@@ -4,6 +4,10 @@ import javax.annotation.Resource;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.service.BindInviteService;
+import com.etotem.cfc.service.MembershipService;
+import com.etotem.cfc.service.PlannerCertificationGuard;
+import com.etotem.cfc.service.PlannerCertificationService;
+import com.etotem.cfc.util.ParamUtils;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 
@@ -19,6 +23,12 @@ public class BindInviteController {
     @Resource
     private BindInviteService bindInviteService;
 
+    @Resource
+    private MembershipService membershipService;
+
+    @Resource
+    private PlannerCertificationGuard plannerCertificationGuard;
+
     /**
      * 生成绑定邀请
      */
@@ -39,4 +49,53 @@ public class BindInviteController {
         Map<String, Object> result = bindInviteService.validateToken(token);
         return Result.success(result);
     }
+
+    /**
+     * 家庭主账号通过邀请令牌确认绑定规划师。
+     * 语义对齐 BindController#acceptBind(/api/bind/accept):家庭校验 → 规划师认证闸门 → 落库绑定。
+     */
+    @Operation(summary = "家庭主账号确认绑定规划师(邀请令牌)")
+    @PostMapping("/confirm")
+    public Result<Boolean> confirmBind(@RequestAttribute("userId") Long userId,
+                                       @RequestBody(required = false) Map<String, Object> body) {
+        if (userId == null) {
+            return Result.error("请先登录");
+        }
+        try {
+            Long familyId = membershipService.getUserFamilyId(userId);
+            if (familyId == null) {
+                return Result.noFamily("请先创建或加入家庭");
+            }
+            Long guideId = ParamUtils.getLong(body != null ? body.get("guideId") : null);
+            if (guideId == null) {
+                return Result.error("guideId不能为空");
+            }
+            // 认证闸门:规划师(基础类别)认证通过才可开始服务(家庭绑定)
+            Result<Void> denied = plannerCertificationGuard.assertCertified(guideId,
+                    PlannerCertificationService.CATEGORY_PLANNER);
+            if (denied != null) {
+                return Result.error(denied.getCode(), denied.getMessage());
+            }
+            String inviteToken = firstNonBlank(
+                    body.get("bindToken") != null ? body.get("bindToken").toString() : null,
+                    body.get("inviteToken") != null ? body.get("inviteToken").toString() : null);
+            String serviceType = body.get("serviceType") != null ? body.get("serviceType").toString() : "consulting";
+            Long servicePrice = ParamUtils.getLong(body.get("servicePrice"));
+            boolean success = bindInviteService.confirmBind(inviteToken, guideId, familyId, serviceType,
+                    servicePrice != null ? servicePrice.intValue() : 0);
+            return Result.success(success);
+        } catch (RuntimeException e) {
+            return Result.error(400, e.getMessage());
+        }
+    }
+
+    /** 取第一个非空白字符串(bindToken / inviteToken 兼容) */
+    private static String firstNonBlank(String... values) {
+        for (String value : values) {
+            if (value != null && !value.trim().isEmpty()) {
+                return value;
+            }
+        }
+        return null;
+    }
 }

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideController.java

@@ -103,6 +103,29 @@ public class GuideController {
         return Result.success(guidePackageService.getPackagesByLevel(level));
     }
 
+    /**
+     * 路径别名:前端以 POST /api/guide/packages/my 取当前规划师可售套餐,
+     * body 可选 guideId(传则以该规划师等级为准,不传则取当前登录用户对应规划师)。
+     */
+    @PostMapping("/packages/my")
+    public Result<List<GuidePackage>> getGuidePackages(
+            @RequestBody(required = false) Map<String, Object> params,
+            @RequestAttribute("userId") Long userId) {
+        if (userId == null) {
+            return Result.error("未登录");
+        }
+        Long guideId = params != null ? ParamUtils.getLong(params.get("guideId")) : null;
+        Guide guide = guideId != null ? guideService.getById(guideId) : guideService.getByUserId(userId);
+        if (guide == null) {
+            return Result.success(java.util.Collections.emptyList());
+        }
+        String level = guide.getLevel();
+        if (level == null) {
+            return Result.success(java.util.Collections.emptyList());
+        }
+        return Result.success(guidePackageService.getPackagesByLevel(level));
+    }
+
     @PostMapping("/parent-invite/generate")
     public Result<Map<String, Object>> generateParentInvite(@RequestBody Map<String, Object> params,
         @RequestAttribute("userId") Long userId,

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/GuideFamilyQueryController.java

@@ -35,6 +35,18 @@ public class GuideFamilyQueryController {
         return Result.success(guideFamilyQueryService.listMyBoundFamilies(userId));
     }
 
+    /**
+     * 根路径别名:前端(cfc-frontend)以 POST /api/guide/families 拉取我的服务家庭列表,
+     * 与 /my-families 语义完全一致,直接委托。
+     */
+    @Operation(summary = "我的服务家庭列表(根路径别名,等同 /my-families)")
+    @PostMapping
+    public Result<List<Map<String, Object>>> rootFamilies(
+            @RequestAttribute("role") String role,
+            @RequestAttribute("userId") Long userId) {
+        return this.myFamilies(role, userId);
+    }
+
     @Operation(summary = "客户家庭成员列表")
     @PostMapping("/{familyId}/members")
     public Result<List<FamilyMember>> familyMembers(

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/reward/RewardController.java

@@ -27,6 +27,15 @@ public class RewardController {
         return Result.success(rewardId);
     }
 
+    /**
+     * 根路径别名:前端以 POST /api/rewards 创建奖励,语义等同 /create。
+     */
+    @PostMapping
+    public Result<Long> rootCreate(@RequestAttribute("userId") Long userId,
+                                   @RequestBody CreateRewardDTO dto) {
+        return this.createReward(userId, dto);
+    }
+
     @PostMapping("/wishlist")
     public Result<List<RewardDTO>> getWishlist(@RequestBody Map<String, Object> params,
                                                @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/wish/WishController.java

@@ -72,6 +72,18 @@ public class WishController {
         }
     }
 
+    /**
+     * 路径别名:前端以 POST /api/wishes/{id}/price 定价,语义等同 /{id}/set-price。
+     */
+    @Operation(summary = "家庭主账号定价(路径别名,等同 /{id}/set-price)")
+    @PostMapping("/{id}/price")
+    public Result<Boolean> priceAlias(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> body) {
+        return this.setPrice(id, userId, body);
+    }
+
     @Operation(summary = "家庭主账号拒绝心愿")
     @PostMapping("/{id}/reject")
     public Result<Boolean> rejectWish(