一、引言

"智愈"系统构建了"主动咨询 + 报告解读"的双通道寻医体验:智能医生模块以对话形式回答健康咨询,而病情诊断书导入分析模块则让用户上传真实诊断书,由系统自动完成文件校验、多模态识别、结构化提取和报告生成。

此篇博客重点展示从功能设计到代码落地的完整技术实现路径,涵盖 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;
}

patientNameprimaryDiagnosis 采用冗余存储设计——将 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 配置的情况下即可完整运行诊断书分析功能,降低了部署门槛。

五、下一篇博客:

文档管理子系统与接口设计:设计与实现诊断书文档的全生命周期管理系统。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐