|
@@ -83,6 +83,9 @@ public class HealthReportController {
|
|
|
* TODO(部署): 容器化部署时需确保 Java 上传目录(uploads/health-reports)
|
|
* TODO(部署): 容器化部署时需确保 Java 上传目录(uploads/health-reports)
|
|
|
* 对 LangGraph 容器可见(共享 volume 或主机路径挂载),否则 file_path 不可访问将回退本地解析。
|
|
* 对 LangGraph 容器可见(共享 volume 或主机路径挂载),否则 file_path 不可访问将回退本地解析。
|
|
|
*/
|
|
*/
|
|
|
|
|
+ @Value("${upload.base-dir:/data/cfc-uploads}")
|
|
|
|
|
+ private String uploadBaseDir;
|
|
|
|
|
+
|
|
|
@Value("${langgraph.base-url:http://localhost:9000}")
|
|
@Value("${langgraph.base-url:http://localhost:9000}")
|
|
|
private String langgraphBaseUrl;
|
|
private String langgraphBaseUrl;
|
|
|
|
|
|
|
@@ -435,9 +438,220 @@ public class HealthReportController {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 两阶段入库 Phase 0: 仅上传文件,不解析。返回 draftId 和 fileUrl,
|
|
|
|
|
+ * 供前端展示「开始解析」按钮并导航至确认页触发异步解析。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Operation(summary = "仅上传报告文件(不解析)")
|
|
|
|
|
+ @PostMapping("/report/upload")
|
|
|
|
|
+ public Result<Map<String, Object>> uploadOnly(
|
|
|
|
|
+ @RequestParam("file") MultipartFile file,
|
|
|
|
|
+ @RequestParam(value = "type", defaultValue = "auto") String type,
|
|
|
|
|
+ @RequestParam(value = "familyId", required = false) Long familyId,
|
|
|
|
|
+ @RequestParam(value = "memberId", required = false) Long memberId,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
|
|
+
|
|
|
|
|
+ if ("tongue".equals(type)) {
|
|
|
|
|
+ if (memberId == null) {
|
|
|
|
|
+ return Result.error("memberId is required for tongue diagnosis");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (file.isEmpty()) {
|
|
|
|
|
+ return Result.error("文件不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String contentType = file.getContentType();
|
|
|
|
|
+ String originalFilename = file.getOriginalFilename();
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 图片类型:安全检测 + 保存
|
|
|
|
|
+ if ("image".equals(type) || isImageContent(contentType)) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ byte[] imageBytes = file.getBytes();
|
|
|
|
|
+ if (!wechatService.checkImage(imageBytes)) {
|
|
|
|
|
+ return Result.error("图片内容包含敏感信息,上传失败");
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (IOException e) {
|
|
|
|
|
+ return Result.error("图片读取失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // PDF 类型校验
|
|
|
|
|
+ String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
|
|
|
|
|
+ if (!lcName.endsWith(".pdf")
|
|
|
|
|
+ && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
|
|
|
|
|
+ return Result.error("仅支持PDF文件");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String fileUrl = saveUploadFile(file, userId);
|
|
|
|
|
+ String reportType = familyId != null && !("pdf".equals(type) || "auto".equals(type))
|
|
|
|
|
+ ? "physical_exam" : "gut_flora";
|
|
|
|
|
+
|
|
|
|
|
+ // 创建空载荷草稿(不含解析数据)
|
|
|
|
|
+ ParsedReportPayload.Payload emptyPayload = new ParsedReportPayload.Payload();
|
|
|
|
|
+ String payloadJson = objectMapper.writeValueAsString(emptyPayload);
|
|
|
|
|
+
|
|
|
|
|
+ HealthReportDraft draft = healthReportDraftService.createDraft(
|
|
|
|
|
+ userId, familyId, reportType, fileUrl, originalFilename, payloadJson);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("draftId", draft.getId());
|
|
|
|
|
+ result.put("fileUrl", fileUrl);
|
|
|
|
|
+ result.put("originalFilename", originalFilename);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (IOException e) {
|
|
|
|
|
+ log.error("文件上传失败", e);
|
|
|
|
|
+ return Result.error("文件上传失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 两阶段入库 Phase 1: 按 draftId 触发解析。读取已保存的文件,
|
|
|
|
|
+ * 执行 LangGraph / 本地 Java 解析,更新草稿载荷后返回完整解析结果。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Operation(summary = "按草稿ID触发解析")
|
|
|
|
|
+ @PostMapping("/report/parse")
|
|
|
|
|
+ public Result<Map<String, Object>> parseByDraftId(
|
|
|
|
|
+ @RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute(value = "familyId", required = false) Long familyId,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
|
|
+
|
|
|
|
|
+ Long draftId = params.get("draftId") != null
|
|
|
|
|
+ ? Long.valueOf(params.get("draftId").toString()) : null;
|
|
|
|
|
+ if (draftId == null) {
|
|
|
|
|
+ return Result.error("draftId不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ HealthReportDraft draft = healthReportDraftService.getActiveDraft(draftId);
|
|
|
|
|
+ if (draft == null) {
|
|
|
|
|
+ return Result.error("草稿不存在、已确认或已过期");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!draft.getUserId().equals(userId)) {
|
|
|
|
|
+ return Result.error("无权操作此草稿");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ String fileUrl = draft.getFileUrl();
|
|
|
|
|
+ String originalFilename = draft.getOriginalFilename();
|
|
|
|
|
+ String absolutePath = uploadBaseDir + "/" + fileUrl;
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ java.io.File localFile = new java.io.File(absolutePath);
|
|
|
|
|
+ if (!localFile.exists()) {
|
|
|
|
|
+ return Result.error("文件不存在或已过期: " + originalFilename);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 尝试 LangGraph 远程解析
|
|
|
|
|
+ try {
|
|
|
|
|
+ Map<String, Object> lgRequest = new HashMap<>();
|
|
|
|
|
+ lgRequest.put("file_path", absolutePath);
|
|
|
|
|
+ lgRequest.put("user_id", userId);
|
|
|
|
|
+ if (familyId != null) {
|
|
|
|
|
+ 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 buildParseResultFromLg(lgData, draft, familyId);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("LangGraph 解析失败,回退到本地 Java 解析: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Fallback: 本地 Java 解析
|
|
|
|
|
+ return parseLocalFallback(absolutePath, draft, familyId, userId);
|
|
|
|
|
+
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("文件解析失败", e);
|
|
|
|
|
+ return Result.error("解析失败: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 构造 LangGraph 解析结果(复用 parsePreview 中的逻辑)
|
|
|
|
|
+ */
|
|
|
|
|
+ private Result<Map<String, Object>> buildParseResultFromLg(
|
|
|
|
|
+ Map<String, Object> lgData, HealthReportDraft draft,
|
|
|
|
|
+ Long familyId) throws Exception {
|
|
|
|
|
+ ParsedReportPayload.Payload lgPayload = buildPayloadFromLgData(lgData);
|
|
|
|
|
+ String payloadJson = objectMapper.writeValueAsString(lgPayload);
|
|
|
|
|
+ healthReportDraftService.updatePayload(draft.getId(), payloadJson);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> overview = (Map<String, Object>) lgData.get("overview");
|
|
|
|
|
+ String personName = overview != null ? (String) overview.get("person_name") : null;
|
|
|
|
|
+ String gender = overview != null ? (String) overview.get("gender") : null;
|
|
|
|
|
+ Integer age = overview != null ? toInteger(overview.get("age")) : null;
|
|
|
|
|
+
|
|
|
|
|
+ MemberMatchResult matchResult = healthReportService.matchMemberByProfile(
|
|
|
|
|
+ personName, gender, age, familyId);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("draftId", draft.getId());
|
|
|
|
|
+ result.put("payload", lgPayload);
|
|
|
|
|
+ result.put("matchedMemberId", matchResult.getMatchedMemberId());
|
|
|
|
|
+ result.put("confidence", matchResult.getConfidence());
|
|
|
|
|
+ result.put("matchLabel", matchResult.getMatchLabel());
|
|
|
|
|
+ result.put("candidates", matchResult.getCandidates());
|
|
|
|
|
+ result.put("extractedName", personName);
|
|
|
|
|
+ result.put("extractedGender", gender);
|
|
|
|
|
+ result.put("extractedAge", age);
|
|
|
|
|
+ result.put("needBind", matchResult.getMatchedMemberId() == null && familyId != null);
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ saveResultToJsonFile(result);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("保存结果到 JSON 文件失败:{}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 本地 Java 解析回退
|
|
|
|
|
+ */
|
|
|
|
|
+ private Result<Map<String, Object>> parseLocalFallback(
|
|
|
|
|
+ String absolutePath, HealthReportDraft draft, Long familyId, Long userId)
|
|
|
|
|
+ throws Exception {
|
|
|
|
|
+
|
|
|
|
|
+ try (java.io.FileInputStream fis = new java.io.FileInputStream(absolutePath)) {
|
|
|
|
|
+ ParsedReportResult parsed = pdfParseService.parse(fis);
|
|
|
|
|
+ if (parsed.getOverallScore() == null && parsed.getGutHealthScore() == null) {
|
|
|
|
|
+ return Result.error("无法解析PDF文件,请确认是募极生物肠道菌群报告");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ParsedReportPayload.Payload payload = convertToPayload(parsed);
|
|
|
|
|
+ String payloadJson = objectMapper.writeValueAsString(payload);
|
|
|
|
|
+ healthReportDraftService.updatePayload(draft.getId(), payloadJson);
|
|
|
|
|
+
|
|
|
|
|
+ MemberMatchResult matchResult = healthReportService.matchMemberByProfile(
|
|
|
|
|
+ parsed.getPersonName(), parsed.getGender(), parsed.getAge(), familyId);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
+ result.put("draftId", draft.getId());
|
|
|
|
|
+ result.put("payload", payload);
|
|
|
|
|
+ result.put("matchedMemberId", matchResult.getMatchedMemberId());
|
|
|
|
|
+ result.put("confidence", matchResult.getConfidence());
|
|
|
|
|
+ result.put("matchLabel", matchResult.getMatchLabel());
|
|
|
|
|
+ result.put("candidates", matchResult.getCandidates());
|
|
|
|
|
+ result.put("extractedName", parsed.getPersonName());
|
|
|
|
|
+ result.put("extractedGender", parsed.getGender());
|
|
|
|
|
+ result.put("extractedAge", parsed.getAge());
|
|
|
|
|
+ result.put("needBind", matchResult.getMatchedMemberId() == null && familyId != null);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 两阶段入库 Phase 1: 上传PDF → 解析 → 存草稿 → 返回draftId + 解析载荷 + 成员匹配
|
|
* 两阶段入库 Phase 1: 上传PDF → 解析 → 存草稿 → 返回draftId + 解析载荷 + 成员匹配
|
|
|
* 不落库到 health_reports 等正式表。支持 PDF 解析(auto)和图片上传(image)。
|
|
* 不落库到 health_reports 等正式表。支持 PDF 解析(auto)和图片上传(image)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @deprecated 使用 /report/upload(仅上传)+ /report/parse(按草稿解析)替代
|
|
|
*/
|
|
*/
|
|
|
@Operation(summary = "上传解析报告(预览,不入库)")
|
|
@Operation(summary = "上传解析报告(预览,不入库)")
|
|
|
@PostMapping("/report/parse-preview")
|
|
@PostMapping("/report/parse-preview")
|
|
@@ -503,7 +717,7 @@ public class HealthReportController {
|
|
|
|
|
|
|
|
// 保存文件到磁盘(LangGraph 和本地解析共用)
|
|
// 保存文件到磁盘(LangGraph 和本地解析共用)
|
|
|
String fileUrl = saveUploadFile(file, userId);
|
|
String fileUrl = saveUploadFile(file, userId);
|
|
|
- String absolutePath = new File(fileUrl).getAbsolutePath();
|
|
|
|
|
|
|
+ String absolutePath = uploadBaseDir + "/" + fileUrl;
|
|
|
|
|
|
|
|
// 尝试 LangGraph 远程解析
|
|
// 尝试 LangGraph 远程解析
|
|
|
try {
|
|
try {
|
|
@@ -1541,7 +1755,7 @@ public class HealthReportController {
|
|
|
* 保存上传文件到本地存储
|
|
* 保存上传文件到本地存储
|
|
|
*/
|
|
*/
|
|
|
private String saveUploadFile(MultipartFile file, Long ownerId) throws IOException {
|
|
private String saveUploadFile(MultipartFile file, Long ownerId) throws IOException {
|
|
|
- String uploadDir = "uploads" + File.separator + "health-reports" + File.separator + ownerId;
|
|
|
|
|
|
|
+ String uploadDir = uploadBaseDir + File.separator + "uploads" + File.separator + "health-reports" + File.separator + ownerId;
|
|
|
File dir = new File(uploadDir);
|
|
File dir = new File(uploadDir);
|
|
|
if (!dir.exists()) {
|
|
if (!dir.exists()) {
|
|
|
dir.mkdirs();
|
|
dir.mkdirs();
|