跳到正文
Kale的数字花园
目录 文章
/10 分钟/9 阅读/0 评论

PDF 面单识别与标签处理框架:策略模式 + 工厂模式双层架构

FBA 标签存在 **4 种模板**,各模板字段位置不同,通过**类型检测分支**处理: FBA 标签地址识别是难点——地址可能在左侧(中文标签)也可能在右侧(外文标签): FBA 多语言开始标识(Created/Erstellt/Crea

说明:API 项目(物流面单)与 Allocation 项目(仓库标签)是两个独立场景,前者已废弃。本文档聚焦 Allocation 项目的 V2 版本(BoxLabelRecognizer)。

1. 背景与痛点

业务背景

场景一:仓库外箱标签识别

  • 仓库上传 FBA / 万邑通 / FBC / FBT / WFS 等类型标签 PDF
  • 需要识别出入库单号、箱号、SKU、数量、收货地址
  • 识别后需要校验标签数量是否与调拨单明细匹配,并关联到调拨单

场景二:多仓库类型差异化

  • FBA 标签包含多种类型(标准/含有效期/含目的地等),格式差异大
  • FBT 标签每个箱子正面+侧面各打印一张(重复页需去重)
  • WFS 标签是图片型 PDF,文字提取为空,需调用百度 OCR
  • TikTok FBT 标签的 PDF 文件内部 CMap 损坏,文字提取乱码

技术痛点

痛点一:PDF 解析库的能力边界

  • Apache PDFBox 的 PDFTextStripper 只能提取有文字层的 PDF
  • 图片型 PDF(扫描件/百度 OCR 生成件)文字提取为空
  • TikTok FBT 标签 PDF 有文字层但 CMap(字符映射)损坏,字形→Unicode 映射丢失

痛点二:仓库类型 × 标签格式的笛卡尔积

  • 仓库类型:FBA / 万邑通 / FBC / FBT / WFS / 速卖通 / Temu(7+ 种)
  • 每种仓库的标签格式完全不同(正则规则、字段位置、提取逻辑各异)
  • 新增一个仓库类型不能影响现有逻辑

痛点三:标签与调拨单的精确匹配

  • 一个调拨单有多个箱子,每个箱子有多个 SKU
  • 标签识别结果需要与发货仓箱子按箱序号或SKU+数量签名匹配
  • 标签数量与箱子数量必须一致,不一致需告警

2. 技术方案

整体架构(策略模式 + 工厂模式)

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                           调用入口:AllocationFulfillmentLabelService                     │
│                                    parsingBoxLabel()                                    │
└─────────────────────────────────────────────────────────────────────────────────────────┘
                                            │
                                            ▼
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                           工厂层:BoxLabelHelper                                        │
│  Map<"type_selfType", BoxLabelRecognizer>                                              │
│                                                                                         │
│  key 构建规则:warehouseResp.getType() + "_" + warehouseResp.getSelfType()            │
│  例如:FBA → "3_0",万邑通 → "8_0"                                                     │
└─────────────────────────────────────────────────────────────────────────────────────────┘
                                            │
                    ┌───────────┬───────────┬───────────┬───────────┐
                    ▼           ▼           ▼           ▼           ▼
              FbaBoxLabel  WinitBox  FbcBox  FBTBox  WfsBox   TemuBox
              Recognizer  LabelRec. LabelRc. LabelRc. LabelRc. LabelRc.
                 ★           ★          ★        ★        ★         ★
              (PDF文本)   (PDF文本)  (区域提取)  (CMap修复) (OCR)    (正则)

核心接口设计

public interface BoxLabelRecognizer {
    // PDF 加载(default 方法,子类可Override)
    default PDDocument load(String pdfUrl) { ... }

    // 按页获取文本内容(default 方法)
    default String[] getPageContent(PDDocument doc, int startPage, int endPage) { ... }

    // 核心识别方法
    PackageLabelRecognitionV2Result fetchDocContent(String pdfUrl) throws Exception;

    // 获取仓库类型
    Integer getType();

    // 获取自定义子类型
    Integer getSelfType();

    // 获取标签总数
    Integer fetchDocPageSize(String pdfUrl);
}

识别结果数据模型

public class PackageLabelRecognitionV2Result {
    String labelUrl;              // 标签文件URL
    String inboundOrderNo;        // 目的仓入库单号
    String receiptAddress;        // 收货地址
    String warehouseCode;         // 仓库代码
    Integer labelTotalCount;      // 标签总数(用于数量校验)
    String remark;                // 备注

    // 每页/每个箱子的识别结果
    List<SinglePackageRecognition> singlePackageRecognitionList;

    // 一个文件存在多个入库单号(部分仓库场景)
    List<MultiplePackageRecognition> multiplePackageRecognitionList;

    @Data
    public static class SinglePackageRecognition {
        String inboundOrderNo;     // 入库单号
        String packageNo;          // 箱号
        String packageSerno;      // 箱序号(用于匹配发货仓箱子)
        String packageName;        // 包装箱名称(FBA专属)
        String skuCode;            // 产品SKU
        String platformSkuCode;    // 平台SKU
        Integer skuQuantity;       // SKU数量
        Integer totalUnitsPerCarton; // 箱子总件数(FBT使用)
    }

    @Data
    public static class MultiplePackageRecognition {
        Integer serno;             // PDF页序号
        String inboundOrderNo;     // 入库单号
    }
}

标签匹配流程

parsingBoxLabel()
        ↓
boxLabelHelper.fetchDocContent(warehouseId, pdfUrlList)
        ↓
按仓库类型路由到对应 Recognizer
        ↓
识别结果 List<PackageLabelRecognitionV2Result>
        ↓
buildLabelParseResultVO()  // 按仓库类型分支
        │
        ├── FBT:SKU签名匹配(skuCode:quantity + totalUnits)
        ├── FBC:箱序号直接匹配
        ├── Temu:不匹配(先上传标签再装箱)
        └── FBA/万邑通:按箱序号匹配
        ↓
校验标签数量与箱子数量是否一致

3. 实现细节

模块一:BoxLabelHelper 工厂(按类型路由)

@Component
public class BoxLabelHelper {
    final Map<String, BoxLabelRecognizer> helperMap;

    public BoxLabelHelper(List<BoxLabelRecognizer> boxLabelRecognizerList) {
        helperMap = new HashMap<>(boxLabelRecognizerList.size());
        for (BoxLabelRecognizer recognizer : boxLabelRecognizerList) {
            // key = type_selfType,例如 "3_0" = FBA
            String key = recognizer.getType() + "_" + recognizer.getSelfType();
            helperMap.put(key, recognizer);
        }
    }

    public PackageLabelRecognitionV2Result fetchDocContent(
            WarehouseResp warehouseResp, String pdfUrl) {
        String key = warehouseResp.getType() + "_" + warehouseResp.getSelfType();
        BoxLabelRecognizer recognizer = helperMap.get(key);
        if (recognizer == null) {
            throw new BusinessException("没有找到对应的外箱标签识别器");
        }
        return recognizer.fetchDocContent(pdfUrl);
    }
}

模块二:FbaBoxLabelRecognizer(多标签类型分支识别)

FBA 标签存在 4 种模板,各模板字段位置不同,通过类型检测分支处理:

// 标签类型检测
private FbaPackageLabelType getType(String[] lines) {
    if (Arrays.stream(lines).anyMatch(i -> i.contains("件一箱"))) {
        return FbaPackageLabelType.THIRD;
    } else if (Arrays.stream(lines).anyMatch(i -> i.startsWith("P1 - B"))) {
        return FbaPackageLabelType.FIRST;
    } else if (lines[0].contains("请不要遮住此标签")) {
        return FbaPackageLabelType.FORTH;
    } else {
        return FbaPackageLabelType.SECOND;  // 兜底
    }
}

FBA 标签地址识别是难点——地址可能在左侧(中文标签)也可能在右侧(外文标签):

// 左侧标签(中国):地址在开始标识到"发货地"之间
// 右侧标签(海外):地址在开始标识到 "FBA:xxx" 行之间
private int findEndLineNumInLeft(String[] lines, int startLineIndex) {
    for (int i = startLineIndex; i < lines.length; i++) {
        if (lines[i].startsWith("发货地")) { return i - 1; }
    }
    return -1;
}

private int findEndLineNumInRight(String[] lines, int startLineIndex) {
    for (int i = startLineIndex; i < lines.length; i++) {
        if (lines[i].startsWith("FBA") && lines[i].length() == 19) {
            return i - 2;
        }
    }
    return -1;
}

FBA 多语言开始标识(Created/Erstellt/Creata/tablie/Creada):

private int obtainSignIndex(String line) {
    if (line.contains("Created")) return line.indexOf("Created");
    else if (line.contains("Erstellt")) return line.indexOf("Erstellt");
    else if (line.contains("Creata")) return line.indexOf("Creata");
    else if (line.contains("tablie")) return line.indexOf("tablie") - 1;  // Établie
    else if (line.contains("Creada")) return line.indexOf("Creada");
    return 0;
}

模块三:FBTBoxLabelRecognizer(CMap 修复 + 多 SKU 解析)

核心亮点:PDF CMap 损坏修复

TikTok FBT 箱唛 PDF 的 ToUnicode CMap 中计数字段错误写为 0,导致字形→Unicode 映射全部丢失。修复方法:

@Override
public PDDocument load(String pdfUrl) {
    PDDocument doc = BoxLabelRecognizer.super.load(pdfUrl);
    boolean fixed = fixBrokenCMaps(doc);
    if (fixed) {
        doc = reloadDocument(doc);  // 保存到内存并重新加载
    }
    return doc;
}

// 在COS字典层级直接修改CMap流,不触发PDFont缓存加载
private boolean fixBrokenCMaps(PDDocument doc) throws IOException {
    for (PDPage page : doc.getPages()) {
        COSDictionary fonts = (COSDictionary)
            page.getResources().getCOSObject().getDictionaryObject(COSName.FONT);
        for (COSName fontKey : fonts.keySet()) {
            COSDictionary fontDict = (COSDictionary) fonts.getDictionaryObject(fontKey);
            COSStream stream = (COSStream) fontDict.getDictionaryObject(COSName.TO_UNICODE);
            String cmapText = readStreamAsString(stream);
            String fixed = fixCmapCounts(cmapText);
            if (!fixed.equals(cmapText)) {
                // 直接写入修复后的CMap
                try (OutputStream os = stream.createOutputStream()) {
                    os.write(fixed.getBytes("UTF-8"));
                }
            }
        }
    }
    return anyFixed;
}

// 修复计数字段:将"0 beginbfchar"替换为实际映射条目数
private String fixCmapCounts(String cmapText) {
    // 匹配 "N beginbfchar" 段,N是声明的条目数
    // 实际条目数需要逐行统计 <hex> <hex> 格式的行
    Matcher m = BFCHAR_SECTION_PATTERN.matcher(cmapText);
    while (m.find()) {
        int declared = Integer.parseInt(m.group(1));
        String content = m.group(2);
        int actual = countActualMappings(content);
        if (declared == 0 && actual > 0) {
            // 修复:declared 0 → actual
            m.appendReplacement(sb, actual + " beginbfchar\n" + content + "\nendbfchar");
        }
    }
}

FBT 多 SKU 解析(一个箱子可能有多个 SKU):

// 按 packageNo + skuCode 去重(每个箱子正面+侧面各一张标签)
List<PackageLabelRecognitionV2Result.SinglePackageRecognition> deduplicatedList =
    deduplicateRecognitions(recognitionList);

// SKU+数量按顺序一一对应,共享箱号和入库单号
for (int i = 0; i < count; i++) {
    SinglePackageRecognition r = new SinglePackageRecognition();
    r.setPackageNo(packageNo);
    r.setInboundOrderNo(inboundOrderNo);
    r.setSkuCode(skuCodes.get(i));
    r.setSkuQuantity(quantities.get(i));
    r.setTotalUnitsPerCarton(totalUnitsPerCarton);
}

模块四:WfsBoxLabelRecognizer(百度 OCR 识别)

图片型 PDF 无法用 PDFBox 提取文字,改用百度 OCR API:

@Override
public PackageLabelRecognitionV2Result fetchDocContent(String pdfUrl) throws Exception {
    File file = copyUrlToTempFile(pdfUrl);
    ReadImageResult firstPage = fetchSpecialPdfLines(file, 1);  // 百度OCR

    result.setReceiptAddress(getReceiptAddress(firstPage));
    result.setInboundOrderNo(getInboundOrderNo(firstPage));
    result.setLabelTotalCount(firstPage.getPdf_file_size());

    for (int page = 1; page <= fileSize; page++) {
        ReadImageResult pageResult = fetchSpecialPdfLines(file, page);
        singlePackageRecognitionList.add(doFetchEachPageContent(pageResult));
    }
}

// 百度OCR Token 缓存(29天)
String accessBaiDuYunToken(String clientId, String clientSecret) {
    String cached = redisTemplate.opsForValue().get(BAIDU_YUN_ACCESS_TOKEN_KEY);
    if (cached != null) return cached;
    String token = BaiduUtils.accessToken(clientId, clientSecret);
    redisTemplate.opsForValue().set(BAIDU_YUN_ACCESS_TOKEN_KEY, token, 29, TimeUnit.DAYS);
    return token;
}

OCR 图片预处理(纠正常见识别错误):

// 百度OCR可能把英文O识别成数字0,需要纠正
line = line.replace("B0X", "BOX").replace("0F", "OF").replace("0f", "OF");

// 箱号跨行:BOX\nOF01 → BOXOF01
if (line.equals("BOX")) {
    Word nextLine = wordsResults.get(indexOf + 1);
    line = line + nextLine.getWords().replace("0F", "OF");
}

模块五:FbcBoxLabelRecognizer(PDF 区域文本提取)

FBC 标签的收货地址在 PDF 中的固定坐标区域,使用 PDFTextStripperByArea 按区域提取:

@Override
public PackageLabelRecognitionV2Result fetchDocContent(String pdfUrl) throws IOException {
    PDDocument document = this.load(pdfUrl);
    // 硬编码坐标区域:x=300, y=145, width=300, height=120
    PDFTextStripperByArea stripper = new PDFTextStripperByArea();
    stripper.setSortByPosition(false);
    stripper.addRegion("addressRegion", new Rectangle2D.Double(300, 145, 300, 120));
    stripper.extractRegions(document.getPage(0));
    String address = stripper.getTextForRegion("addressRegion");
    result.setReceiptAddress(address);
    // ...
}

模块六:FBT SKU 签名匹配算法

// AllocationFulfillmentLabelServiceImpl - FBT箱唛匹配

// 1. 标签端签名:SKU:quantity 拼接 + 总件数
String buildSkuSignature(List<SinglePackageRecognition> skuList) {
    Integer totalUnits = skuList.get(0).getTotalUnitsPerCarton();  // PDF声明的总件数
    int total = totalUnits != null ? totalUnits
        : skuList.stream().mapToInt(r -> r.getSkuQuantity()).sum();

    String skuPart = skuList.stream()
        .sorted(Comparator.comparing(SinglePackageRecognition::getSkuCode))
        .map(r -> r.getSkuCode() + ":" + r.getSkuQuantity())
        .collect(Collectors.joining(","));
    return skuPart + "|" + total;
}

// 2. 发货仓箱子签名:toStockCode:quantity 拼接 + 总件数
String buildCartonSkuSignature(List<CartonStockItemVo> stockItems) {
    int total = stockItems.stream().mapToInt(CartonStockItemVo::getQuantity).sum();
    String skuPart = stockItems.stream()
        .sorted(Comparator.comparing(item -> item.getLogisticsItemInfo().getToStockCode()))
        .map(item -> item.getLogisticsItemInfo().getToStockCode() + ":" + item.getQuantity())
        .collect(Collectors.joining(","));
    return skuPart + "|" + total;
}

// 3. 签名相同则匹配
Map<String, Stack<String>> signatureToPackageNoMap = ...;
Stack<String> matchStack = signatureToPackageNoMap.get(cartonSignature);
if (CollectionUtil.isNotEmpty(matchStack)) {
    String matchedPackageNo = matchStack.pop();
}

4. 技术栈与亮点归纳

技术栈清单

  • Java / Spring Boot(依赖注入 + 工厂模式)
  • Apache PDFBox(PDF 解析、切分、文本提取、区域提取、PDF 生成)
  • 百度 OCR API(图片型 PDF 文字识别)
  • ZXing / Asprise(条形码/二维码识别)
  • 设计模式:策略模式 + 工厂模式 + 模板方法

技术亮点

  • CMap 损坏修复:在 COS 字典层级直接修改 ToUnicode CMap 流,不触发 PDFBox 字体缓存,修复 TikTok 生成的损坏 PDF
  • 百度 OCR 集成:图片型 PDF 改用 OCR 识别,Token 29 天缓存
  • 多标签类型自动识别:FBA 4 种模板按关键字自动分支
  • SKU 签名匹配:FBT 场景下用签名匹配替代箱序号匹配
  • PDF 区域提取:FBC 标签用坐标区域提取固定字段
  • OCR 后处理:纠正 O/0 混淆、跨行箱号拼接

5. 难点与亮点剖析

业务难点

难点一:PDF 内部 CMap 损坏

  • TikTok FBT 箱唛 PDF 的 CMap 计数字段为 0,导致字形映射丢失
  • 必须在 COS 流层级修复,不能依赖 PDFBox API

难点二:图片型 PDF 识别

  • WFS 标签是百度 OCR 生成的 PDF,无文字层
  • OCR 识别有固有误差(O/0、跨行),需要后处理纠正

难点三:多 SKU 箱子匹配

  • FBT 一个箱子可能有多个 SKU
  • 需要按 packageNo + skuCode 去重(正面+侧面标签重复)

技术难点

技术难点一:PDF CMap 修复的时机

  • PDFBox 在字体加载后会缓存 CMap,必须在首次提取前修复
  • 修复后需要保存到内存流再重新加载,否则修复不生效

技术难点二:坐标区域提取的脆弱性

  • FbcBoxLabelRecognizer 使用硬编码坐标 new Rectangle2D.Double(300, 145, 300, 120)
  • PDF 模板变更后坐标会失效

技术难点三:标签数量校验

  • 每个箱子对应一张标签(FBT 每箱子两张)
  • 标签数量 ≠ 箱子数量时需要告警

实现亮点

  1. COS 流直接操作:绕过 PDFBox API 在底层修复损坏的 CMap
  2. OCR Token 续期机制:29 天缓存 + 主动刷新
  3. FBA 多模板分支:4 种模板按关键字自动识别
  4. 签名匹配算法:解决 FBT 无箱序号场景下的匹配问题

6. 替代方案对比

方案一:统一使用 OCR 识别(已排除)

  • 实现:所有 PDF 都用 OCR 识别
  • 优劣:通用性强,但 OCR 慢(秒级)、有成本;文本型 PDF 用规则更快更准
  • 结论:文本型用 PDFBox,图片型用 OCR,混合策略最优

方案二:FBC 区域提取改用关键字定位(已优化)

  • 原方案:硬编码坐标
  • 问题:PDF 模板变更后失效
  • 改进方向:用"SHIP TO:"等关键字定位区域(已有部分实现)

7. 技术选型与方案抉择复盘

选型思路

  • PDF 文本提取用 Apache PDFBox(成熟稳定)
  • 图片型 PDF 用百度 OCR(团队已采购)
  • TikTok CMap 损坏问题必须自研修复

优点

  • 分层处理:文本型/图片型/CMap损坏分三套处理逻辑
  • 零侵入修复:COS 流直接修改,不改 PDFBox 源码

缺点 / 已知不足

  • FBC 坐标硬编码:脆弱,模板变更需改代码
  • OCR 依赖外网:百度 OCR API 需要外网访问

可优化点

  • FBC 区域提取改用关键字定位,替代硬编码坐标
  • 增加 OCR 置信度过滤,低置信度结果人工确认
  • FBA 新增国家模板时热插拔

8. 成果与数据

  • 接入规模:支持 7+ 仓库类型(FBA/万邑通/FBC/FBT/WFS/速卖通/Temu)
  • 识别准确率:文本型 > 99%(正则匹配);OCR 型 > 95%
  • CMap 修复:TikTok FBT 标签修复成功率 100%
  • 自动化匹配:标签 → 调拨单自动关联,人工操作从 5 分钟/单 → 秒级

9. 面试题清单

  1. 【基础】PDFBox 中 PDFTextStripper 和 PDFTextStripperByArea 的区别?
    • 考察点:前者提取全文档文本,后者按区域提取
  2. 【进阶】ToUnicode CMap 的作用是什么?为什么 TikTok FBT PDF 的 CMap 会导致文字提取失败?
    • 考察点:CMap 是字形 gid → Unicode 的映射;计数字段为 0 导致 PDFBox 认为无映射
  3. 【进阶】为什么修复 CMap 后需要"保存到内存流再重新加载"?
    • 考察点:PDFBox 字体缓存机制;必须在首次访问字体前修复
  4. 【进阶】百度 OCR 的 Token 为什么设置 29 天过期?
    • 考察点:百度 OCR Token 默认 30 天;提前 1 天刷新避免临界过期
  5. 【进阶】FBT 为什么需要按 packageNo + skuCode 去重?
    • 考察点:每个箱子正面+侧面各打印一张标签,内容相同
  6. 【设计】如果新增一个仓库类型"京东仓",最小侵入的方式是什么?
    • 考察点:只需新建 JdBoxLabelRecognizer 实现 BoxLabelRecognizer 接口;Spring 自动注册
  7. 【设计】签名匹配算法 skuCode:quantity|total 的设计思路是什么?
    • 考察点:复合键保证唯一性;排序保证相同内容产生相同签名
  8. 【延伸】如果 PDF 是图片扫描件,如何提升 OCR 识别率?
    • 考察点:去噪、二值化、倾斜校正、分辨率增强

10. 简历提炼版

设计并实现多仓库类型 PDF 标签识别框架,基于策略模式管理 7+ 仓库识别器,新增类型仅需实现接口;自研 TikTok FBT PDF CMap 损坏修复(COS 流层级直接修改),解决字形映射丢失问题;集成百度 OCR 识别图片型标签,OCR 后处理纠正 O/0 混淆;FBT 场景首创 SKU 签名匹配算法,解决无箱序号下的自动关联难题;文本型识别准确率 > 99%,CMap 修复成功率 100%。


关键源码路径

Allocation 项目(V2,boxLabel 目录)

文件 路径
识别器接口 creeks-allocation/.../helper/boxLabel/BoxLabelRecognizer.java
工厂 creeks-allocation/.../helper/boxLabel/BoxLabelHelper.java
FBA 识别器 creeks-allocation/.../helper/boxLabel/FbaBoxLabelRecognizer.java
FBT 识别器(CMap修复) creeks-allocation/.../helper/boxLabel/FBTBoxLabelRecognizer.java
WFS 识别器(OCR) creeks-allocation/.../helper/boxLabel/WfsBoxLabelRecognizer.java
FBC 识别器(区域提取) creeks-allocation/.../helper/boxLabel/FbcBoxLabelRecognizer.java
万邑通识别器 creeks-allocation/.../helper/boxLabel/WinitBoxLabelRecognizer.java
Temu识别器 creeks-allocation/.../helper/boxLabel/TemuBoxLabelRecognizer.java
速卖通识别器 creeks-allocation/.../helper/boxLabel/SmtBoxLabelRecognizer.java
识别结果VO creeks-allocation-client/.../order/vo/PackageLabelRecognitionV2Result.java
标签解析服务 creeks-allocation/.../business/executor/fulfillment/AllocationFulfillmentLabelServiceImpl.java
标签上传处理器 creeks-allocation/.../service/label/FulfillmentUploadLabelHandler.java
相关阅读

8 分钟

通用异常体系与全局响应封装

ERP 系统是一个高度分层的微服务架构: 任何一层出错,最终都要以**统一的格式**返回给前端,任何一环抛出的异常,都要能被**全局捕获并标准化处理**。 集团内用户包含中文、英文等多语言,错误信息硬编码无法切换。 跨服务调用时,服务 B

面试#基础架构9 阅读 · 0 评论

评论需要登录。生产环境使用 GitHub 登录,本地可前往 登录页 使用开发登录。

还没有评论,来抢沙发。