# 受理流程中的文档检测

> 在受理流程中加入文档检测：上传、接收 Webhook、按风险等级分流、排入人工复核队列、保存报告编号并在之后核验。

来源： https://fileverity.com/zh/docs/intake-workflow

本示例把文档检测置于接收外部文档的流程之前，例如贷款申请、保险理赔、供应商入驻或费用报销。每份文档只检测一次，按风险等级分流，其报告编号作为审计记录保存。API 返回风险等级和处置建议，最终由您的策略和复核人员作出决定。

它使用模型 `fvdoc-3`，并使用[文件](https://fileverity.com/zh/docs/files.md)、[分析](https://fileverity.com/zh/docs/analyses.md)和 [Webhooks](https://fileverity.com/zh/docs/webhooks.md) 中的调用。使用测试密钥时，上传和检测会立即返回示例结果，但不会发送 Webhook，也没有示例 PDF。如需试用您的接收程序，请在控制台中对 Webhook 端点使用**发送测试**。

## 流程

1. 用 `POST /files` 上传文件，用 `POST /analyses` 发起检测。将您的案件 ID 放入 `metadata`，并发送 `Idempotency-Key`。
2. 立即响应您的用户。文档可能需要排队，因此不要让其请求一直挂起；检测完成时由 Webhook 通知您。
3. 接收 `analysis.completed` 或 `analysis.failed`，校验签名，并将任务加入队列。
4. 在任务中获取该分析，按其摘要分流，并将结果和 PDF 报告与案件一并保存。
5. 将需要人工处理的案件送入复核队列，并附上 PDF。
6. 之后，用保存的文件核验该报告。

## 1. 上传并发起检测

最大 4 MB 的文件以 multipart 方式上传。更大的文件（最大 50 MB）请按[文件](https://fileverity.com/zh/docs/files.md#presigned)中的说明使用预签名上传。创建分析会返回 `202` 和 `status: processing`（使用测试密钥时返回 `200` 和已完成的示例结果），您的 `metadata` 会随分析一同返回，并出现在该分析的每个 Webhook 中。

**提交文档: cURL**

```bash
FILE_ID=$(curl -s https://api.fileverity.com/v1/files \
  -H "Authorization: Bearer $FILEVERITY_API_KEY" \
  -H "Idempotency-Key: intake-A-10492-file" \
  -F file=@application.pdf | jq -r .id)

curl https://api.fileverity.com/v1/analyses \
  -H "Authorization: Bearer $FILEVERITY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: intake-A-10492-check" \
  -d "{\"model\": \"fvdoc-3\", \"file\": \"$FILE_ID\", \"metadata\": {\"case_id\": \"A-10492\"}}"
```

**提交文档: JavaScript**

```javascript
import { readFile } from "node:fs/promises";
import { basename } from "node:path";

const base = "https://api.fileverity.com/v1";
const auth = { Authorization: "Bearer " + process.env.FILEVERITY_API_KEY };

async function call(path, init) {
  const response = await fetch(base + path, init);
  const body = await response.json();
  if (!response.ok) throw new Error(body.error.code + " (" + body.error.request_id + ")");
  return body;
}

export async function submit(caseId, path) {
  const form = new FormData();
  form.append("file", new Blob([await readFile(path)]), basename(path));
  const file = await call("/files", {
    method: "POST",
    headers: { ...auth, "Idempotency-Key": `intake-${caseId}-file` },
    body: form,
  });
  return call("/analyses", {
    method: "POST",
    headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": `intake-${caseId}-check` },
    body: JSON.stringify({ model: "fvdoc-3", file: file.id, metadata: { case_id: caseId } }),
  });
}
```

**提交文档: Python**

```python
import os
import requests

BASE = "https://api.fileverity.com/v1"
HEADERS = {"Authorization": "Bearer " + os.environ["FILEVERITY_API_KEY"]}

def call(method, path, **kwargs):
    headers = {**HEADERS, **kwargs.pop("headers", {})}
    response = requests.request(method, BASE + path, headers=headers, timeout=90, **kwargs)
    body = response.json()
    if not response.ok:
        raise RuntimeError(body["error"]["code"] + " (" + body["error"]["request_id"] + ")")
    return body

def submit(case_id, path):
    with open(path, "rb") as source:
        file = call("POST", "/files", headers={"Idempotency-Key": f"intake-{case_id}-file"}, files={"file": source})
    return call(
        "POST",
        "/analyses",
        headers={"Idempotency-Key": f"intake-{case_id}-check"},
        json={"model": "fvdoc-3", "file": file["id"], "metadata": {"case_id": case_id}},
    )
```

**202 Accepted**

```json
{
  "id": "an_8Hk2pQ4vN9sL1xR7cW3mY6zB",
  "object": "analysis",
  "created": 1793700120,
  "model": "fvdoc-3-2026-11-03",
  "livemode": true,
  "status": "processing",
  "file": {
    "name": "application.pdf",
    "bytes": 18874368,
    "format": "pdf"
  },
  "report_id": null,
  "report_url": null,
  "summary": null,
  "result": null,
  "error": null,
  "progress": {
    "stage": "queued",
    "done": 0,
    "total": 0,
    "position": 1
  },
  "usage": {
    "amount": 599,
    "currency": "usd",
    "funding": "balance"
  },
  "metadata": {
    "case_id": "A-10492"
  },
  "completed_at": null,
  "expires_at": null
}
```

请将 `id` 与案件一并保存。由于 Idempotency-Key 由案件 ID 派生，超时后再次运行 `submit` 会返回第一次的响应，而不会重复上传文件，也不会重复计费。Idempotency-Key 保留 24 小时；参见[幂等性](https://fileverity.com/zh/docs/idempotency.md)。

## 2. 接收结果

使用 [Webhooks](https://fileverity.com/zh/docs/webhooks.md#signature) 中的 `verify` 函数校验签名，并将其保存为 `verify.js` 或 `verify.py`。请快速返回 `2xx`，并在任务中完成实际工作。`enqueue` 代表您自己的任务队列。请以事件 ID 为键存储每个任务，这样重复投递就不会创建第二个任务。

**Webhook 接收程序: JavaScript**

```javascript
import express from "express";
import { verify } from "./verify.js";

const app = express();

app.post("/webhooks/fileverity", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = verify(process.env.FILEVERITY_WEBHOOK_SECRET, req.headers, req.body.toString("utf8"));
  } catch {
    return res.sendStatus(400);
  }
  if (event.type === "analysis.completed" || event.type === "analysis.failed") {
    await enqueue({ eventId: event.id, analysisId: event.data.id });
  }
  res.sendStatus(200);
});
```

**Webhook 接收程序: Python**

```python
import os

from fastapi import FastAPI, HTTPException, Request

from verify import verify

app = FastAPI()

@app.post("/webhooks/fileverity")
async def receive(request: Request):
    body = await request.body()
    try:
        event = verify(os.environ["FILEVERITY_WEBHOOK_SECRET"], request.headers, body)
    except (KeyError, ValueError):
        raise HTTPException(status_code=400)
    if event["type"] in ("analysis.completed", "analysis.failed"):
        enqueue(event["id"], event["data"]["id"])
    return {"ok": True}
```

## 3. 按风险等级分流

`summary` 是文档检测稳定且机器可读的判定结果。`level` 的取值从 `L1` 到 `L5`，`action` 是建议的下一步。请同时依据两者分流，并将任何无法识别的值交给人工处理。

| 摘要 | 含义 | 分流示例 |
| --- | --- | --- |
| `L1` 或 `L2`，`accept` | 未发现篡改迹象。`L1` 表示来源已验证。 | 自动继续处理。保存报告编号。 |
| `L1` 或 `L2`，`note` | 没有篡改迹象，但报告中标出了 AI 撰写的文本或 AI 标识。 | 继续处理；如果您的策略限制 AI 撰写的内容，请标记该案件。 |
| `L3`, `review` | 需要人工复核。 | 常规复核队列。 |
| `L4`, `review` | 高风险。 | 优先复核队列。 |
| `L5`, `reject` | 报告中已确认存在问题。 | 优先复核队列。拒绝任何案件之前，须由人工确认。 |
| `status` 为 `failed` 或 `canceled` | 检测未完成，未扣费。 | 重新上传已保存的原始文件，或将案件交给人工处理。 |

这种对应关系体现的是您自己的策略。修改该函数，即可把 `L3` 放入优先队列，或让 `note` 进入复核。action 的取值集合还包括 `resubmit`，函数将其视为索取文件另一份副本的请求。摘要中的每个字段都在[分析](https://fileverity.com/zh/docs/analyses.md#summary-document)中有说明。

**分流已完成的检测: JavaScript**

```javascript
export function route(analysis) {
  if (analysis.status !== "succeeded") return "manual";
  const { action, level } = analysis.summary;
  if (action === "accept") return "accept";
  if (action === "note") return "accept_with_note";
  if (action === "resubmit") return "request_new_copy";
  if (action === "reject" || level === "L4" || level === "L5") return "priority_review";
  return "review";
}
```

**分流已完成的检测: Python**

```python
def route(analysis):
    if analysis["status"] != "succeeded":
        return "manual"
    action = analysis["summary"]["action"]
    level = analysis["summary"]["level"]
    if action == "accept":
        return "accept"
    if action == "note":
        return "accept_with_note"
    if action == "resubmit":
        return "request_new_copy"
    if action == "reject" or level in ("L4", "L5"):
        return "priority_review"
    return "review"
```

## 4. 保存报告编号和 PDF

该任务获取分析、对其分流，并保存之后需要用到的内容。它复用前面步骤中的 `call`、`route` 和凭据，`saveCase` 和 `saveReport` 代表您自己的存储。请在同一个任务中下载 PDF：分析结果保留 7 天，文档的页面图片和文本摘录保留 3 小时，此后 Fileverity 只保留核验记录，不保留完整报告。

**处理已完成的检测: JavaScript**

```javascript
export async function handleResult(analysisId) {
  const analysis = await call(`/analyses/${analysisId}`, { headers: auth });
  const decision = route(analysis);
  await saveCase(analysis.metadata.case_id, {
    analysisId: analysis.id,
    reportId: analysis.report_id,
    reportUrl: analysis.report_url,
    summary: analysis.summary,
    model: analysis.model,
    decision,
  });
  if (analysis.status === "succeeded") {
    const pdf = await fetch(`${base}/analyses/${analysis.id}/report.pdf`, { headers: auth });
    await saveReport(analysis.report_id, Buffer.from(await pdf.arrayBuffer()));
  }
  return decision;
}
```

**处理已完成的检测: Python**

```python
def handle_result(analysis_id):
    analysis = call("GET", f"/analyses/{analysis_id}")
    decision = route(analysis)
    save_case(analysis["metadata"]["case_id"], {
        "analysis_id": analysis["id"],
        "report_id": analysis["report_id"],
        "report_url": analysis["report_url"],
        "summary": analysis["summary"],
        "model": analysis["model"],
        "decision": decision,
    })
    if analysis["status"] == "succeeded":
        pdf = requests.get(f"{BASE}/analyses/{analysis['id']}/report.pdf", headers=HEADERS, timeout=90)
        pdf.raise_for_status()
        save_report(analysis["report_id"], pdf.content)
    return decision
```

- `report_id`（`FV-XXXX-XXXX-XXXX`）是查找审计记录的依据。请将其与案件一并保存，存入您的数据库，并写在所有决定通知书上。
- `model` 是产生该结果的带日期快照，因此您之后可以准确说明是哪个版本检测了该文档。
- 请把原始文件保存在您自己的存储中。分析开始时，Fileverity 会删除已上传的文件，检测失败后需要重新上传。参见[数据保留与安全](https://fileverity.com/zh/docs/security.md)。

## 5. 运行人工复核队列

为每个已分流的案件创建一个任务。向复核人员提供无需再次调用 API 即可作出决定所需的内容：

- 案件 ID、`level`、`action`、`reason` 以及 `findings` 中的发现 ID，并附上 PDF 报告。PDF 会列出每项发现及其位置和证据。
- 您保存的原始文件。
- `report_url`，即公开核验页。它确认报告记录和文件指纹，但不能代替阅读报告本身。

在您自己的系统中，将复核人员、决定和时间记录在 `report_id` 旁边。

> **注意。** 检测未发现问题，并不能证明文档是真实的；检测发现了问题，也不能证明存在欺诈。请勿仅凭 API 结果拒绝案件。对于收入、身份或所有权类文档，请通过您自行查得的联系方式向出具方核实。[文档伪造检测指南](https://fileverity.com/zh/guides/ai-document-fraud-detection)介绍了按文档类型应检查哪些内容。

## 6. 稍后核验报告

数月之后，审计人员或客户无需 Fileverity 账号即可核对该记录。打开保存的 `report_url`，页面会显示报告类型、记录的发现和下一步操作、被检测文件的 SHA-256 指纹，以及 RFC 3161 时间戳。把您保存的原始文件拖到页面上，浏览器会计算其 SHA-256 并与记录比对，不会上传任何内容。任何重新保存、转换或编辑过的副本都会得出不同的指纹。

您也可以在自己的系统中执行同样的比对：

**计算已保存原始文件的指纹: cURL**

```bash
sha256sum application.pdf
```

**计算已保存原始文件的指纹: JavaScript**

```javascript
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";

const digest = createHash("sha256").update(await readFile("application.pdf")).digest("hex");
console.log(digest);
```

**计算已保存原始文件的指纹: Python**

```python
import hashlib

print(hashlib.sha256(open("application.pdf", "rb").read()).hexdigest())
```

核验不会再次分析文件，也不证明文档是真实的。它只记录检测了哪份文件、报告给出的结论是什么，以及检测的时间。完整报告只存在于您保存的 PDF 中。报告的各字段在[报告对象](https://fileverity.com/zh/docs/analyses.md#report)中有说明。

## 上线之前

- 请先使用测试密钥开发。它会立即返回示例结果：风险等级 `L2`，处置建议 `accept`，不会运行模型。Webhook 和 PDF 下载需要 Live 密钥，您可以用免费额度来试用。请把[分析](https://fileverity.com/zh/docs/analyses.md#summary-document)中的其他摘要传入 `route`，以测试每个分支。
- 处理 `503 service_unavailable`：等待 `retry-after` 秒后，使用相同的 `Idempotency-Key` 重试。参见[服务繁忙时](https://fileverity.com/zh/docs/analyses.md#busy)。
- 为密钥设置每月支出上限，并添加 `balance.low` Webhook，以免受理因 `insufficient_balance` 而中断。参见[价格与计费](https://fileverity.com/zh/docs/pricing.md#alerts)。
- 请从您的服务器调用 API，并将密钥保存在密钥管理服务中，切勿放在浏览器代码里。
- [联系技术支持](https://fileverity.com/zh/contact)时，请提供 `x-request-id` 响应头的值。

