PDF-OCR文件识别篇(七):数据入库

前面把「PDF → 结构化 JSON」打通了。这一章讲怎么把它接进真实业务:异步化、定时回写、按表落库。核心是 ExtractionRecordServiceImpl(继承 MyBatis-Plus IService),以 extraction_record(一表一条)为中心。

7.1 数据模型

复制代码
extraction_card     一份文档一张卡片(父)
   └─ extraction_record   一张表一条(pdfPath / ocrJson / dataJson / status / taskId / importStatus ...)
        └─ ocr_result     一次百度OCR解析一条(jsonPath / mdPath,关联 record_id)

record 几个关键列:status(待抽取/抽取中/Success/Failed)、taskId(大模型异步任务ID)、ocrJson(OCR JSON 文件路径)、dataJson(AI 结构化 JSON 文件路径)、importStatus(是否已入库)。

落盘约定ocr_json / data_json / pdf_path 列只存文件路径 ,真实内容写到配置目录(ocrJsonDir / dataJsonDir / pdfSaveDir),避免大字段撑爆表。读取时 readDataJson 兼容历史「直接存 JSON 内容」的数据。

7.2 路径 A:百度 OCR 解析 ocrParse

调第 4 章的 baiduocr,把结果落盘并绑定到记录:

java 复制代码
byte[] bytes = Files.readAllBytes(Paths.get(record.getPdfPath()));
BaiduDocParseResult parse = baiduDocParseClient.parse(bytes, pdf.getName());  // 调 baiduocr
String jsonPath = writeOcrFile(record, parse.getJsonText(), "json");          // 结果落盘
String mdPath   = writeOcrFile(record, parse.getMarkdownText(), "md");
ocrResultMapper.insert(ocr);            // 写 ocr_result(关联 record_id)
record.setOcrJson(jsonPath);            // JSON 路径绑定到记录,供调用 AI 使用
updateById(record);

控制器 AiOcrParseController

复制代码
@PostMapping("/extraction/record/{id}/ocr-parse")   // 触发解析
@GetMapping ("/extraction/record/{id}/ocr-md")      // 读最近一次 Markdown

7.3 调 AI:异步提交 + 定时轮询

单表「调AI」走异步,避免长请求阻塞页面。分两段:

提交(立即返回)callAi

复制代码
String ocrText  = extractOcrText(readOcrContent(record.getOcrJson()));
String userContent = buildUserContent(record.getTableTitle(), ocrText);
String taskId = aiClient.submitAsync(SYSTEM_PROMPT, userContent, record.getTableTitle());

record.setTaskId(taskId);
record.setStatus(STATUS_RUNNING);       // 标「抽取中」
record.setUpdateTime(now);              // update_time 当「开始时间」,完成时算耗时
updateById(record);

轮询(定时回写)pollAsyncResults

复制代码
@Scheduled(fixedDelayString = "${pdf.ai.poll-interval-ms:8000}")
public void pollAsyncResults() {
    for (record : 状态=抽取中 且 taskId 非空) handleAsyncResult(record);
}

private void handleAsyncResult(ExtractionRecord record) {
    AsyncChatResult r = aiClient.queryAsync(record.getTaskId());
    if (r.isProcessing()) return;                          // 等下次轮询
    if (r.isSuccess()) {
        Object parsed = JSON.parse(r.getContent());        // 校验
        record.setDataJson(writeDataJsonFile(record, json));// 结构化 JSON 落盘
        record.setTotalTokens(r.getTotalTokens());         // 记 token 用量
        record.setStatus(STATUS_SUCCESS);
    } else {
        record.setStatus(STATUS_FAILED);
        record.setError(r.getError());
    }
    record.setDurationMs(now - startTime);                 // 抽取耗时
    updateById(record);
}

注意:定时任务依赖启动类 @EnableScheduling。没开的话,记录会永远停在「抽取中」。

7.4 入库 importToLibrary:按表号分派装配器

抽取成功后,按表号选对应 XxxImportAssembler 把 AI-JSON 装配成业务 VO,再交 IImportService 落库:

复制代码
if (!STATUS_SUCCESS.equals(record.getStatus())) throw ServiceException("请先成功调用AI后再入库");
if (IMPORT_DONE.equals(record.getImportStatus())) throw ServiceException("该记录已入库,请勿重复");

switch (表号) {
  case 表1:  vo = table1ImportAssembler.assemble(dataJson);  dataid = importService.importTable1(vo);
  case 表2:  vo = table2ImportAssembler.assemble(dataJson);  dataid = importService.importTable2(vo);
  case 表3:  vo = table3ImportAssembler.assemble(dataJson);  dataid = importService.importTable3(vo);
  ...  // 每张表各有专用装配器与导入方法
}

完整方法

java 复制代码
 @Override
    public AiExtractionRecord importToLibrary(Long id) {
        AiExtractionRecord record = getById(id);
        if (record == null) {
            throw new ServiceException("记录不存在");
        }
        if (!STATUS_SUCCESS.equals(record.getStatus())) {
            throw new ServiceException("请先成功调用AI后再入库");
        }

        if (IMPORT_DONE.equals(record.getImportStatus())) {
            throw new ServiceException("该记录已入库,请勿重复入库");
        }
        // 卡片级 dataid/enterid,保证同一单位多张表共用同一标识
        AiExtractionCard card = record.getCardId() == null ? null
                : aiExtractionCardMapper.selectById(record.getCardId());
        String cardDataid = card == null ? null : card.getDataid();
        String cardEnterid = card == null ? null : card.getEnterid();

        String dataJson = readDataJson(id);
        String dataid;
        if (isTable1(record)) {
            PwBaseImportVo vo = table1ImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            if (card != null && vo.getBaseInfo() != null
                    && StringUtils.isEmpty(vo.getBaseInfo().getDevcompany())) {
                vo.getBaseInfo().setDevcompany(card.getDevcompany());
            }
            dataid = pwImportService.importBaseInfo(vo);
        } else if (isTable2bc(record)) {
            SubTab3bcImportVo vo = table2bcImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            dataid = pwImportService.importSubTab3bc(vo);
        } else if (isTable2(record)) {
            ProductImportVo vo = table2ImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            dataid = pwImportService.importProduct(vo);
        } else if (isWaterLine(record)) {
            com.pwxk.aiextraction.domain.WaterLineImportVo vo = waterLineImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            dataid = pwImportService.importWaterLine(vo);
        } else if (isSewageTreatment(record)) {
            com.pwxk.aiextraction.domain.SewageTreatmentImportVo vo = sewageTreatmentImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            dataid = pwImportService.importSewageTreatment(vo);
        } else if (isTable3(record)) {
            MaterialImportVo vo = table3ImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            dataid = pwImportService.importMaterial(vo);
        } else if (isTable4(record)) {
            AirImportVo vo = table4ImportAssembler.assemble(dataJson);
            vo.setDataid(cardDataid);
            vo.setEnterid(cardEnterid);
            dataid = pwImportService.importAir(vo);

        } else {
            throw new ServiceException("暂不支持该表入库:" + record.getTableTitle());
        }
        record.setImportDataId(dataid);
        record.setImportStatus(IMPORT_DONE);
        record.setImportTime(DateUtils.getNowDate());
        record.setUpdateBy(SecurityUtils.getUsername());
        record.setUpdateTime(DateUtils.getNowDate());
        updateById(record);
        return record;
    }

7.5 装配器 XxxImportAssembler:吸收模型输出波动

这层负责把「模型给的相对自由的 JSON」规整成「数据库要的严格结构」,是 AI 结果可靠落库的保障。以一张明细表的装配器为例:

java 复制代码
public DetailImportVo assemble(String dataJson) {
    JSONObject root = unwrap(JSON.parse(dataJson));        // ① 解包 rootKey
    DetailImportVo vo = new DetailImportVo();
    JSONArray rows = arr(root, "设备明细", "明细");           // ② 字段多别名兜底
    Map<String, Group> map = new LinkedHashMap<>();
    for (row : rows) {
        String code = str(row, "设备编号");
        // ③ 按业务键(如编号|名称)归并,把子明细挂到对应分组
    }
    return vo;
}

完整某个装配器:

java 复制代码
@Component
public class FSBZImportAssembler {

    private static final String ROOT_KEY = "废水污染物排放执行标准表";

    public FSBZImportVo assemble(String dataJson) {
        JSONArray rows = WaterDrainJson.locateArray(dataJson, ROOT_KEY, "废水污染物排放执行标准");
        FSBZImportVo vo = new FSBZImportVo();
        for (int i = 0; i < rows.size(); i++) {
            JSONObject row = rows.getJSONObject(i);
            if (row == null) {
                continue;
            }
            FSBZNorm n = new FSBZNorm();
            n.setDraincode(WaterDrainJson.str(row, "排放口编号", "排污口编号", "企业排放口编号"));
            n.setXkdraincode(WaterDrainJson.str(row, "许可排放口编号", "排放口编号", "排污口编号"));
            n.setDrainname(WaterDrainJson.str(row, "排放口名称", "排污口名称"));
            n.setWrwname(WaterDrainJson.str(row, "污染物种类"));
            // 国家或地方污染物排放标准 {名称, 浓度限值(数值), 浓度限值单位}(新版数值与单位分开)
            JSONObject std = WaterDrainJson.obj(row, "国家或地方污染物排放标准", "排放标准");
            JSONObject src = std != null ? std : row;
            n.setEmissionname(WaterDrainJson.str(src, "名称", "国家或地方污染物排放标准名称"));
            n.setEmissioncon(WaterDrainJson.str(src, "浓度限值(不要单位)", "浓度限值", "国家或地方污染物排放标准浓度限值"));
            n.setEmissioncondwname(WaterDrainJson.str(src, "浓度限值单位", "单位", "国家或地方污染物排放标准浓度限值单位"));
            n.setDrainageagreement(WaterDrainJson.str(row, "排水协议规定的浓度限值(数值)", "排水协议规定的浓度限值(如有)", "排水协议规定的浓度限值", "排污协议规定的浓度限值"));
            n.setAskname(WaterDrainJson.str(row, "环境影响评价批复要求", "环境影响评价审批意见要求"));
            n.setOffername(WaterDrainJson.str(row, "承诺是否更加严格排放限值(数值)", "承诺是否更加严格排放限值", "承诺更加严格排放限值"));
            n.setOthercontent(WaterDrainJson.str(row, "其他信息", "其他"));

            if (StringUtils.isBlank(n.getDraincode()) && StringUtils.isBlank(n.getDrainname())
                    && StringUtils.isBlank(n.getWrwname())) {
                continue;
            }
            vo.getNorms().add(n);
        }
        if (vo.getNorms().isEmpty()) {
            throw new ServiceException("未能从结构化结果中识别到有效的废水执行标准数据");
        }
        return vo;
    }
}

通用套路:解包 rootKey → 字段名多别名兜底 str(obj,"a","b") → 明细按业务键归并 → 输出严格 VO。 模型偶尔换个字段名、层级略有出入,都在这层被吸收掉。

7.6 全链路回顾

java 复制代码
上传PDF → 按表建 record
  → ocrParse(百度解析,落盘 ocrJson)          [路径A]
  → callAi(异步提交,status=抽取中)
  → pollAsyncResults(定时轮询,落盘 dataJson,status=Success)
  → importToLibrary(按表号 → 装配器 → IImportService 落业务库)

每一步都可单独重试,状态机清晰,失败不连坐。

关键文件

  • service/impl/ExtractionRecordServiceImpl.java --- 业务编排/异步轮询/入库分派
  • convert/*ImportAssembler.java --- AI-JSON → 业务 VO 装配器
  • service/impl/ImportServiceImpl.java --- 各表 importXxx 落库
  • controller/AiOcrParseController.java --- OCR 解析接口
  • domain/ExtractionRecord.javadomain/OcrResult.java --- 实体
相关推荐
AI人工智能+1 小时前
融合计算机视觉与自然语言处理的驾驶证识别技术,实现了从非结构化图像到结构化数据的高效转化,成为智慧交通数字化转型的关键支撑
计算机视觉·自然语言处理·ocr·驾驶证识别
rebibabo2 小时前
Java基础(番外) | Kafka 入门:分区、副本与消费者组原理
java·分布式·kafka·学习笔记·副本·分区·异步日志
Flittly2 小时前
【AgentScope Java新手村系列】(17)长期记忆系统
java·spring boot·spring
wei1986212 小时前
.net添加web引用和添加服务引用有什么区别?
java·前端·.net
Full Stack Developme2 小时前
正则表达式的使用教程
java·数据库·正则表达式
SeeYa-J2 小时前
Sprint 1-2:创建第一个 Spring Boot Module(user-service)
java·spring boot·sprint
云絮.3 小时前
数据库事务
java·开发语言·数据库
格子软件3 小时前
2026年GEO优化系统源码级状态机与多模型调度拆解
java·前端·vue.js·人工智能·vue·geo