Просмотр исходного кода

feat(backend): 规划师营养师代上传健康报告

E2E Test Bot 1 месяц назад
Родитель
Сommit
814b3808f2

+ 5 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -8107,6 +8107,11 @@ private void runMigration100() {
 			log.info("已创建diet_record_items表");
 		} catch (Exception e) {
 			log.warn("创建 diet_record_items 表失败(可能已存在): " + e.getMessage());
+
+		// 迁移178: health_reports 添加代上传字段(规划师/营养师代用户提交报告)
+		ensureColumn("health_reports", "proxy_uploader_id", "BIGINT COMMENT '代上传人ID(规划师/营养师)'");
+		ensureColumn("health_reports", "proxy_upload_note", "TEXT COMMENT '代上传备注'");
+		ensureColumn("health_reports", "ai_analysis", "TEXT COMMENT 'AI解读结果JSON'");
 		}
 	}
 }

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

@@ -20,8 +20,10 @@ import com.etotem.cfc.entity.HealthReport;
 import com.etotem.cfc.entity.HealthReportDraft;
 import com.etotem.cfc.entity.NutritionDeficiencyRecord;
 import com.etotem.cfc.entity.NutritionIndicatorMapping;
+import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.HealthReportDraftMapper;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.FamilyMemberNutritionProfileService;
 import com.etotem.cfc.service.DimensionScoreService;
 import com.etotem.cfc.service.FoodRecommendService;
@@ -127,6 +129,9 @@ public class HealthReportController {
     @Resource
     private WechatServiceInterface wechatService;
 
+    @Resource
+    private UserMapper userMapper;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -1560,4 +1565,189 @@ public class HealthReportController {
         log.info("报告文件已保存: {}", relativePath);
         return relativePath;
     }
+
+    /**
+     * 检查是否为授权代上传角色
+     */
+    private boolean isProxyUploader(String role, List<String> roles, User user) {
+        if ("teacher".equals(role)) return true;
+        if (roles != null && roles.contains("teacher")) return true;
+        if (user != null && "approved".equals(user.getNutritionistStatus())) return true;
+        if (user != null && "planner".equals(user.getVendorType()) && "approved".equals(user.getVendorStatus())) return true;
+        return false;
+    }
+
+    /**
+     * 规划师/营养师代用户上传报告(预览模式,仅解析不入库)
+     */
+    @Operation(summary = "代上传报告(预览)")
+    @PostMapping("/report/proxy-parse")
+    public Result<Map<String, Object>> proxyParsePreview(
+            @RequestParam("file") MultipartFile file,
+            @RequestParam(value = "familyId", required = false) Long familyId,
+            @RequestParam(value = "subjectId", required = false) Long subjectId,
+            @RequestParam(value = "reportType", defaultValue = "auto") String reportType,
+            @RequestParam(value = "note", required = false) String note,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("roles") List<String> roles,
+            HttpServletRequest request) {
+
+        if (file.isEmpty()) {
+            return Result.error("文件不能为空");
+        }
+
+        User user = userMapper.selectById(userId);
+        if (!isProxyUploader(role, roles, user)) {
+            return Result.error("无权限代上传报告,仅规划师/营养师可操作");
+        }
+
+        if (familyId == null) {
+            return Result.error("familyId不能为空");
+        }
+
+        try {
+            String absolutePath = saveUploadFileForProxy(file, userId);
+
+            // 调用 LangGraph 解析
+            Map<String, Object> result = callLangGraphParse(absolutePath, userId, familyId, subjectId);
+
+            if (result == null) {
+                return Result.error("报告解析失败");
+            }
+
+            // 保存草稿
+            Long draftId = (Long) result.get("draftId");
+            if (draftId != null && note != null) {
+                healthReportDraftService.updateNote(draftId, note);
+            }
+
+            result.put("proxyUploaderId", userId);
+            result.put("proxyUploadNote", note);
+            return Result.success(result);
+
+        } catch (Exception e) {
+            log.error("代上传解析失败", e);
+            return Result.error("解析失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 规划师/营养师代确认报告草稿(入库)
+     */
+    @Operation(summary = "代确认报告(入库)")
+    @PostMapping("/report/proxy-confirm")
+    public Result<Map<String, Object>> proxyConfirm(
+            @RequestBody Map<String, Object> params,
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("role") String role,
+            @RequestAttribute("roles") List<String> roles) {
+
+        User user = userMapper.selectById(userId);
+        if (!isProxyUploader(role, roles, user)) {
+            return Result.error("无权限");
+        }
+
+        Long draftId = params.get("draftId") != null
+                ? Long.valueOf(params.get("draftId").toString()) : null;
+        Long subjectId = params.get("subjectId") != null
+                ? Long.valueOf(params.get("subjectId").toString()) : null;
+
+        if (draftId == null) {
+            return Result.error("draftId不能为空");
+        }
+
+        try {
+            // 代上传模式跳过草稿归属校验
+            Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId);
+            Long reportId = (Long) result.get("reportId");
+
+            // 设置代上传信息
+            if (reportId != null) {
+                healthReportService.setProxyUploader(reportId, userId);
+            }
+
+            // 触发 AI 解读
+            if (reportId != null && subjectId != null) {
+                triggerAiAnalysis(reportId, subjectId, userId);
+            }
+
+            return Result.success(result);
+        } catch (Exception e) {
+            log.error("代确认失败", e);
+            return Result.error("确认失败: " + e.getMessage());
+        }
+    }
+
+    /**
+     * 代上传场景:保存文件(用代上传人目录)
+     */
+    private String saveUploadFileForProxy(MultipartFile file, Long proxyUserId) throws IOException {
+        String uploadDir = "uploads" + File.separator + "health-reports-proxy" + File.separator + proxyUserId;
+        File dir = new File(uploadDir);
+        if (!dir.exists()) {
+            dir.mkdirs();
+        }
+        String originalFilename = file.getOriginalFilename();
+        if (originalFilename == null) originalFilename = "report.pdf";
+        String extension = originalFilename.substring(originalFilename.lastIndexOf('.'));
+        String savedFilename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + extension;
+        Path filePath = Paths.get(uploadDir, savedFilename);
+        Files.copy(file.getInputStream(), filePath);
+        return uploadDir.replace("\\", "/") + "/" + savedFilename;
+    }
+
+    /**
+     * 调用 LangGraph 解析报告
+     */
+    private Map<String, Object> callLangGraphParse(String absolutePath, Long userId, Long familyId, Long subjectId) {
+        try {
+            Map<String, Object> lgRequest = new HashMap<>();
+            lgRequest.put("file_path", absolutePath);
+            lgRequest.put("user_id", userId);
+            lgRequest.put("family_id", familyId);
+
+            ResponseEntity<Map> lgResponse = restTemplate.postForEntity(
+                    langgraphBaseUrl + "/api/v1/report/parse", lgRequest, Map.class);
+            Map<String, Object> lgBody = lgResponse.getBody();
+            if (lgBody != null && Integer.valueOf(200).equals(lgBody.get("code"))) {
+                Map<String, Object> lgData = (Map<String, Object>) lgBody.get("data");
+                if (lgData != null) {
+                    return lgData;
+                }
+            }
+        } catch (Exception e) {
+            log.warn("LangGraph 解析失败,回退: {}", e.getMessage());
+        }
+        return null;
+    }
+
+    /**
+     * 触发 AI 解读
+     */
+    private void triggerAiAnalysis(Long reportId, Long subjectId, Long proxyUploaderId) {
+        try {
+            Map<String, Object> analyzeReq = new HashMap<>();
+            analyzeReq.put("report_id", reportId);
+            analyzeReq.put("user_id", subjectId);
+            analyzeReq.put("focus", "代上传报告解读");
+
+            ResponseEntity<Map> resp = restTemplate.postForEntity(
+                    langgraphBaseUrl + "/api/v1/analyze", analyzeReq, Map.class);
+            if (resp.getBody() != null) {
+                Map<String, Object> body = resp.getBody();
+                String analysis = (String) body.get("analysis");
+                if (analysis != null && !analysis.isEmpty()) {
+                    Map<String, Object> aiData = new HashMap<>();
+                    aiData.put("analysis", analysis);
+                    aiData.put("traceId", body.get("trace_id"));
+                    aiData.put("proxyUploaderId", proxyUploaderId);
+                    healthReportService.setAiAnalysis(reportId, aiData);
+                    log.info("AI解读已保存: reportId={}, traceId={}", reportId, body.get("trace_id"));
+                }
+            }
+        } catch (Exception e) {
+            log.warn("AI解读触发失败: {}", e.getMessage());
+        }
+    }
 }

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthReport.java

@@ -100,6 +100,15 @@ public class HealthReport implements Serializable {
     /** 核心菌属评分 (核心菌属) */
     private Integer coreGenusScore;
 
+    /** 代上传人ID(规划师/营养师代用户上传时的操作者) */
+    private Long proxyUploaderId;
+
+    /** 代上传备注 */
+    private String proxyUploadNote;
+
+    /** AI解读结果(JSON: {analysis, recommendations, traceId}) */
+    private String aiAnalysis;
+
     private Date createdAt;
 
     private Date updatedAt;

+ 11 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportDraftService.java

@@ -100,4 +100,15 @@ public class HealthReportDraftService {
         }
         return count;
     }
+
+    /**
+     * 更新代上传备注
+     */
+    public void updateNote(Long draftId, String note) {
+        LambdaUpdateWrapper<HealthReportDraft> uw = new LambdaUpdateWrapper<>();
+        uw.eq(HealthReportDraft::getId, draftId)
+          .set(HealthReportDraft::getRemark, note)
+          .set(HealthReportDraft::getUpdatedAt, new Date());
+        draftMapper.update(null, uw);
+    }
 }

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/HealthReportService.java

@@ -1241,4 +1241,34 @@ public class HealthReportService {
             log.warn("刷新7维健康评分失败: {}", e.getMessage());
         }
     }
+
+    /**
+     * 设置代上传人信息
+     */
+    public void setProxyUploader(Long reportId, Long proxyUploaderId) {
+        HealthReport report = reportMapper.selectById(reportId);
+        if (report != null) {
+            report.setProxyUploaderId(proxyUploaderId);
+            report.setUpdatedAt(new Date());
+            reportMapper.updateById(report);
+        }
+    }
+
+    /**
+     * 设置AI解读结果
+     */
+    public void setAiAnalysis(Long reportId, Map<String, Object> aiData) {
+        try {
+            com.alibaba.fastjson.JSON json = com.alibaba.fastjson.JSON.parseObject(
+                    com.alibaba.fastjson.JSON.toJSONString(aiData));
+            HealthReport report = reportMapper.selectById(reportId);
+            if (report != null) {
+                report.setAiAnalysis(json.toJSONString());
+                report.setUpdatedAt(new Date());
+                reportMapper.updateById(report);
+            }
+        } catch (Exception e) {
+            log.warn("保存AI解读失败: {}", e.getMessage());
+        }
+    }
 }

+ 3 - 0
cfc-backend/src/main/resources/schema.sql

@@ -1698,6 +1698,9 @@ CREATE TABLE IF NOT EXISTS health_reports (
     file_url VARCHAR(500) COMMENT '报告文件URL',
     status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/archived',
     remark TEXT COMMENT '备注',
+    proxy_uploader_id BIGINT COMMENT '代上传人ID(规划师/营养师)',
+    proxy_upload_note TEXT COMMENT '代上传备注',
+    ai_analysis TEXT COMMENT 'AI解读结果JSON',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     INDEX idx_user_id (user_id),