实现腾讯通用票据OCR安全适配(07-28-travel-reimbursement-guide)

这个提交包含在:
2026-07-28 22:02:11 +08:00
父节点 b4bd484260
当前提交 ac17092fa0
共修改 19 个文件,包含 1277 行新增和 127 行删除
@@ -121,6 +121,136 @@ const ocrRan = invoice.ocr_attempts > 0
|| invoice.ocr_result_id != null;
```
## Scenario: Tencent General Invoice OCR Safe Persistence
### 1. Scope / Trigger
- Trigger: production uses Tencent OCR for employee-uploaded PDF invoices,
including VAT/electronic ordinary invoices and railway electronic tickets.
- Use the single `RecognizeGeneralInvoice` entry point. Do not split railway
tickets onto the legacy train-ticket API or fall back to synthetic OCR.
- This contract applies to upload limits, provider requests, normalization,
required-field validation, duplicate detection, cache reuse, retry behavior
and database persistence.
### 2. Signatures
- Runtime env: `OCR_PROVIDER=tencent`, `TENCENT_SECRET_ID`,
`TENCENT_SECRET_KEY`, `TENCENT_OCR_REGION` and `OCR_TIMEOUT_MS`.
- Upload: `POST /api/invoice-pool/upload` with one
`application/pdf` multipart `file`, at most `7 * 1024 * 1024` bytes.
- Provider call:
`runTencentGeneralInvoiceOcr(config, { imageBase64, sourceMimeType })` calls
`client.RecognizeGeneralInvoice(request)`.
- Persistence boundary:
`sanitizeOcrResultForPersistence(ocrProvider, normalized)` must run before
every Tencent `OcrResult.result_json` create, cache return or cache repair.
- Duplicate boundary:
`shouldDeduplicateByInvoiceNumberOnly(normalized)` is true only for a known
no-code category with a non-empty invoice number.
### 3. Contracts
- Each PDF contains one invoice. Tencent receives page 1 only with
`EnablePdf=true`, `PdfPageNumber=1`, `EnableMultiplePage=false`,
`EnableCutImage=false`, `EnableItemPolygon=false`, `EnableQRCode=false`,
`EnableSeal=false` and `EnableOther=false`.
- Supported categories are `vat_invoice`, `full_vat_invoice` and
`electronic_train_ticket` (`ElectronicTrainTicketFull`). Other subtypes and
multiple detected invoices fail closed.
- Required normalized fields:
- `vat_invoice`: `invoiceCode`, `invoiceNumber`, `invoiceDate`,
`totalAmount`, `sellerName`.
- `full_vat_invoice`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
`sellerName`.
- `electronic_train_ticket`: `invoiceNumber`, `invoiceDate`, `totalAmount`,
`buyerName`.
- Never invent an invoice code from an invoice number or vice versa. Known
no-code full-VAT and railway tickets use invoice-number-only duplicate
detection; other categories retain code-plus-number semantics.
- Tencent persistence is allow-list only: document subtype/category/count,
normalized invoice identifiers/date/amounts/buyer/seller, approved quality
error and `pdf_native` transport mode. Never persist the raw provider
response, passenger name, identity number, QR/seal data or full ticket fields.
- Existing Tencent cache rows are not trusted as already safe. Every cache hit,
including the unique-key race path, is re-sanitized and written back before
use.
- OCR remains assistive. Finance must compare the reimbursement item, source
PDF and required payment evidence; recognition success is not automatic
approval.
### 4. Validation & Error Matrix
- Non-PDF source -> terminal `unsupported_source_mime_type`; do not retry.
- PDF larger than 7 MiB -> upload rejects with
`PDF 发票不能超过 7 MiB`; do not enqueue OCR.
- No invoice / more than one invoice / unsupported subtype / provider item not
`OK` -> `invoice_not_detected` /
`multiple_invoices_not_supported` / `unsupported_invoice_subtype` /
`invoice_recognition_failed`; mark OCR failed without inventing fields.
- Missing per-category fields ->
`missing_required_fields:<comma-separated-fields>`; mark OCR failed.
- `AuthFailure*`, `UnauthorizedOperation*`, `InvalidParameter*`,
`InvalidParameterValue*`, `FailedOperation.UnOpenError`,
`LimitExceeded.TooLargeFileError`,
`ResourceUnavailable.ResourcePackageRunOut`,
`ResourceUnavailable.InArrears` and
`ResourcesSoldOut.ChargeStatusException` -> terminal failure; do not consume
retry attempts blindly or switch to a billable/ mock provider.
- A transient provider/network error not in the terminal allowlist -> retain the
bounded existing retry policy; never expose credentials or raw responses in
the user-facing error.
### 5. Good/Base/Bad Cases
- Good: one railway electronic ticket PDF under 7 MiB yields
`electronic_train_ticket`, a blank code, a valid number/date/total/buyer and
participates in number-only duplicate detection; persisted JSON contains no
passenger identity fields or raw response.
- Base: one supported VAT/electronic ordinary invoice yields its required
category fields and follows the normal code-plus-number or known no-code
duplicate rule.
- Bad: send a multi-invoice PDF, parse only the first detected invoice, copy
number into code, persist `raw`, or retry after the free resource package is
exhausted.
### 6. Tests Required
- Adapter unit tests assert the exact `RecognizeGeneralInvoice` request flags,
PDF-only transport and normalization for VAT, full VAT and
`ElectronicTrainTicketFull`.
- Adapter error tests assert terminal classification for credentials,
authorization, invalid input, oversized input, service-not-open,
arrears/charge state and free-package exhaustion.
- OCR service/job tests assert category-specific required fields,
number-only dedupe only for known no-code categories, and no retry on terminal
errors.
- Cache tests seed a legacy Tencent result containing `raw` and sensitive
fields, then assert both normal and race cache paths return and persist only
the allow-listed shape.
- Upload route tests assert exactly 7 MiB remains accepted and any larger PDF
is rejected before OCR enqueue.
- Full backend regression, Prisma validation, sensitive-content check and
Trellis governance check remain required before commit.
### 7. Wrong vs Correct
#### Wrong
```js
const result = await client.VatInvoiceOCR({ ImageBase64: imageBase64 });
await prisma.ocrResult.create({ data: { result_json: { ...result, raw: result } } });
```
#### Correct
```js
const result = await client.RecognizeGeneralInvoice(buildGeneralInvoiceRequest(imageBase64));
const normalized = normalizeGeneralInvoiceResult(result);
const persisted = sanitizeOcrResultForPersistence("tencent", normalized);
await prisma.ocrResult.create({ data: { result_json: persisted } });
```
## Scenario: Payment Batch Receipt Attention Count
### 1. Scope / Trigger