Laravel 接入文档抽取 API:任务状态、超时重试与结果入库

AI摘要
【知识分享】本文介绍用 Laravel 结合 ComPDF 实现采购单字段抽取的完整方案:先验证单号、供应商、金额三类字段的抽取能力,再通过异步提交、队列轮询、状态机与人工核查机制处理超时、响应丢失、重复提交及缺失字段,最终校验后入库。

上传采购单后,业务系统通常需要拿到单号、供应商和金额。把请求塞进上传接口里,会让用户一直等待;简单地给 Job 加上重试,又可能在响应丢失时重复提交远端任务。

下面先用不同版式的采购单验证 ComPDF 能否取到同一组字段,再用 Laravel 把上传、异步查询和入库连起来。你可以先在官网试自己的文件,确认抽取值有用,再接入业务系统。

Laravel 文档抽取教程封面

先核验抽取能力,再接队列

不同供应商的采购单,单号、供货单位和金额未必放在相同位置。如果业务只需要这三个字段,先定义需要的值,再验证输入,比先写一堆坐标规则更直接。

三份教学采购单使用同一套 vision 模式与字段定义,抽取结果如下:

输入 文档中实际字段 API 返回 核对结果
文本列表版式 PO-2026-017 / 教学供应商 / 128.50 三项对应值相同 通过
文本卡片版式 PO-2026-021 / 教学供应商乙 / 206.40 三项对应值相同 通过
图像型表格版式 PO-2026-022 / 教学供应商丙 / 315.00 三项对应值相同 通过

三种采购单实际输入与ComPDF返回值对照

图:原始文件片段与真实 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 协议》,转载必须注明作者和本文链接
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
未填写
文章
34
粉丝
2
喜欢
7
收藏
9
排名:1447
访问:2520
私信
所有博文
社区赞助商