|
|
@@ -365,8 +365,13 @@ public class HealthReportController {
|
|
|
List<HealthReport> merged = mergeDanReports(reports, memberId, familyId, visibleForDan, userId);
|
|
|
|
|
|
if (size > 0) {
|
|
|
- return Result.success(limitLatest(merged, size));
|
|
|
+ // B1-6:列表补展示字段(报告对象姓名 / 代上传人姓名),实体上的 @TableField(exist=false) 字段
|
|
|
+ List<HealthReport> limited = limitLatest(merged, size);
|
|
|
+ healthReportService.fillSubjectAndProxyNames(limited);
|
|
|
+ return Result.success(limited);
|
|
|
}
|
|
|
+ // B1-6:列表补展示字段(报告对象姓名 / 代上传人姓名)
|
|
|
+ healthReportService.fillSubjectAndProxyNames(merged);
|
|
|
return Result.success(merged);
|
|
|
}
|
|
|
|
|
|
@@ -1237,6 +1242,9 @@ public class HealthReportController {
|
|
|
result.put("extractedName", parsed.getPersonName());
|
|
|
result.put("extractedGender", parsed.getGender());
|
|
|
result.put("extractedAge", parsed.getAge());
|
|
|
+ // B1-1:前端预览三行(被检测人/报告编号/报告日期)还读这两个字段,补齐避免恒 '-'
|
|
|
+ result.put("extractedReportNumber", parsed.getReportNumber());
|
|
|
+ result.put("extractedReportDate", parsed.getReportDate());
|
|
|
result.put("needBind", matchResult.getMatchedMemberId() == null && familyId != null);
|
|
|
return Result.success(result);
|
|
|
}
|
|
|
@@ -2669,6 +2677,14 @@ public class HealthReportController {
|
|
|
|
|
|
/**
|
|
|
* 规划师/营养师代用户上传报告(预览模式,仅解析不入库)
|
|
|
+ *
|
|
|
+ * <p>T29-B1-1:重构为与「两阶段入库」一致的模式——先建草稿拿到 draftId,再解析并回写草稿载荷,
|
|
|
+ * 前端「确认入库」才能拿到 draftId(原先透传 LangGraph 原始返回,恒无 draftId → 前端卡死)。</p>
|
|
|
+ * <p>链路:storeFile → createDraft → updateNote(备注) → getFilePath → LangGraph 解析
|
|
|
+ * (失败回退本地 Java 解析)→ 返回 draftId/payload/成员匹配/提取字段。</p>
|
|
|
+ *
|
|
|
+ * <p>权限编排说明:① 角色校验 isProxyUploader;② familyId 非空校验;
|
|
|
+ * ③ 家庭访问拦截器兜底(代传绑定校验由 T29-B2 补充)。</p>
|
|
|
*/
|
|
|
@Operation(summary = "代上传报告(预览)")
|
|
|
@PostMapping("/report/proxy-parse")
|
|
|
@@ -2696,27 +2712,53 @@ public class HealthReportController {
|
|
|
return Result.error("familyId不能为空");
|
|
|
}
|
|
|
|
|
|
+ String contentType = file.getContentType();
|
|
|
+ String originalFilename = file.getOriginalFilename();
|
|
|
+ String lcName = originalFilename != null ? originalFilename.toLowerCase() : "";
|
|
|
+ if (!lcName.endsWith(".pdf")
|
|
|
+ && (contentType == null || !contentType.equalsIgnoreCase("application/pdf"))) {
|
|
|
+ return Result.error("仅支持PDF文件");
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
+ // ① 存文件(本地磁盘 / 京东云 OSS)
|
|
|
String fileUrl = storageService.storeFile(file, "health-reports");
|
|
|
- String absolutePath = storageService.getFilePath(fileUrl);
|
|
|
|
|
|
- // 调用 LangGraph 解析
|
|
|
- Map<String, Object> result = callLangGraphParse(absolutePath, userId, familyId, subjectId);
|
|
|
+ // ② 先建草稿:保证预览必然有 draftId 供 /report/proxy-confirm 入库
|
|
|
+ // reportType 传 auto 时落 gut_flora(与非代传链路一致)
|
|
|
+ // health_report_drafts.payload_json 为 NOT NULL 列,先写空载荷 JSON
|
|
|
+ // (与图片上传链路一致),解析成功后由 updatePayload 覆盖为真实载荷
|
|
|
+ String draftReportType = (reportType == null || reportType.isEmpty() || "auto".equals(reportType))
|
|
|
+ ? "gut_flora" : reportType;
|
|
|
+ String emptyPayloadJson = objectMapper.writeValueAsString(new ParsedReportPayload.Payload());
|
|
|
+ HealthReportDraft draft = healthReportDraftService.createDraft(
|
|
|
+ userId, familyId, draftReportType, fileUrl, originalFilename, emptyPayloadJson);
|
|
|
|
|
|
- if (result == null) {
|
|
|
- return Result.error("报告解析失败");
|
|
|
+ // ③ 备注写入草稿 remark(updateNote 实际写 remark 列),确认入库时同步到 proxy_upload_note
|
|
|
+ if (note != null && !note.isEmpty()) {
|
|
|
+ healthReportDraftService.updateNote(draft.getId(), note);
|
|
|
}
|
|
|
|
|
|
- result.put("fileUrl", fileUrl);
|
|
|
- // 保存草稿
|
|
|
- Long draftId = (Long) result.get("draftId");
|
|
|
- if (draftId != null && note != null) {
|
|
|
- healthReportDraftService.updateNote(draftId, note);
|
|
|
+ // ④ LangGraph 需要本地路径:经 getFilePath 下载/定位到共享卷
|
|
|
+ String absolutePath = storageService.getFilePath(fileUrl);
|
|
|
+
|
|
|
+ // ⑤ 解析(LangGraph 优先,失败回退本地 Java 解析)
|
|
|
+ Map<String, Object> data = parseProxyDraftFile(draft, absolutePath, userId, familyId, subjectId);
|
|
|
+ if (data == null) {
|
|
|
+ // 解析失败:草稿已无后续用途,标记丢弃,避免遗留空载荷 pending 草稿被误确认入库
|
|
|
+ try {
|
|
|
+ healthReportDraftService.markDiscarded(draft.getId());
|
|
|
+ } catch (Exception ex) {
|
|
|
+ log.warn("标记代传草稿为已丢弃失败 draftId={}: {}", draft.getId(), ex.getMessage());
|
|
|
+ }
|
|
|
+ return Result.error("报告解析失败,请确认是募极生物肠道菌群报告PDF");
|
|
|
}
|
|
|
|
|
|
- result.put("proxyUploaderId", userId);
|
|
|
- result.put("proxyUploadNote", note);
|
|
|
- return Result.success(result);
|
|
|
+ // ⑥ 返回体补代传相关字段(前端既有字段契约)
|
|
|
+ data.put("fileUrl", fileUrl);
|
|
|
+ data.put("proxyUploaderId", userId);
|
|
|
+ data.put("proxyUploadNote", note);
|
|
|
+ return Result.success(data);
|
|
|
|
|
|
} catch (Exception e) {
|
|
|
log.error("代上传解析失败", e);
|
|
|
@@ -2724,6 +2766,56 @@ public class HealthReportController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 代传草稿文件解析:LangGraph 优先,失败回退本地 Java 解析。
|
|
|
+ * 两路都会把解析载荷写回 draft.payload_json,并返回完整预览结果
|
|
|
+ * (draftId / payload / matchedMemberId / extracted 各字段 / needBind)。
|
|
|
+ *
|
|
|
+ * @return 解析结果体;文件不存在或两路均失败时返回 null
|
|
|
+ */
|
|
|
+ private Map<String, Object> parseProxyDraftFile(HealthReportDraft draft, String absolutePath,
|
|
|
+ Long userId, Long familyId, Long subjectId) {
|
|
|
+ File localFile = new File(absolutePath);
|
|
|
+ if (!localFile.exists()) {
|
|
|
+ log.warn("代传解析:文件不存在 draftId={}, path={}", draft.getId(), absolutePath);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ Map<String, Object> lgData = callLangGraphParse(absolutePath, userId, familyId, subjectId);
|
|
|
+ if (lgData != null) {
|
|
|
+ Map<String, Object> data = buildParseResultFromLg(lgData, draft, familyId).getData();
|
|
|
+ putExtractedReportMeta(data, lgData);
|
|
|
+ return data;
|
|
|
+ }
|
|
|
+ log.warn("代传解析:LangGraph 无有效返回,回退本地 Java 解析 draftId={}", draft.getId());
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("代传解析:LangGraph 解析异常,回退本地 Java 解析 draftId={}: {}",
|
|
|
+ draft.getId(), e.getMessage());
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return parseLocalFallback(absolutePath, draft, familyId, userId).getData();
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("代传解析:本地 Java 解析失败 draftId={}", draft.getId(), e);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 补齐 LangGraph 预览结果的 extractedReportNumber / extractedReportDate。
|
|
|
+ * buildParseResultFromLg 只回填 extractedName/Gender/Age,而前端预览页三行
|
|
|
+ * (被检测人/报告编号/报告日期)还读这两个字段,缺省会一直显示 '-'。
|
|
|
+ */
|
|
|
+ private void putExtractedReportMeta(Map<String, Object> data, Map<String, Object> lgData) {
|
|
|
+ if (data == null || lgData == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ Map<String, Object> overview = (Map<String, Object>) lgData.get("overview");
|
|
|
+ if (overview != null) {
|
|
|
+ data.put("extractedReportNumber", objectToString(overview.get("report_number")));
|
|
|
+ data.put("extractedReportDate", objectToString(overview.get("report_date")));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 规划师/营养师代确认报告草稿(入库)
|
|
|
*/
|
|
|
@@ -2748,6 +2840,10 @@ public class HealthReportController {
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
+ // 先取草稿备注(代传 note):确认后写入 health_reports.proxy_upload_note
|
|
|
+ HealthReportDraft draft = healthReportDraftService.getDraftById(draftId);
|
|
|
+ String proxyUploadNote = draft != null ? draft.getRemark() : null;
|
|
|
+
|
|
|
// 代上传模式跳过草稿归属校验
|
|
|
Map<String, Object> result = healthReportService.auditConfirm(draftId, subjectId, userId);
|
|
|
Long reportId = (Long) result.get("reportId");
|
|
|
@@ -2762,14 +2858,23 @@ public class HealthReportController {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- // 设置代上传信息
|
|
|
+ // 设置代上传人 + 代上传备注
|
|
|
if (reportId != null) {
|
|
|
- healthReportService.setProxyUploader(reportId, userId);
|
|
|
+ healthReportService.setProxyUploader(reportId, userId, proxyUploadNote);
|
|
|
}
|
|
|
|
|
|
- // 触发 AI 解读
|
|
|
+ // 触发 AI 解读(T29-B1-5:失败不再静默吞错,结果/失败原因随响应回传前端)
|
|
|
+ Map<String, Object> aiResult = new HashMap<>();
|
|
|
if (reportId != null && subjectId != null) {
|
|
|
- triggerAiAnalysis(reportId, subjectId, userId);
|
|
|
+ aiResult = triggerAiAnalysis(reportId, subjectId, userId);
|
|
|
+ }
|
|
|
+ if (Boolean.TRUE.equals(aiResult.get("success"))) {
|
|
|
+ result.put("aiAnalysis", aiResult.get("analysis"));
|
|
|
+ result.put("aiAnalysisStatus", "success");
|
|
|
+ } else {
|
|
|
+ result.put("aiAnalysisStatus", aiResult.get("reason"));
|
|
|
+ Object aiMessage = aiResult.get("message") != null ? aiResult.get("message") : aiResult.get("toast");
|
|
|
+ result.put("aiAnalysisMessage", aiMessage);
|
|
|
}
|
|
|
|
|
|
return Result.success(result);
|
|
|
@@ -2805,15 +2910,24 @@ public class HealthReportController {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 触发 AI 解读
|
|
|
+ * 触发 AI 解读(T29-B1-5:失败不再静默吞错,结果与失败原因回传调用方)
|
|
|
+ *
|
|
|
+ * @return {success, analysis, traceId} 成功;
|
|
|
+ * {success=false, reason="quota", toast} 配额不足;
|
|
|
+ * {success=false, reason="langgraph_failed", message} LangGraph 失败或返回空解读
|
|
|
*/
|
|
|
- private void triggerAiAnalysis(Long reportId, Long subjectId, Long proxyUploaderId) {
|
|
|
+ private Map<String, Object> triggerAiAnalysis(Long reportId, Long subjectId, Long proxyUploaderId) {
|
|
|
+ Map<String, Object> aiResult = new HashMap<>();
|
|
|
+ aiResult.put("success", false);
|
|
|
+
|
|
|
// BASIC 配额检查(报告 AI 解读,2026-10-23 新口径:BASIC 1 次/月;FAMILY 及以上不限次;FREE 维持既有口径扣减)
|
|
|
if (proxyUploaderId != null) {
|
|
|
com.etotem.cfc.dto.QuotaConsumeResult quotaResult = basicQuotaService.consumeMemberQuota(proxyUploaderId, "REPORT_READING", false);
|
|
|
if (!quotaResult.isSuccess()) {
|
|
|
log.info("报告 AI 解读配额不足: userId={}, toast={}", proxyUploaderId, quotaResult.getToastMsg());
|
|
|
- return;
|
|
|
+ aiResult.put("reason", "quota");
|
|
|
+ aiResult.put("toast", quotaResult.getToastMsg());
|
|
|
+ return aiResult;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -2827,19 +2941,34 @@ public class HealthReportController {
|
|
|
langgraphBaseUrl + "/api/v1/analyze", analyzeReq, Map.class);
|
|
|
if (resp.getBody() != null) {
|
|
|
Map<String, Object> body = resp.getBody();
|
|
|
- String analysis = (String) body.get("analysis");
|
|
|
+ String analysis = objectToString(body.get("analysis"));
|
|
|
+ String traceId = objectToString(body.get("trace_id"));
|
|
|
if (analysis != null && !analysis.isEmpty()) {
|
|
|
Map<String, Object> aiData = new HashMap<>();
|
|
|
aiData.put("analysis", analysis);
|
|
|
- aiData.put("traceId", body.get("trace_id"));
|
|
|
+ aiData.put("traceId", traceId);
|
|
|
aiData.put("proxyUploaderId", proxyUploaderId);
|
|
|
healthReportService.setAiAnalysis(reportId, aiData);
|
|
|
- log.info("AI解读已保存: reportId={}, traceId={}", reportId, body.get("trace_id"));
|
|
|
+ log.info("AI解读已保存: reportId={}, traceId={}", reportId, traceId);
|
|
|
+ aiResult.put("success", true);
|
|
|
+ aiResult.put("analysis", analysis);
|
|
|
+ aiResult.put("traceId", traceId);
|
|
|
+ return aiResult;
|
|
|
}
|
|
|
+ aiResult.put("reason", "langgraph_failed");
|
|
|
+ aiResult.put("message", "AI解读服务返回空结果");
|
|
|
+ log.warn("AI解读返回空结果: reportId={}, body={}", reportId, body);
|
|
|
+ } else {
|
|
|
+ aiResult.put("reason", "langgraph_failed");
|
|
|
+ aiResult.put("message", "AI解读服务无响应");
|
|
|
+ log.warn("AI解读服务无响应: reportId={}", reportId);
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
- log.warn("AI解读触发失败: {}", e.getMessage());
|
|
|
+ aiResult.put("reason", "langgraph_failed");
|
|
|
+ aiResult.put("message", "AI解读服务异常: " + e.getMessage());
|
|
|
+ log.warn("AI解读触发失败: {}", e.getMessage(), e);
|
|
|
}
|
|
|
+ return aiResult;
|
|
|
}
|
|
|
|
|
|
/**
|