山东大学软件学院创新实训--“智愈医院自助服务系统“-(4)-导入分析功能实现
一、引言
"智愈"系统构建了"主动咨询 + 报告解读"的双通道寻医体验:智能医生模块以对话形式回答健康咨询,而病情诊断书导入分析模块则让用户上传真实诊断书,由系统自动完成文件校验、多模态识别、结构化提取和报告生成。
此篇博客重点展示从功能设计到代码落地的完整技术实现路径,涵盖 Qwen-VL 多模态 API 集成、System Prompt 工程、双通道图片传入策略、PDF 报告自动生成以及前后端交互链路的构建。
二、整体技术架构
2.1 前端到端到端的数据流
用户选择文件 → 前端校验格式/大小 → AJAX 上传
→ 后端接收 → [Base64 编码 | OSS 存储] → Qwen-VL API
→ JSON 解析 → 持久化 → 返回前端渲染
文件上传采用 Base64 内联方式,不依赖阿里云 OSS,降低部署门槛的同时消除了公网 URL 访问权限可能带来的 API 调用失败风险。
2.2 模块代码结构
src/main/java/world/
├── controller/MedicalScanController.java # 6个REST API端点
├── service/
│ ├── MedicalRecordAnalysisService.java # Qwen-VL多模态识别核心
│ └── AnalysisHistoryService.java # 分析历史CRUD
├── entity/AnalysisHistory.java # 数据实体映射
├── dao/AnalysisHistoryDao.java # MyBatis-Plus Mapper
└── utils/PdfExportUtil.java # iText 5 PDF报告生成
三、核心实现
3.1 诊断书分析入口:控制器层
MedicalScanController 对外暴露六个 RESTful 端点,覆盖"上传分析 → 历史查看 → 详情获取 → 删除 → 清空 → PDF 导出"的完整操作闭环:
@Slf4j
@Controller
public class MedicalScanController {
@Resource
private MedicalRecordAnalysisService medicalRecordAnalysisService;
@Resource
private AnalysisHistoryService analysisHistoryService;
@GetMapping("/medical-scan")
public String medicalScan() {
return "medical-scan"; // Thymeleaf 页面
}
/** 核心分析接口:接收文件 → 调用 Qwen-VL → 持久化 → 返回结果 */
@ResponseBody
@PostMapping("/api/medical-record/analyze")
public RespResult analyzeMedicalRecord(@RequestParam("file") MultipartFile file,
HttpSession session) {
User loginUser = (User) session.getAttribute("loginUser");
if (loginUser == null) return RespResult.fail("请先登录");
try {
// 方案一:直接 Base64 内联调用(不依赖 OSS)
JSONObject result = medicalRecordAnalysisService
.uploadAndAnalyzeWithBase64(file);
// 分析完成后自动持久化
analysisHistoryService.saveHistory(
loginUser.getId(),
file.getOriginalFilename(),
file.getContentType(),
result.getString("image_url"),
result
);
return RespResult.success("分析成功", result);
} catch (Exception e) {
log.error("分析失败", e);
return RespResult.fail(e.getMessage());
}
}
}
控制器的设计遵循单一职责原则:接收请求→委派 Service→返回结果,不包含任何业务逻辑。文件校验、AI 调用、结果解析全部下沉到 Service 层。
3.2 多模态识别引擎:MedicalRecordAnalysisService
这是模块的技术核心,封装了与通义千问 Qwen-VL 的完整交互逻辑。
3.2.1 双通道图片传入策略
针对不同部署场景,设计了两种图片传入 Qwen-VL 的方式:
/** 方式一:公网 URL 方式——文件先上传至 OSS,获取可公开访问的 URL */
public JSONObject uploadAndAnalyze(MultipartFile file) throws IOException {
validateFileType(file);
String imageUrl = ossClient.upload(file, "medical_records");
JSONObject result = analyzeMedicalRecord(imageUrl);
result.put("image_url", imageUrl);
return result;
}
/** 方式二:Base64 内联方式——文件直接转为 data URL,无需外部存储 */
public JSONObject uploadAndAnalyzeWithBase64(MultipartFile file) throws IOException {
validateFileType(file);
byte[] bytes = file.getBytes();
String base64Content = Base64.getEncoder().encodeToString(bytes);
String dataUrl = "data:" + file.getContentType() + ";base64," + base64Content;
return analyzeMedicalRecordWithBase64(dataUrl);
}
方式一的优势:文件持久化到 OSS,返回的 URL 可重复使用,适合有 OSS 配置的生产环境。
方式二的优势:不依赖任何外部存储,部署成本最低;消除了 OSS 访问权限导致 Qwen-VL 无法读取图片的风险,适合本地开发和演示。
本项目采用方式二作为默认策略。
3.2.2 文件校验
private void validateFileType(MultipartFile file) {
String filename = file.getOriginalFilename();
if (filename == null) throw new RuntimeException("文件名为空");
String ext = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
if (!ext.equals("jpg") && !ext.equals("jpeg") && !ext.equals("png") && !ext.equals("pdf")) {
throw new RuntimeException("不支持的文件类型");
}
if (file.getSize() > 10 * 1024 * 1024) {
throw new RuntimeException("文件大小不能超过10MB");
}
}
支持 JPG、JPEG、PNG、PDF 四种格式,限制 10MB 大小。PDF 文件将由前端或后续处理进行转图。
3.2.3 Qwen-VL API 调用
private static final String DASHSCOPE_API_URL =
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS) // 多模态识别需要较长读超时
.build();
public JSONObject analyzeMedicalRecordWithBase64(String dataUrl) {
JSONObject requestBody = new JSONObject();
requestBody.put("model", "qwen-vl-plus");
JSONObject input = new JSONObject();
JSONArray messages = new JSONArray();
JSONObject message = new JSONObject();
message.put("role", "user");
JSONArray content = new JSONArray();
JSONObject imageContent = new JSONObject();
imageContent.put("image", dataUrl);
content.add(imageContent);
JSONObject textContent = new JSONObject();
textContent.put("text", buildMedicalPrompt());
content.add(textContent);
message.put("content", content);
messages.add(message);
input.put("messages", messages);
requestBody.put("input", input);
JSONObject parameters = new JSONObject();
parameters.put("result_format", "message");
requestBody.put("parameters", parameters);
Request request = new Request.Builder()
.url(DASHSCOPE_API_URL)
.addHeader("Authorization", "Bearer " + apiKey)
.post(RequestBody.create(requestBody.toJSONString(),
MediaType.parse("application/json")))
.build();
try (Response response = okHttpClient.newCall(request).execute()) {
return parseResponse(response.body().string());
} catch (IOException e) {
log.error("调用通义千问VL失败", e);
throw new RuntimeException("诊断书分析失败: " + e.getMessage(), e);
}
}
关键参数说明:
- model:
qwen-vl-plus——通义千问多模态模型,兼顾识别精度和响应速度 - result_format:
message——返回结构化 message 格式而非原始文本 - connectTimeout: 30 秒——防止偶发网络抖动
- readTimeout: 60 秒——多模态模型推理耗时较长,需设置大超时
3.2.4 System Prompt 工程
Prompt 的设计决定了 Qwen-VL 的输出质量。本模块的 Prompt 采用"两步决策 + 结构化约束"的策略:
private String buildMedicalPrompt() {
return "你是一位专业的医疗文档分析专家。请分析这张图片。\n\n"
+ "第一步:判断这张图片是否是医疗相关的文档"
+ "(诊断书、检验报告、处方、出院小结、病历等)。\n\n"
+ "如果不是医疗文档,请返回:\n"
+ "{\n"
+ " \"is_medical_document\": false,\n"
+ " \"message\": \"请上传诊断书、检验报告等医疗文档\",\n"
+ " \"detected_content_type\": \"识别到的文件类型\"\n"
+ "}\n\n"
+ "如果是医疗文档,请提取以下信息并以JSON格式返回:\n"
+ "{\n"
+ " \"is_medical_document\": true,\n"
+ " \"patient_name\": \"患者姓名\",\n"
+ " \"patient_age\": \"年龄\",\n"
+ " \"patient_gender\": \"性别\",\n"
+ " \"diagnosis\": {\n"
+ " \"primary_diagnosis\": \"主要诊断结论\",\n"
+ " \"secondary_diagnosis\": \"次要诊断\",\n"
+ " \"icd_code\": \"疾病编码\"\n"
+ " },\n"
+ " \"symptoms\": [\"症状1\", \"症状2\"],\n"
+ " \"examinations\": [\n"
+ " {\"item\": \"检查项目\", \"result\": \"结果\",\n"
+ " \"reference\": \"参考范围\", \"status\": \"正常/偏高/偏低\"}\n"
+ " ],\n"
+ " \"medications\": [\n"
+ " {\"name\": \"药品名称\", \"dosage\": \"用法用量\",\n"
+ " \"frequency\": \"频率\", \"duration\": \"周期\"}\n"
+ " ],\n"
+ " \"recommendations\": \"医生建议\",\n"
+ " \"report_date\": \"报告日期\",\n"
+ " \"hospital_name\": \"医院名称\",\n"
+ " \"doctor_name\": \"医生姓名\"\n"
+ "}\n\n"
+ "要求:只输出JSON,不要有其他文字。";
}
设计要点:
两步决策:先判断是否为医疗文档,避免用户误传无关图片导致无效 AI 调用
JSON Schema 约束:在 Prompt 中嵌入完整的 JSON 结构模板,限定输出格式
字段完备性:覆盖诊断书的所有关键信息维度——患者信息、诊断结论、检查指标、药品处方、医嘱建议
输出约束:明确要求"只输出JSON",消除 AI 生成额外解释文本的干扰
3.2.5 响应解析与容错
Qwen-VL 返回的响应体为嵌套 JSON,需要逐层解析。同时,模型有时会在 JSON 外层包裹 markdown 代码块标记,需要清理:
private JSONObject parseResponse(String responseBody) {
JSONObject response = JSON.parseObject(responseBody);
JSONObject output = response.getJSONObject("output");
JSONArray choices = output.getJSONArray("choices");
JSONObject choice = choices.getJSONObject(0);
JSONObject message = choice.getJSONObject("message");
// content 可能是 String 或 JSONArray(两种格式兼容)
String content = "";
Object contentObj = message.get("content");
if (contentObj instanceof JSONArray) {
JSONArray contentArray = (JSONArray) contentObj;
content = contentArray.getJSONObject(0).getString("text");
} else if (contentObj instanceof String) {
content = (String) contentObj;
}
// 清理 markdown 代码块标记
String cleanContent = content
.replaceAll("```json\\n?", "")
.replaceAll("\\n?```", "");
try {
JSONObject result = JSON.parseObject(cleanContent);
// 非医疗文档:直接抛出提示
if (result.containsKey("is_medical_document")
&& !result.getBoolean("is_medical_document")) {
String errorMsg = result.getString("message");
throw new RuntimeException(errorMsg != null
? errorMsg : "请上传诊断书、检验报告等医疗文档");
}
ensureFieldExists(result); // 补充默认空字段
// 低置信度标记
if (!checkHasValidData(result)) {
result.put("_warning", "未能从图片中识别出医疗信息");
}
return result;
} catch (Exception e) {
// JSON 解析失败时降级返回原始内容
if (e.getMessage() != null
&& (e.getMessage().contains("请上传")
|| e.getMessage().contains("医疗文档"))) {
throw new RuntimeException(e.getMessage());
}
JSONObject fallback = new JSONObject();
fallback.put("raw_content", content);
fallback.put("parse_error", true);
fallback.put("_warning", "AI返回格式异常");
return fallback;
}
}
容错策略分三级:
- 一级:Qwen-VL 识别为非医疗文档 → 直接抛出可读错误提示
- 二级:JSON 可解析但缺少有效医疗信息 → 标注
_warning降级展示 - 三级:JSON 完全不可解析 → 返回原始文本并标记
parse_error: true
3.3 分析历史持久化
每次分析完成后,通过 AnalysisHistoryService 自动保存记录:
@Data
@TableName("analysis_history")
public class AnalysisHistory {
@TableId(type = IdType.AUTO)
private Integer id;
private Integer userId; // 用户ID,用于隔离不同用户的数据
private String fileName; // 原始文件名
private String fileType; // 文件 MIME 类型
private String imageUrl; // 图片 URL(Base64/OSS)
private String analysisResult; // 完整分析结果 JSON
private String patientName; // 患者姓名(冗余存储,方便列表展示)
private String primaryDiagnosis; // 主要诊断(冗余存储)
@TableField(fill = FieldFill.INSERT)
private Date createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}
patientName 和 primaryDiagnosis 采用冗余存储设计——将 Qwen-VL 返回结果中的关键字段提取到独立列。这样在查询历史列表时可以直接从数据库返回,无需每次反序列化完整的 analysisResult JSON,减少解析开销。
public void saveHistory(Integer userId, String fileName, String fileType,
String imageUrl, JSONObject analysisResult) {
AnalysisHistory history = new AnalysisHistory();
history.setUserId(userId);
history.setFileName(fileName);
history.setFileType(fileType);
history.setImageUrl(imageUrl);
history.setAnalysisResult(analysisResult.toJSONString());
// 冗余存储关键字段
history.setPatientName(analysisResult.getString("patient_name"));
if (analysisResult.containsKey("diagnosis")) {
history.setPrimaryDiagnosis(
analysisResult.getJSONObject("diagnosis")
.getString("primary_diagnosis"));
}
analysisHistoryDao.insert(history);
}
历史查询采用 MyBatis-Plus 分页插件:
public Page<AnalysisHistory> getUserHistory(Integer userId,
Integer pageNum, Integer pageSize) {
Page<AnalysisHistory> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<AnalysisHistory> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AnalysisHistory::getUserId, userId)
.orderByDesc(AnalysisHistory::getCreateTime);
return analysisHistoryDao.selectPage(page, wrapper);
}
3.4 PDF 报告自动生成
PdfExportUtil 基于 iText 5 实现诊断分析报告的结构化 PDF 生成。整体采用"卡片式"布局,每个区域由一个独立方法负责:
public static byte[] generateMedicalReport(JSONObject analysisData, String fileName) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
Document document = new Document(PageSize.A4);
PdfWriter.getInstance(document, baos);
document.open();
addHeaderDecoration(document); // 蓝色顶部装饰线
addTitle(document); // "智愈医疗 · 智能诊断分析报告"
addReportInfoCard(document, fileName); // 报告编号、时间戳
addPatientInfoCard(document, analysisData); // 患者姓名/年龄/性别
addDiagnosisCard(document, analysisData); // 诊断结论(含ICD-10编码)
addSymptomsCard(document, analysisData); // 症状表现列表
addExaminationsTable(document, analysisData);// 检验检查表(异常高亮)
addMedicationsTable(document, analysisData); // 用药建议表
addRecommendationsCard(document, analysisData); // 医嘱建议
addHospitalFooter(document, analysisData); // 医院/医生/日期/置信度
addDisclaimer(document); // 免责声明
document.close();
return baos.toByteArray();
}
检验检查表实现了异常指标自动着色——根据 Qwen-VL 返回的 status 字段,偏高/偏低用红色标记,正常用绿色标记:
Font statusFont;
if ("偏高".equals(status) || "偏低".equals(status)) {
statusFont = new Font(Font.FontFamily.HELVETICA, 9, Font.BOLD, COLOR_ABNORMAL);
} else if ("正常".equals(status)) {
statusFont = new Font(Font.FontFamily.HELVETICA, 9, Font.BOLD, COLOR_NORMAL);
}
PdfPCell statusCell = new PdfPCell(new Phrase(status, statusFont));
statusCell.setPadding(6);
table.addCell(statusCell);
3.5 前端交互:Thymeleaf 页面
前端采用项目现有的 Thymeleaf + jQuery + Bootstrap 技术栈。分析页面通过 AJAX 异步提交文件并展示结果:
// 文件上传与分析
$('#analyzeBtn').click(function() {
var formData = new FormData();
formData.append('file', fileInput.files[0]);
$.ajax({
url: '/api/medical-record/analyze',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(res) {
if (res.code === 200) {
renderAnalysisResult(res.data);
} else {
alert(res.msg);
}
}
});
});
页面接收到分析结果后,渲染为诊断概览卡片、检验指标表格、用药建议等可视化区域,形成完整的"上传 → 分析 → 展示"闭环。
四、技术亮点总结
4.1 一次调用取代两阶段流水线
传统诊断书识别方案采用"OCR 提取文字 → NER 结构化"的两阶段流水线,需要维护两套模型和复杂的中间数据转换。本模块利用 Qwen-VL 多模态模型的强大能力,一次 API 调用完成文字识别 + 语义理解 + 结构化输出三项任务,架构更简洁,维护成本更低。
4.2 三级容错体系
针对 AI 输出天然的不确定性,设计了三级容错:
| 级别 | 触发条件 | 处理方式 |
|---|---|---|
| 一级 | 非医疗文档 | 精确错误提示,引导用户 |
| 二级 | 缺少医疗信息 | 降级展示,标注 _warning |
| 三级 | JSON 解析失败 | 返回原始文本,标记 parse_error |
4.3 响应兼容性处理
Qwen-VL 的 result_format 设为 message 时,不同版本的模型对 content 字段的输出格式存在差异(有时返回 String,有时返回 JSONArray)。parseResponse 方法使用 instanceof 进行运行时类型判断,兼容两种格式,确保模型升级不中断服务。
4.4 无外部存储依赖的部署策略
Base64 内联方式将图片直接编码为 data URL,消除了对阿里云 OSS 的依赖。系统在完全没有 OSS 配置的情况下即可完整运行诊断书分析功能,降低了部署门槛。
五、下一篇博客:
文档管理子系统与接口设计:设计与实现诊断书文档的全生命周期管理系统。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)