前面把「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.java、domain/OcrResult.java--- 实体