1. 背景

1.1 两套 OCR 的协议差异

系统在开发和测试阶段使用的是百度 OCR 异步 API。应用先上传文件并创建识别任务,再轮询任务状态,任务完成后下载识别结果。这套接口依赖公网服务和访问令牌,适合研发阶段快速接入和验证效果。

进入客户交付环境后,OCR 模型由客户在本地部署。应用不再调用云端异步任务接口,而是通过内网访问本地模型服务,使用 JSON 传递 Base64 文件内容,并在一次请求中同步获取识别结果。

两套服务提供的业务能力相同:输入一份文档,输出分页后的 OCR 结果。但它们的调用流程、请求格式和结果获取方式并不相同。系统既要保留现有的开发测试能力,又要适配客户的本地部署环境,因此不能简单地用本地接口替换百度接口,而需要在同一套业务流程下兼容两种实现。

差异

百度异步 OCR API

本地 OCR 模型服务

请求格式

multipart/form-data

application/json

文件传输

文件二进制

Base64 字符串

调用方式

提交任务、轮询状态、下载结果

同步返回结果

图片结果

URL

Base64 字符串

认证方式

Bearer Token

通常由内网控制

第一次遇到这个问题时,很容易产生一个直接想法:既然本地接口要求 JSON,那就把原来的 @RequestPart 改成 @RequestBody。

这个修改会让本地接口恢复正常,却会破坏原来的云端接口。原因并不复杂:@RequestPart 和 @RequestBody 描述的是两份不同的 HTTP 契约。它们不是同一个接口的两种写法,也不能通过修改一个公共 Client 同时满足。

真正需要解决的问题不是“该用 form 还是 JSON”,而是:

当两个外部系统提供相同的业务能力,却使用完全不同的技术协议时,怎样让业务代码不被这些差异污染?

1.2 直接加分支为什么会逐渐失控

最直接的实现是在原来的 Gateway 中增加判断:

fun recognize(input: OcrInput): List<OcrParsedPage> =
    when (properties.ocr.provider) {
        BAIDU -> {
            // multipart 上传
            // 提交异步任务
            // 轮询并下载结果
        }

        LOCAL -> {
            // 转成 Base64
            // 发送 JSON
            // 解析同步响应
        }
    }

在只有两个分支、每个分支只有几行代码时,这种写法似乎没有问题。但继续实现后,差异会不断进入同一个类:

  • 两组 URL、模型和认证配置;

  • 两套请求 DTO;

  • 两套响应 DTO;

  • 同步与异步处理流程;

  • URL 图片下载与 Base64 解码;

  • 不同的超时、错误码和异常处理。

这个类最终既负责业务能力,又负责供应商选择,还负责两种 HTTP 协议。增加第三种实现时,只能继续添加分支。

问题的根源不是 when 本身,而是三个不同职责被放进了同一个地方:

  1. 应用需要什么能力;

  2. 当前选择哪种实现;

  3. 每种实现如何与外部系统通信。

把这三个问题拆开,设计就会清晰很多。

2. 目标结构

最终结构如下:

flowchart LR
    Service["OcrService<br/>业务编排"] --> Port["OcrRecognitionGateway<br/>应用层端口"]
    Port --> Router["OcrGatewayRouter<br/>策略路由"]
    Config["OCR provider 配置"] --> Router

    Router --> BaiduAdapter["BaiduPaddleOcrGateway<br/>云端适配器"]
    Router --> LocalAdapter["LocalPaddleOcrGateway<br/>本地适配器"]

    BaiduAdapter --> BaiduClient["BaiduPaddleOcrClient<br/>multipart + 异步任务"]
    LocalAdapter --> LocalClient["LocalPaddleOcrClient<br/>JSON + 同步响应"]

    BaiduAdapter --> Domain["OcrParsedPage<br/>统一领域结果"]
    LocalAdapter --> Domain

这张图可以从左向右理解:

  • OcrService 只知道“我要识别文档”;

  • OcrRecognitionGateway 定义这项能力;

  • OcrGatewayRouter 根据配置选择实现;

  • 两个供应商适配器分别处理自己的协议;

  • 不论调用哪一套接口,最终都返回统一的领域模型。

Spring 在这里主要完成两件事:创建这些对象,以及把对象注入到需要它们的位置。真正的边界仍然由接口和包结构决定。

2.1 先分清两个模式解决的问题

在继续看代码前,先把策略模式和适配器模式分开。它们经常出现在同一组类里,但解决的不是同一个问题。

模式

它回答的问题

生活中的类比

本文中的代码

策略模式

多种实现中,这次选择哪一种?

导航软件选择驾车、步行或骑行路线

Router 选择百度或本地 Provider

适配器模式

两边接口不兼容,怎样完成转换?

转接头把 HDMI 转成 USB-C

Gateway 转换系统模型与供应商协议

可以把两条关系分别写成:

策略模式:OcrGatewayRouter --选择--> OcrProvider

适配器模式:OcrInput --Gateway 翻译--> 供应商请求
           供应商响应 --Gateway 翻译--> OcrParsedPage

同一个 Gateway 可以同时参与两种模式:从 Router 向下看,它是一个可选择的策略;从外部 OCR 接口向上看,它又是一个协议适配器。设计模式描述的是对象之间的协作关系,不是规定一个类只能拥有一个身份。

3. 第一个抽象:应用层端口

应用层先定义自己需要的能力:

interface OcrRecognitionGateway {
    fun recognize(input: OcrInput): List<OcrParsedPage>
}

为了突出核心设计,正文省略了异步任务恢复、任务提交回调等参数,完整项目可以根据业务需要补充。

这个接口最重要的地方不是名字,而是它的位置和依赖方向:它由应用层定义,基础设施层实现。

@Service
class OcrService(
    private val recognitionGateway: OcrRecognitionGateway,
) {
    fun recognize(input: OcrInput): List<OcrParsedPage> =
        recognitionGateway.recognize(input)
}

OcrService 不再依赖 BaiduPaddleOcrGateway 或 LocalPaddleOcrGateway。对它来说,OCR 来自云端、本地进程还是测试桩都不重要。

这就是依赖倒置原则的实际用法:

业务代码不依赖某个具体外部系统,而是依赖自己定义的能力接口。

在六边形架构或端口与适配器架构中,这个接口通常被称为“出站端口”。这里沿用团队更熟悉的 Gateway 命名。

4. OcrProvider:把不同实现变成可选择的策略

策略模式的核心不是“算法一定很复杂”,而是多个实现完成同一个目标,并且调用方可以用同一种方式使用它们。

例如导航软件的目标都是“到达目的地”,但可以选择驾车、步行或骑行。调用方只需要提出起点和终点,不需要把每种路线算法写进自己的业务代码。

OCR 场景也是如此:百度和本地模型都完成“识别文档”这个目标,因此先用一个统一接口描述它们。

基础设施层还需要知道每个实现属于哪个供应商,因此定义内部策略接口:

internal interface OcrProvider {
    val type: OcrProviderType

    fun recognize(input: OcrInput): List<OcrParsedPage>
}

两个实现分别声明自己的类型:

@Component
class BaiduPaddleOcrGateway(...) : OcrProvider {
    override val type = OcrProviderType.BAIDU
}

@Component
class LocalPaddleOcrGateway(...) : OcrProvider {
    override val type = OcrProviderType.LOCAL
}

按照策略模式的经典角色,对应关系如下:

策略模式角色

当前代码

职责

Strategy

OcrProvider

规定所有策略都必须支持 recognize

Concrete Strategy

BaiduPaddleOcrGateway

百度 OCR 的具体实现

Concrete Strategy

LocalPaddleOcrGateway

本地 OCR 的具体实现

Context

OcrGatewayRouter

根据配置选择并调用一个策略

到这里解决的只是“选择谁来识别”,还没有解决百度和本地接口怎样调用。后者属于适配器模式。

这里为什么同时存在 OcrRecognitionGateway 和 OcrProvider?

它们虽然都有 recognize,但面向的对象不同:

  • OcrRecognitionGateway 面向应用层,表达“系统需要 OCR 能力”;

  • OcrProvider 面向基础设施路由器,表达“我是某一种可选择的 OCR 实现”。

这个区分还有一个很实际的好处:应用层只会看到一个 OcrRecognitionGateway Bean,也就是后面介绍的 Router。如果两个供应商实现都直接实现应用层接口,Spring 注入时会发现多个候选 Bean,还需要引入 @Primary、@Qualifier 或额外的选择逻辑。

5. Router:选择策略,但不创建策略

Router 的代码并不复杂:

@Component
internal class OcrGatewayRouter(
    properties: AppProperties,
    providers: List<OcrProvider>,
) : OcrRecognitionGateway {
    private val selectedProvider = properties.ocr.provider
    private val providersByType = providers.associateBy(OcrProvider::type)

    override fun recognize(input: OcrInput): List<OcrParsedPage> =
        requireNotNull(providersByType[selectedProvider]) {
            "OCR provider is not configured: $selectedProvider"
        }.recognize(input)
}

对于刚接触 Spring 的读者,最需要理解的是构造参数中的这一行:

providers: List<OcrProvider>

Spring 启动时会扫描带有 @Component 的类,并创建对应对象。当某个 Bean 的构造函数需要 List<OcrProvider> 时,Spring 会把容器里所有实现该接口的 Bean 放进这个列表。

因此这里会自动获得:

[
  BaiduPaddleOcrGateway,
  LocalPaddleOcrGateway
]

Router 再通过 associateBy 把它们整理成:

BAIDU -> BaiduPaddleOcrGateway
LOCAL -> LocalPaddleOcrGateway

这段设计同时使用了两种思想:

  • 策略模式:每个 Provider 是一种可替换的 OCR 策略;

  • 注册表/路由模式:Router 保存策略映射,并根据配置选择。

它不是典型的工厂模式。工厂负责创建对象,而这里的对象由 Spring 创建,Router 只负责选择已有对象。

6. 适配器模式:为什么 HTTP Client 必须分开

适配器模式可以继续用转接头理解。电脑只接受 USB-C,显示器只提供 HDMI,我们通常不会修改电脑或显示器,而是在中间放一个能够完成转换的转接头。

在当前系统中:

  • 系统内部接受 OcrInput,希望得到 List<OcrParsedPage>;

  • 百度接口接受 multipart 文件,返回异步任务和下载地址;

  • 本地接口接受 JSON Base64,直接返回同步结果。

系统接口与两套外部接口不兼容,因此需要两个不同的“转接头”。但在编写转接逻辑前,首先要让 HTTP Client 如实描述各自的外部协议。

供应商隔离后,两份 HTTP 契约可以各自保持真实形态。

云端接口上传文件二进制:

@HttpExchange
interface BaiduPaddleOcrClient {
    @PostExchange(contentType = "multipart/form-data")
    fun submitJob(
        uri: URI,
        @RequestHeader("Authorization") authorization: String,
        @RequestPart("model") model: String,
        @RequestPart("optionalPayload") optionalPayload: String,
        @RequestPart("file") file: Resource,
    ): OcrJobResponse
}

这里还有一个容易误解的细节:同一个云端异步 API 在传入 fileUrl 时可以使用 application/json,而直接上传本地文件二进制时使用 multipart/form-data。本文代码选择的是后者,因此使用 @RequestPart。判断 Content-Type 时应先看实际传递的是 URL 还是文件,不能只看接口名称。

本地接口接收 JSON,其中 file 是 Base64 字符串:

@HttpExchange(accept = ["application/json"])
interface LocalPaddleOcrClient {
    @PostExchange(contentType = "application/json")
    fun parseLayout(
        uri: URI,
        @RequestBody request: LocalLayoutParsingRequest,
    ): LocalLayoutParsingResponse
}

现在,@RequestPart 和 @RequestBody 不再争夺同一个方法。它们分别描述各自服务端真正接受的请求。

需要特别说明:两个 HTTP Client 本身主要是外部接口的代理,还不是完整的适配器。真正完成请求转换、流程转换和响应转换的是下一层 Gateway。

7. Gateway 具体适配了什么

Client 只描述 HTTP 请求,Gateway 才负责把业务输入转换成供应商请求,并把供应商响应转换回领域结果。

以本地 OCR 为例,Gateway 完成了四步转换:

  1. 把 ByteArray 文件内容编码为 Base64;

  2. 根据文件扩展名生成本地接口需要的 fileType;

  3. 把 OcrInput 组装成 LocalLayoutParsingRequest;

  4. 把外部响应和 Base64 页面图片转换成 OcrParsedPage。

本地适配器的关键流程如下:

override fun recognize(input: OcrInput): List<OcrParsedPage> {
    val encodedFile = Base64.getEncoder().encodeToString(input.content)
    val response =
        client.parseLayout(
            uri = URI.create(properties.layoutParsingUrl),
            request = LocalLayoutParsingRequest(file = encodedFile),
        )

    return response.result.layoutParsingResults.mapIndexed { index, result ->
        OcrParsedPage(
            pageIndex = index,
            mainResult = result.toDomain(),
            pageImageBytes = decodeBase64(result.inputImage),
        )
    }
}

这段代码可以按适配器模式的经典角色理解:

适配器模式角色

当前代码

职责

Client

OcrGatewayRouter

只按照统一接口发起识别

Target

OcrProvider

系统希望使用的目标接口

Adapter

LocalPaddleOcrGateway

转换请求、调用外部接口、转换响应

Adaptee

LocalPaddleOcrClient

被适配的本地 OCR 接口代理

这里设计模式中的 Client 指“使用目标接口的调用方”,不是名字中带有 Client 的 HTTP 接口。LocalPaddleOcrClient 反而属于被适配的一端。

云端适配器则负责另一套流程:

文件二进制
  -> multipart 提交任务
  -> 获取 jobId
  -> 轮询任务状态
  -> 下载 JSONL 结果
  -> 下载页面图片
  -> 转换为 OcrParsedPage

两条流程差异很大,但差异全部停留在基础设施层。应用层只看到相同的输入和输出。

因此,LocalPaddleOcrGateway 和 BaiduPaddleOcrGateway 同时具有两个观察角度:

  • Router 把它们当作 OcrProvider,这是策略模式;

  • 它们把外部协议转换为系统模型,这是适配器模式。

简单来说,策略模式负责“选谁”,适配器模式负责“怎么翻译”。

8. 外部 DTO 为什么不能直接进入业务层

外部接口通常会返回大量技术字段,例如模型参数、检测阈值、下载地址和供应商错误码。如果业务代码直接使用这些 DTO,供应商协议就会逐渐扩散到整个系统。

因此我们在边界处做一次显式转换:

internal fun PaddlePageResult.toDomain(): OcrMainResult =
    OcrMainResult(
        width = width,
        height = height,
        blocks = blocks.map { block ->
            OcrParsingBlock(
                label = block.label,
                content = block.content,
                bbox = block.bbox,
            )
        },
    )

这层转换经常被称为防腐层。它的作用不是消除所有字段复制,而是防止外部模型决定内部模型的形状。

本例中,云端和本地服务的外层响应不同,但部分页面结果都来自 PaddleOCR,结构确实相同。因此可以共享稳定的 PaddleOCR 结果 DTO,同时把云端任务响应和本地同步响应分别定义。共享的是已确认相同的协议片段,不是为了减少文件数量而强行复用。

9. 配置只负责选择,不负责实现逻辑

配置结构可以保持简单:

app:
  ocr:
    provider: ${OCR_PROVIDER:local}
  baidu-ocr:
    token: ${BAIDU_OCR_TOKEN:}
    job-url: ${BAIDU_OCR_JOB_URL:}
    model: PaddleOCR-VL
  local-ocr:
    layout-parsing-url: ${LOCAL_OCR_LAYOUT_URL:}
    ocr-url: ${LOCAL_OCR_URL:}

对应的配置类型:

@ConfigurationProperties(prefix = "app")
data class AppProperties(
    val ocr: OcrProperties = OcrProperties(),
    val baiduOcr: BaiduOcrProperties = BaiduOcrProperties(),
    val localOcr: LocalOcrProperties = LocalOcrProperties(),
)

data class OcrProperties(
    val provider: OcrProviderType = OcrProviderType.LOCAL,
)

enum class OcrProviderType {
    BAIDU,
    LOCAL,
}

Spring Boot 会把 app.ocr.provider 绑定到枚举。配置值写成 local、LOCAL 或 Local 都能识别,但团队最好统一一种写法。

供应商的模型参数不需要全部放进配置。只有确实存在环境差异、部署差异或运维调整需求的参数才值得配置化。固定的接口默认值留在请求对象或适配器中,通常更容易维护。

10. 这套设计用了哪些模式

模式或原则

代码位置

解决的问题

依赖倒置

OcrRecognitionGateway

应用层不依赖具体供应商

端口与适配器

应用端口与两个 Gateway

外部系统被限制在基础设施边界

策略模式

OcrProvider 的不同实现

多套实现可以替换

注册表/路由

OcrGatewayRouter

根据配置查找策略

适配器模式

Baidu、Local Gateway

吸收请求和响应协议差异

防腐层

toDomain() 映射

外部 DTO 不污染领域模型

构造器注入

各组件构造参数

依赖显式且容易测试

这些模式不是为了“用模式而用模式”。它们分别对应了真实变化:供应商会变化、HTTP 协议不同、业务输出必须稳定。

11. Spring 在背后做了什么

把 Spring 的行为拆开看,并没有太多魔法:

  1. @ConfigurationPropertiesScan 找到配置类,把 YAML 绑定成 Kotlin 对象;

  2. @Component 和 @Service 把 Router、Adapter、Service 注册成 Bean;

  3. Spring 根据构造函数类型注入依赖;

  4. List<OcrProvider> 会得到全部供应商实现;

  5. OcrService 需要一个 OcrRecognitionGateway,容器中承担这个角色的是 Router;

  6. Router 根据配置把调用转发给对应 Adapter。

如果暂时不理解 Bean 生命周期,也可以先把 Spring 容器想象成一个帮我们创建对象并连接构造参数的对象仓库。设计是否清晰,仍然取决于每个接口表达的职责。

12. 怎样测试隔离是否真的成立

仅仅启动成功还不够。至少需要三类测试。

12.1 Router 测试

使用两个假的 Adapter,验证配置为 LOCAL 时只调用本地实现:

val router = OcrGatewayRouter(
    properties = propertiesOf(OcrProviderType.LOCAL),
    adapters = listOf(baiduAdapter, localAdapter),
)

router.recognize(sampleInput)

assertFalse(baiduAdapter.called)
assertTrue(localAdapter.called)

12.2 HTTP 契约测试

不调用真实 OCR 服务,而是使用 Mock Server 检查:

  • 云端提交请求必须是 multipart/form-data,并包含 file、model 等 part;

  • 本地请求必须是 application/json,文件内容位于 JSON 的 file 字段;

  • 请求字段使用服务端要求的 camelCase 或 snake_case;

  • 响应缺少可选字段时仍能正常反序列化。

12.3 Adapter 测试

给 Gateway 注入假的 Client,验证:

  • 文件是否被正确转换为 Base64;

  • PDF 与图片的 fileType 是否正确;

  • 页面图片是否被正确下载或解码;

  • 外部响应是否转换为统一领域结果;

  • 辅助 OCR 失败时是否按既定策略降级。

这三类测试分别保护“选对实现”“发对请求”和“转对结果”。

13. 常见误区

13.1 只抽一个公共 HTTP Client

业务能力相同,不代表 HTTP 契约相同。公共抽象应该建立在稳定的业务语义上,而不是建立在碰巧都使用 HTTP 这一点上。

13.2 在 Service 中判断供应商

这样会让应用层知道供应商名称,并随着供应商增加不断修改。选择逻辑应该集中在 Router,协议逻辑应该留在 Adapter。

13.3 让外部响应 DTO 充当领域模型

短期少写了一次转换,长期会让数据库、接口层和业务规则都依赖供应商字段。

13.4 为了复用而共享全部 DTO

只有确认语义和结构相同的协议片段才应该共享。云端任务状态与本地同步响应显然不是同一契约,应当分别建模。

13.5 把所有参数都做成配置

配置越多不代表扩展性越好。大量几乎不会变化的布尔参数会增加部署和排障成本。配置应该表达环境差异,代码默认值负责表达稳定策略。

14. 总结

这次改造最关键的结论并不是 multipart/form-data 和 application/json 应该怎么写,而是抽象应该放在哪里。

  • 应用层抽象稳定的 OCR 能力;

  • Router 负责选择实现;

  • Adapter 负责吸收供应商差异;

  • Client 负责描述真实 HTTP 契约;

  • Mapper 负责保护内部领域模型。

当外部系统发生变化时,我们不需要把变化藏进一个越来越复杂的 Gateway,而是给变化安排一个清晰的边界。这正是设计模式最实际的价值。

15. 参考资料

附录 A:完整结构参考代码

下面给出一套可独立理解的结构代码。为了避免示例变成供应商字段手册,只保留与隔离设计有关的请求和响应字段。

A.1 目录结构

ocr/
  application/
    OcrRecognitionGateway.kt
    OcrService.kt
  domain/
    OcrModels.kt
  infrastructure/gateway/
    OcrProvider.kt
    OcrGatewayRouter.kt
    BaiduPaddleOcrClient.kt
    BaiduPaddleOcrGateway.kt
    LocalPaddleOcrClient.kt
    LocalPaddleOcrGateway.kt
    OcrProviderModels.kt
config/
  AppProperties.kt
  HttpInterfaceConfig.kt

A.2 领域模型

package com.example.ocr.domain

data class OcrInput(
    val fileName: String,
    val content: ByteArray,
)

data class OcrParsedPage(
    val pageIndex: Int,
    val text: String,
    val pageImageBytes: ByteArray,
)

A.3 应用层端口与服务

package com.example.ocr.application

import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import org.springframework.stereotype.Service

interface OcrRecognitionGateway {
    fun recognize(input: OcrInput): List<OcrParsedPage>
}

@Service
class OcrService(
    private val recognitionGateway: OcrRecognitionGateway,
) {
    fun recognize(input: OcrInput): List<OcrParsedPage> {
        val pages = recognitionGateway.recognize(input)
        require(pages.isNotEmpty()) { "OCR must return at least one page" }
        return pages
    }
}

A.4 配置对象与扫描入口

package com.example

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan

@SpringBootApplication
@ConfigurationPropertiesScan
class ExampleApplication
package com.example.config

import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "app")
data class AppProperties(
    val ocr: OcrProperties = OcrProperties(),
    val baiduOcr: BaiduOcrProperties = BaiduOcrProperties(),
    val localOcr: LocalOcrProperties = LocalOcrProperties(),
)

data class OcrProperties(
    val provider: OcrProviderType = OcrProviderType.LOCAL,
)

enum class OcrProviderType {
    BAIDU,
    LOCAL,
}

data class BaiduOcrProperties(
    val token: String = "",
    val jobUrl: String = "",
    val model: String = "PaddleOCR-VL",
)

data class LocalOcrProperties(
    val layoutParsingUrl: String = "",
)

A.5 OcrProvider 与 Router

package com.example.ocr.infrastructure.gateway

import com.example.config.AppProperties
import com.example.config.OcrProviderType
import com.example.ocr.application.OcrRecognitionGateway
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import org.springframework.stereotype.Component

internal interface OcrProvider {
    val type: OcrProviderType

    fun recognize(input: OcrInput): List<OcrParsedPage>
}

@Component
internal class OcrGatewayRouter(
    properties: AppProperties,
    providers: List<OcrProvider>,
) : OcrRecognitionGateway {
    private val selectedProvider = properties.ocr.provider
    private val providersByType = providers.associateBy(OcrProvider::type)

    init {
        require(providersByType.keys.containsAll(OcrProviderType.entries)) {
            "OCR providers are incomplete"
        }
    }

    override fun recognize(input: OcrInput): List<OcrParsedPage> =
        requireNotNull(providersByType[selectedProvider]) {
            "OCR provider is not configured: $selectedProvider"
        }.recognize(input)
}

A.6 外部协议模型

package com.example.ocr.infrastructure.gateway

data class OcrJobResponse(
    val data: OcrJobData?,
)

data class OcrJobData(
    val jobId: String,
)

data class OcrJobStatusResponse(
    val data: OcrJobStatusData?,
)

data class OcrJobStatusData(
    val state: String,
    val resultUrl: OcrResultUrl? = null,
)

data class OcrResultUrl(
    val jsonUrl: String?,
)

data class LocalLayoutParsingRequest(
    val file: String,
    val fileType: Int,
    val visualize: Boolean = true,
)

data class LocalLayoutParsingResponse(
    val errorCode: Int,
    val errorMsg: String,
    val result: LocalLayoutParsingResult,
)

data class LocalLayoutParsingResult(
    val layoutParsingResults: List<PaddlePageResult>,
)

data class PaddlePageResult(
    val markdown: PaddleMarkdown,
    val inputImage: String?,
)

data class PaddleMarkdown(
    val text: String,
)

data class JsonlEnvelope(
    val result: JsonlResult,
)

data class JsonlResult(
    val layoutParsingResults: List<PaddlePageResult>,
)

A.7 两套 HTTP Client

package com.example.ocr.infrastructure.gateway

import org.springframework.core.io.Resource
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.service.annotation.GetExchange
import org.springframework.web.service.annotation.HttpExchange
import org.springframework.web.service.annotation.PostExchange
import java.net.URI

@HttpExchange
interface BaiduPaddleOcrClient {
    @PostExchange(
        contentType = "multipart/form-data",
        accept = ["application/json"],
    )
    fun submitJob(
        uri: URI,
        @RequestHeader("Authorization") authorization: String,
        @RequestPart("model") model: String,
        @RequestPart("optionalPayload") optionalPayload: String,
        @RequestPart("file") file: Resource,
    ): OcrJobResponse

    @GetExchange(accept = ["application/json"])
    fun getJobStatus(
        uri: URI,
        @RequestHeader("Authorization") authorization: String,
    ): OcrJobStatusResponse

    @GetExchange
    fun download(uri: URI): ByteArray
}

@HttpExchange(accept = ["application/json"])
interface LocalPaddleOcrClient {
    @PostExchange(contentType = "application/json")
    fun parseLayout(
        uri: URI,
        @RequestBody request: LocalLayoutParsingRequest,
    ): LocalLayoutParsingResponse
}

A.8 本地适配器

package com.example.ocr.infrastructure.gateway

import com.example.config.AppProperties
import com.example.config.OcrProviderType
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import org.springframework.stereotype.Component
import java.net.URI
import java.util.Base64

@Component
class LocalPaddleOcrGateway(
    properties: AppProperties,
    private val client: LocalPaddleOcrClient,
) : OcrProvider {
    override val type = OcrProviderType.LOCAL
    private val localProperties = properties.localOcr

    override fun recognize(input: OcrInput): List<OcrParsedPage> {
        val encodedFile = Base64.getEncoder().encodeToString(input.content)
        val response =
            client.parseLayout(
                uri = URI.create(localProperties.layoutParsingUrl),
                request =
                    LocalLayoutParsingRequest(
                        file = encodedFile,
                        fileType = if (input.fileName.endsWith(".pdf", true)) 0 else 1,
                    ),
            )

        check(response.errorCode == 0) { response.errorMsg }
        return response.result.layoutParsingResults.mapIndexed { index, result ->
            val image = requireNotNull(result.inputImage)
            OcrParsedPage(
                pageIndex = index,
                text = result.markdown.text,
                pageImageBytes = Base64.getMimeDecoder().decode(image.substringAfter(',')),
            )
        }
    }
}

A.9 云端适配器

package com.example.ocr.infrastructure.gateway

import com.example.config.AppProperties
import com.example.config.OcrProviderType
import com.example.ocr.domain.OcrInput
import com.example.ocr.domain.OcrParsedPage
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.springframework.core.io.ByteArrayResource
import org.springframework.stereotype.Component
import java.net.URI

@Component
class BaiduPaddleOcrGateway(
    properties: AppProperties,
    private val client: BaiduPaddleOcrClient,
) : OcrProvider {
    override val type = OcrProviderType.BAIDU
    private val baiduProperties = properties.baiduOcr
    private val objectMapper = jacksonObjectMapper()

    override fun recognize(input: OcrInput): List<OcrParsedPage> {
        val file =
            object : ByteArrayResource(input.content) {
                override fun getFilename(): String = input.fileName
            }

        val submitted =
            client.submitJob(
                uri = URI.create(baiduProperties.jobUrl),
                authorization = "bearer ${baiduProperties.token}",
                model = baiduProperties.model,
                optionalPayload = """{"useDocUnwarping":false}""",
                file = file,
            )
        val jobId = requireNotNull(submitted.data?.jobId)
        val results = waitForResults(jobId)

        return results.mapIndexed { index, result ->
            val imageUrl = requireNotNull(result.inputImage)
            OcrParsedPage(
                pageIndex = index,
                text = result.markdown.text,
                pageImageBytes = client.download(URI.create(imageUrl)),
            )
        }
    }

    private fun waitForResults(jobId: String): List<PaddlePageResult> {
        val statusUrl = "${baiduProperties.jobUrl}/$jobId"
        repeat(200) {
            Thread.sleep(5_000)
            val status =
                client.getJobStatus(
                    uri = URI.create(statusUrl),
                    authorization = "bearer ${baiduProperties.token}",
                )
            when (status.data?.state) {
                "done" -> {
                    val resultUrl = requireNotNull(status.data.resultUrl?.jsonUrl)
                    return parseJsonl(client.download(URI.create(resultUrl)))
                }

                "failed" -> error("OCR job failed")
            }
        }
        error("OCR job timed out")
    }

    private fun parseJsonl(bytes: ByteArray): List<PaddlePageResult> =
        bytes.toString(Charsets.UTF_8)
            .lineSequence()
            .filter(String::isNotBlank)
            .flatMap { line ->
                objectMapper.readValue<JsonlEnvelope>(line)
                    .result
                    .layoutParsingResults
                    .asSequence()
            }.toList()
}

A.10 HTTP Interface 配置

package com.example.config

import com.example.ocr.infrastructure.gateway.BaiduPaddleOcrClient
import com.example.ocr.infrastructure.gateway.LocalPaddleOcrClient
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
import org.springframework.web.client.support.RestClientAdapter
import org.springframework.web.service.invoker.HttpServiceProxyFactory

@Configuration
class HttpInterfaceConfig {
    private fun factory(): HttpServiceProxyFactory =
        HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(RestClient.create()))
            .build()

    @Bean
    fun baiduPaddleOcrClient(): BaiduPaddleOcrClient =
        factory().createClient(BaiduPaddleOcrClient::class.java)

    @Bean
    fun localPaddleOcrClient(): LocalPaddleOcrClient =
        factory().createClient(LocalPaddleOcrClient::class.java)
}

实际项目还应补充连接超时、读取超时、统一异常转换和敏感信息脱敏。它们属于 HTTP 基础设施的生产化要求,不改变本文介绍的隔离结构。