Laravel 接入文档抽取 API:任务状态、超时重试与结果入库
上传采购单后,业务系统通常需要拿到单号、供应商和金额。把请求塞进上传接口里,会让用户一直等待;简单地给 Job 加上重试,又可能在响应丢失时重复提交远端任务。
下面先用不同版式的采购单验证 ComPDF 能否取到同一组字段,再用 Laravel 把上传、异步查询和入库连起来。你可以先在官网试自己的文件,确认抽取值有用,再接入业务系统。

先核验抽取能力,再接队列
不同供应商的采购单,单号、供货单位和金额未必放在相同位置。如果业务只需要这三个字段,先定义需要的值,再验证输入,比先写一堆坐标规则更直接。
三份教学采购单使用同一套 vision 模式与字段定义,抽取结果如下:
| 输入 | 文档中实际字段 | API 返回 | 核对结果 |
|---|---|---|---|
| 文本列表版式 | PO-2026-017 / 教学供应商 / 128.50 | 三项对应值相同 | 通过 |
| 文本卡片版式 | PO-2026-021 / 教学供应商乙 / 206.40 | 三项对应值相同 | 通过 |
| 图像型表格版式 | PO-2026-022 / 教学供应商丙 / 315.00 | 三项对应值相同 | 通过 |

图:原始文件片段与真实 API 返回值。三种版式的字段位置和名称不同,均使用同一套字段定义抽取,无需在本例中逐个指定坐标。
图像型 PDF 是把教学页面渲染成图片再封装而成,已确认没有可提取文字层。它验证了本例不依赖 PDF 的现成文本层,但不等同于低清、倾斜或带噪声的真实扫描测试。
你可以先打开 官网抽取演示:上传自己的单页教学文件,选择 Vision 和自定义抽取,添加 order_number、supplier、total,分别填写“采购单号”“供应商名称”“文档中明确填写的合计金额;未填写则返回空,不要计算”,再点击立即抽取。

图:同一图像型文件的官网实际试用结果,与 API 返回相符。先在这里核对输入和目标字段,再复用字段定义到 Laravel。
ComPDF 负责从文档中抽取指定字段,Laravel 负责超时处理、人工核查状态和入库约束。上表仅记录三份教学输入的结果;接入实际业务时,还需用自己的单据检查准确率。
缺失字段怎样进入人工核查
入库流程使用 PO-2026-017 的完整文件和另一份 PO-2026-018 的缺失金额文件,检查应用怎样处理必填字段。
两份文件的 API 返回如下。金额缺失时保留空值,不用数量和单价推算。
{"message":"OK","results":{"Page_1":{"order_number":"PO-2026-017","supplier":"教学供应商","total":"128.50"}}}
{"message":"OK","results":{"Page_1":{"order_number":"PO-2026-018","supplier":"教学供应商","total":""}}}
完整输入进入 completed,业务表新增 1 条;缺失金额的输入进入 needs_review,等待人工核查,不写入业务表。
准备 Laravel 项目
代码在 PHP 8.4.26、Laravel 12.69.3 和 SQLite 上实测。需要 PHP 的 pdo_sqlite、mbstring、dom、curl、fileinfo、pcntl 扩展,以及 Composer 2。已有 Laravel 12 项目可以直接添加后面的文件;新项目可从标准骨架开始:
composer create-project laravel/laravel purchase-extract "^12.0"
cd purchase-extract
到 ComPDF 控制台取得自己的公共 API key,在 .env 设置:
DB_CONNECTION=sqlite
QUEUE_CONNECTION=database
COMPDF_API_KEY=YOUR_PUBLIC_API_KEY
密钥只放在服务端环境变量中。这里使用 x-api-key 请求头,不需要把密钥写进 URL。
新增 config/compdf.php:
<?php
return ['base'=>'https://api-server.compdf.com/server/v2',
'key'=>env('COMPDF_API_KEY'), 'poll_seconds'=>3, 'deadline_seconds'=>600];
检查 config/queue.php 的 database 连接:retry_after 为 90,after_commit 为 true。HTTP 单次超时是 20 秒,提交 Job 超时 45 秒,查询 Job 超时 55 秒,worker 默认超时 60 秒。查询 Job 可能先查状态、再下载结果,因此它的超时要覆盖两次请求,并小于队列的 retry_after。
在 bootstrap/app.php 的 withRouting() 中加入 api: __DIR__.'/../routes/api.php',使下文路由生效。标准 Laravel 12 骨架已有 jobs 和 failed_jobs 迁移;没有这些表的旧项目先运行 php artisan make:queue-table 和 php artisan make:queue-failed-table。
用数据库保存业务进度
Job 已执行,不等于抽取完成。这里把应用状态保存在 documents,远端受理后再保存 remote_task_id。

图:应用状态示意。提交响应不明时先核查远端;已有 taskId 后,只重试查询和下载。
运行 php artisan make:migration create_document_extraction_tables,把生成文件的 up() 和 down() 改为:
public function up(): void
{
Schema::create('documents', function (Blueprint $t) {
$t->uuid('id')->primary();
$t->string('fingerprint')->unique();
$t->string('path');
$t->string('status')->default('queued');
$t->string('remote_task_id')->nullable();
$t->text('result_json')->nullable();
$t->text('error')->nullable();
$t->timestamp('deadline');
$t->timestamps();
});
Schema::create('purchase_orders', function (Blueprint $t) {
$t->id();
$t->uuid('document_id')->unique();
$t->string('order_number')->unique();
$t->string('supplier');
$t->decimal('total', 12, 2);
$t->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('purchase_orders');
Schema::dropIfExists('documents');
}
文件指纹由内容和 schema 版本共同组成,同一份文件不会反复建立文档记录。业务表另有单号约束:如果不同文件使用同一单号,先核查版本,不覆盖原数据。本例面向一个采购单业务;多租户系统应按租户划定指纹与单号的唯一范围。
把上传和查询分开
文档抽取接口的异步入口是 POST /server/v2/processAsync/idp/documentExtract;受理响应中的 data.taskId 用于 GET /server/v2/task/taskInfo。查询结果里的 fileInfoDTOList[0] 是本例单个输入文件的状态和结果地址。
新增 app/ComPdf.php:
<?php
namespace App;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
final class ComPdf {
public static function client() {
return Http::withHeaders(['x-api-key'=>config('compdf.key')])
->acceptJson()->connectTimeout(5)->timeout(20);
}
public static function submit(object $document): string {
$schema = ['name'=>'PurchaseOrder', 'keys'=>[
'order_number'=>['prompt'=>'采购单号','mapping'=>null],
'supplier'=>['prompt'=>'供应商名称','mapping'=>null],
'total'=>['prompt'=>'文档中明确填写的合计金额;未填写则返回空,不要计算','mapping'=>null]]];
$response = self::client()->attach('file', Storage::get($document->path), basename($document->path))
->post(config('compdf.base').'/processAsync/idp/documentExtract',[
'mode'=>'vision','language'=>2,'extractFields'=>json_encode($schema,JSON_UNESCAPED_UNICODE)]);
// A lost response may still mean the server accepted the request. Never retry this POST blindly.
$data = $response->throw()->json();
if ((string)($data['code'] ?? '') !== '200' || empty($data['data']['taskId'])) {
throw new \RuntimeException('未得到有效 taskId,请先核查远端任务');
}
return $data['data']['taskId'];
}
public static function task(string $taskId): array {
$response = self::client()->get(config('compdf.base').'/task/taskInfo', ['taskId'=>$taskId,'language'=>2]);
$data = $response->throw()->json();
if ((string)($data['code'] ?? '') !== '200') throw new \RuntimeException('任务查询未成功');
return $data['data'];
}
public static function result(string $url): array {
// Download URLs are server-provided; do not attach the API key to this request.
$response = Http::connectTimeout(5)->timeout(20)->get($url);
return json_decode($response->throw()->body(),true,512,JSON_THROW_ON_ERROR);
}
}
这套 抽取字段定义只要求单号、供应商和明确填写的金额。提交操作没有调用 Http::retry():服务器可能已经受理请求,只是客户端没有收到响应。
新增 app/Jobs/SubmitExtraction.php:
<?php
namespace App\Jobs;
use App\ComPdf;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
final class SubmitExtraction implements ShouldQueue {
use Queueable;
public int $tries = 1;
public int $timeout = 45;
public bool $failOnTimeout = true;
public function __construct(public string $id) {}
public function handle(): void {
$claimed = DB::table('documents')->where('id',$this->id)->where('status','queued')
->update(['status'=>'submitting','updated_at'=>now()]);
if (!$claimed) return;
$document = DB::table('documents')->where('id',$this->id)->first();
if (now()->greaterThanOrEqualTo($document->deadline)) {
DB::table('documents')->where('id',$this->id)->update([
'status'=>'needs_review','error'=>'提交前已超过本地等待期限','updated_at'=>now()]); return;
}
try {
$taskId = ComPdf::submit($document);
DB::transaction(function () use ($taskId) {
DB::table('documents')->where('id',$this->id)->update([
'remote_task_id'=>$taskId,'status'=>'processing','updated_at'=>now()]);
PollExtraction::dispatch($this->id)->delay(now()->addSeconds(config('compdf.poll_seconds')));
});
} catch (\Throwable $error) {
DB::table('documents')->where('id',$this->id)->update([
'status'=>'submission_unknown','error'=>'提交响应未确认;先核查远端任务,不自动重新上传',
'updated_at'=>now()]);
}
}
public function failed(?\Throwable $error): void {
DB::table('documents')->where('id',$this->id)->where('status','submitting')->update([
'status'=>'submission_unknown','error'=>'提交任务中断;请先核查远端任务','updated_at'=>now()]);
}
}
queued → submitting 是一次带条件的数据库更新。重复执行同一个提交 Job 时,已经进入其他状态的记录不会再次上传。收到 taskId 后,把记录更新与查询 Job 的分发放进同一个数据库事务。
新增 app/Jobs/PollExtraction.php:
<?php
namespace App\Jobs;
use App\ComPdf;
use App\ImportResult;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
final class PollExtraction implements ShouldQueue {
use Queueable;
public int $tries = 0; // Release attempts are bounded by the document's deadline.
public int $maxExceptions = 3;
public int $timeout = 55;
public bool $failOnTimeout = true;
public function __construct(public string $id) {}
public function handle(): void {
$document = DB::table('documents')->where('id',$this->id)->first();
if (!$document || $document->status !== 'processing') return;
if (now()->greaterThanOrEqualTo($document->deadline)) {
$this->stop('needs_review','已超过本地等待期限;保留 taskId 核查,不表示远端任务已取消'); return;
}
// Only read-only GET requests are retried. No upload POST is sent from this Job.
try {
$task = ComPdf::task($document->remote_task_id);
$file = $task['fileInfoDTOList'][0] ?? [];
if (($file['status'] ?? '') === 'failed') {
$this->stop('remote_failed','远端文件处理失败;请查看控制台原因'); return;
}
if (($file['status'] ?? '') !== 'success' || empty($file['downloadUrl'])) {
$this->release(config('compdf.poll_seconds')); return;
}
ImportResult::save($this->id,ComPdf::result($file['downloadUrl']));
} catch (\Illuminate\Http\Client\ConnectionException|\Illuminate\Http\Client\RequestException $error) {
$this->release(config('compdf.poll_seconds'));
}
}
private function stop(string $status,string $error): void {
DB::table('documents')->where('id',$this->id)->where('status','processing')
->update(['status'=>$status,'error'=>$error,'updated_at'=>now()]);
}
public function failed(?\Throwable $error): void {
$this->stop('needs_review','查询或结果读取中断;保留 taskId 供核查');
}
}
未完成时,Job 释放回队列,至少间隔 3 秒再查。这里只读请求可重试,上传 POST 不在这个 Job 里。超过上传后 10 分钟,应用停止等待并保留 taskId;这不表示远端任务已取消。
核验字段后再入库
新增 app/ImportResult.php:
<?php
namespace App;
use Illuminate\Support\Facades\DB;
final class ImportResult {
public static function save(string $id, array $result): void {
DB::transaction(function () use ($id,$result) {
$document = DB::table('documents')->where('id',$id)->lockForUpdate()->first();
if (!$document || $document->status === 'completed') return;
$pages = $result['results'] ?? [];
$fields = count($pages) === 1 ? array_values($pages)[0] : [];
$number = trim((string)($fields['order_number'] ?? ''));
$supplier = trim((string)($fields['supplier'] ?? ''));
$total = trim((string)($fields['total'] ?? ''));
$valid = $number !== '' && $supplier !== '' && preg_match('/^\d{1,10}(?:\.\d{1,2})?$/D',$total);
$error = $valid ? null : '必填字段缺失、金额格式不符,或结果并非单页;请人工核查';
if ($valid) {
$existing = DB::table('purchase_orders')->where('order_number',$number)->first();
if ($existing && $existing->document_id !== $id) $error = '采购单号已存在;请核查版本,未覆盖旧记录';
}
if ($error === null) {
DB::table('purchase_orders')->updateOrInsert(['document_id'=>$id],[
'order_number'=>$number, 'supplier'=>$supplier, 'total'=>$total,
'created_at'=>now(), 'updated_at'=>now()]);
}
DB::table('documents')->where('id',$id)->update([
'status'=>$error === null ? 'completed' : 'needs_review',
'result_json'=>json_encode($result,JSON_UNESCAPED_UNICODE),
'error'=>$error, 'updated_at'=>now()]);
});
}
}
这段代码只接收单页结果,并检查必填字段和金额格式。字符串 "0" 是有效金额,不能用 empty() 将它当作缺失。格式通过也不证明金额事实正确;实际采购业务仍应按自己的核对规则处理。多页文件在本例中转人工核查,不能直接取第一页当作完整结果。
提供上传和状态接口
新增 routes/api.php:
<?php
use App\Jobs\SubmitExtraction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
Route::post('/documents', function (Request $request) {
$request->validate(['file'=>'required|file|mimes:pdf|max:2048']);
$file = $request->file('file');
$fingerprint = hash('sha256',file_get_contents($file->getRealPath()).'|purchase-order-schema-v1');
$existing = DB::table('documents')->where('fingerprint',$fingerprint)->first();
if ($existing) return response()->json(['id'=>$existing->id,'status'=>$existing->status,'reused'=>true]);
$id = (string) Str::uuid();
$path = $file->storeAs('documents',$id.'.pdf');
// The unique fingerprint is also enforced by the database for concurrent uploads.
DB::table('documents')->insertOrIgnore(['id'=>$id,'fingerprint'=>$fingerprint,'path'=>$path,
'status'=>'queued','deadline'=>now()->addSeconds(config('compdf.deadline_seconds')),
'created_at'=>now(),'updated_at'=>now()]);
$document = DB::table('documents')->where('fingerprint',$fingerprint)->first();
if ($document->id !== $id) \Illuminate\Support\Facades\Storage::delete($path);
if ($document->status === 'queued') SubmitExtraction::dispatch($document->id);
return response()->json(['id'=>$document->id,'status'=>$document->status],202);
});
Route::get('/documents/{id}', function (string $id) {
$document = DB::table('documents')->where('id',$id)->first();
abort_unless($document,404);
return response()->json(['id'=>$document->id,'status'=>$document->status,
'has_remote_task'=>$document->remote_task_id !== null,'error'=>$document->error,
'result'=>$document->result_json ? json_decode($document->result_json,true) : null,
'purchase_order'=>DB::table('purchase_orders')->where('document_id',$id)->first()]);
});
这个教学入口限制 PDF 不超过 2 MB。文件写入非公开存储,API 只返回应用状态与教学业务记录。本地启动时绑定回环地址;接入现有业务系统时,再沿用该系统的登录和权限控制。
touch database/database.sqlite
php artisan migrate
php artisan serve --host=127.0.0.1 --port=8000
另开终端:
php artisan queue:work --timeout=60 --sleep=1
上传自己的单页教学 PDF(将文件路径换成实际路径),再用返回的 id 查询:
curl -H 'Accept: application/json' -F 'file=@purchase-order.pdf' http://127.0.0.1:8000/api/documents
curl -H 'Accept: application/json' http://127.0.0.1:8000/api/documents/返回的id
Worker 修改代码后需要重启,不能继续使用内存中的旧 Job。
检查哪些情况,怎样恢复

图:两份采购单的 API 处理和入库结果。下文用模拟 HTTP 检查应用的异常处理。
上述两份文件已跑通上传、taskId 保存、队列查询和入库。重复上传同一文件返回已有 id,非 PDF 上传返回 422。响应丢失、重复提交 Job、远端失败、本地超时、金额零值、空金额、重复入库和重复单号,则用 Laravel Http::fake() 检查应用逻辑。
恢复时先分清状态:
submission_unknown:先在控制台核查任务是否已受理,取得 taskId 后再恢复查询。不要仅凭客户端超时重新上传。processing:可以继续查同一个 taskId;如果本地已超过期限,核查后再决定是否重新安排查询。needs_review:检查原文件与抽取值;不能把缺失值自动补齐后当成 API 返回的事实。remote_failed:查看远端失败原因,修正输入或参数后再决定新建任务。
如果进程在远端受理后、保存 taskId 前硬停,记录可能停留在 submitting。本例不会自动对账,也无法让本地事务覆盖远端受理;应先核查远端。示例限制了主动重复提交和重复业务入库,没有保证远端任务只会执行一次。
接入前,先拿一份自己的单页文件按上述流程跑通,再根据真实单据扩展字段、页数规则和人工复核步骤。
本作品采用《CC 协议》,转载必须注明作者和本文链接
关于 LearnKu
推荐文章: