Ch 16 API、SaaS 与邮件连接器¶
面包屑
本书主页 › Part III 数据工程实践 › Ch 16
项目第 1 年 · 核心建设期——API/邮件连接器
本章你将学到¶
- 通用 REST 客户端设计:认证体系、分页策略、容错重试
- SaaS 平台批量抽取与双向任务监控
- Salesforce Bulk API 2.0 选型,以及补齐 simple-salesforce 生态缺口的开源贡献路径
- 企业邮件附件自动化摄取的架构设计
16.1 通用 REST 客户端设计:认证体系、分页策略、容错重试¶
API 连接器是最复杂的连接器类型——每个 API 的认证方式、分页方式、错误码都不一样。通用 REST 客户端需要把这些差异抽象成可配置的策略。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
subgraph 通用REST客户端["通用 REST 客户端架构"]
AUTH[认证策略层<br/>Token / API Key / OAuth / 签名]
REQ[请求构造层<br/>URL / Headers / Params / Body]
PAGE[分页策略层<br/>Offset / Cursor / Page / Link]
RETRY[容错重试层<br/>指数退避 / 熔断 / 限流]
PARSE[响应解析层<br/>JSON / XML / CSV]
end
AUTH --> REQ --> PAGE --> RETRY --> PARSE
class AUTH,REQ,PAGE,RETRY,PARSE bpProcess
图 16-1 通用 REST 客户端设计:认证体系、分页策略、容错重试
认证策略¶
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
subgraph 可配置认证体系
direction TB
subgraph Token认证
BEARER[Bearer Token<br/>请求头携带固定 Token]
end
subgraph Key认证
APIKEY[API Key<br/>请求头/参数携带 Key]
end
subgraph OAuth认证
OAUTH[OAuth 2.0<br/>刷新 Token 流程]
end
subgraph 签名认证
SIGN[签名认证<br/>HMAC 签名请求]
end
end
BEARER -->|适合| BEARER_USE[简单 API<br/>固定 Token]
APIKEY -->|适合| APIKEY_USE[第三方平台<br/>Key 认证]
OAUTH -->|适合| OAUTH_USE[SaaS 平台<br/>Salesforce 等]
SIGN -->|适合| SIGN_USE[企业内部 API<br/>HMAC 签名]
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
class BEARER,APIKEY,OAUTH,SIGN bpProcess
class BEARER_USE,APIKEY_USE,OAUTH_USE,SIGN_USE bpDecision
图 16-2 认证策略
| 认证策略 | 机制 | 适合场景 | 配置要点 | 安全考虑 |
|---|---|---|---|---|
| Bearer Token | 请求头携带固定 Token | 简单 API | Token 存 Secrets Manager | Token 泄露风险 |
| API Key | 请求头/参数携带 Key | 第三方平台 | Key 存 Secrets Manager | Key 轮转策略 |
| OAuth 2.0 | 刷新 Token 流程 | SaaS 平台(Salesforce 等) | Client ID/Secret + 刷新流程 | Token 自动刷新 |
| 签名认证 | HMAC 签名请求 | 企业内部 API | 密钥 + 签名算法 | 请求防篡改 |
表 16-1 认证策略矩阵
| 认证策略 | 适合场景 | 配置要点 |
|---|---|---|
| Bearer Token | 简单 API | Token 存 Secrets Manager |
| API Key | 第三方平台 | Key 存 Secrets Manager |
| OAuth 2.0 | SaaS 平台( Salesforce 等) | Client ID/Secret + 刷新流程 |
| 签名认证 | 企业内部 API | 密钥 + 签名算法 |
表 16-2 认证策略
引申
认证策略的可配置化是"策略模式"的典型应用。不要为每种认证写一个客户端——定义统一的认证接口,不同策略实现不同接口,运行时按配置选择。新增一种认证方式只需加一个策略实现。
把策略模式落到代码,就是统一认证接口 + 各策略实现,OAuth2 客户端凭证流是最典型也最复杂的一种:
# 示意:认证策略模式——统一接口 + OAuth2 客户端凭证流实现
from abc import ABC, abstractmethod
import requests, boto3
class AuthStrategy(ABC):
"""统一认证接口:给请求注入凭证。"""
@abstractmethod
def apply(self, request: dict) -> dict: ...
class BearerTokenAuth(AuthStrategy):
def __init__(self, token_secret_arn: str):
self.token = boto3.client("secretsmanager").get_secret_value(SecretId=token_secret_arn)["SecretString"]
def apply(self, request: dict) -> dict:
request["headers"]["Authorization"] = f"Bearer {self.token}" # 核心意图:固定 Token
return request
class OAuth2ClientCredentialsAuth(AuthStrategy):
def __init__(self, token_url, client_id, client_secret_arn):
self.token_url, self.client_id = token_url, client_id
self.client_secret = boto3.client("secretsmanager").get_secret_value(SecretId=client_secret_arn)["SecretString"]
self._token, self._expires_at = None, 0
def apply(self, request: dict) -> dict:
if time.time() >= self._expires_at: # 核心意图:Token 过期自动刷新
resp = requests.post(self.token_url, data={
"grant_type": "client_credentials",
"client_id": self.client_id, "client_secret": self.client_secret})
self._token = resp.json()["access_token"]
self._expires_at = time.time() + resp.json()["expires_in"] - 60 # 提前 60s 刷新
request["headers"]["Authorization"] = f"Bearer {self._token}"
return request
分页策略¶
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
subgraph 四种分页策略
direction TB
OFFSET[Offset 分页<br/>?offset=0&limit=100<br/>简单但大偏移性能差]
CURSOR[Cursor 分页<br/>?cursor=xxx<br/>高性能但需 API 支持]
PAGE[Page 分页<br/>?page=1&size=100<br/>最常见]
LINK[Link 分页<br/>响应头/体含下一页 URL<br/>RESTful 标准]
end
OFFSET -->|选择| OFFSET_USE[简单 API<br/>数据量小]
CURSOR -->|选择| CURSOR_USE[高性能需求<br/>大数据量]
PAGE -->|选择| PAGE_USE[通用场景<br/>最常见]
LINK -->|选择| LINK_USE[RESTful API<br/>标准实现]
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
class OFFSET,CURSOR,PAGE,LINK bpProcess
class OFFSET_USE,CURSOR_USE,PAGE_USE,LINK_USE bpDecision
图 16-3 分页策略
| 分页策略 | 参数示例 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| Offset 分页 | ?offset=0&limit=100 |
简单直观 | 大偏移性能差(需跳过大量记录) | 简单 API、数据量小 |
| Cursor 分页 | ?cursor=xxx |
高性能(基于游标) | 需 API 支持游标 | 高性能需求、大数据量 |
| Page 分页 | ?page=1&size=100 |
最常见、易理解 | 页码变化时可能重复/遗漏 | 通用场景 |
| Link 分页 | 响应头含 Link: <url>; rel="next" |
RESTful 标准、自描述 | 需解析响应头 | RESTful API |
表 16-3 分页策略对比
分页策略落到代码是一个统一的迭代器——不管哪种分页方式,对外都暴露"逐页拉取 + 检查点续传"的接口,分页中断后可从上一页游标恢复:
# 示意:统一分页迭代器 + 检查点续传
def paginate(client, url, auth, page_strategy, checkpoint=None):
cursor = checkpoint or page_strategy.initial_cursor() # 核心意图:中断可续传
while cursor is not None:
resp = auth.apply({"url": url, "params": page_strategy.params(cursor)}).request()
if not resp.ok:
raise PageError(cursor, resp.status_code) # 失败时保存 cursor 到 DLQ
yield resp.json()["items"] # 逐页产出
save_checkpoint(url, cursor) # 持久化游标(DynamoDB)
cursor = page_strategy.next_cursor(resp) # Link: resp.headers["Next"]; Cursor: resp["next_cursor"]
容错重试¶
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TD
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
REQ[发送请求] --> Q{响应状态}
Q -->|200| OK[成功]
Q -->|429 限流| WAIT[等待 + 指数退避]
Q -->|5xx 服务端错误| WAIT
Q -->|4xx 客户端错误| FAIL[失败(不重试)]
WAIT -->|重试次数 < 上限| REQ
WAIT -->|重试次数 ≥ 上限| FAIL
class REQ bpProcess
class Q bpDecision
class OK bpSuccess
class FAIL bpError
class WAIT bpProcess
图 16-4 容错重试
| 重试场景 | 策略 |
|---|---|
| 429 Too Many Requests | 指数退避 + 尊重 Retry-After 头 |
| 5xx 服务端错误 | 指数退避重试 |
| 4xx 客户端错误 | 不重试(请求本身有问题) |
| 网络超时 | 重试 + 告警 |
表 16-4 容错重试
容错重试的标准实现是指数退避 + 抖动装饰器,对可重试错误(5xx/429/超时)自动重试,对不可重试错误(4xx)立即失败:
# 示意:指数退避重试装饰器(含抖动,避免惊群效应)
import random, time
RETRYABLE = {429, 500, 502, 503, 504}
def retry_with_backoff(max_retries=5, initial=1.0, max_wait=60.0, multiplier=2.0):
def decorator(fn):
def wrapper(*args, **kwargs):
attempt, wait = 0, initial
while True:
try:
return fn(*args, **kwargs)
except (HTTPError, Timeout) as e:
if getattr(e, "status", 0) not in RETRYABLE or attempt >= max_retries:
raise # 4xx 或达上限:不重试
jitter = random.uniform(0, wait * 0.3) # 核心意图:抖动打散重试
time.sleep(min(wait + jitter, max_wait)) # 指数退避 + 上限
wait *= multiplier
attempt += 1
return wrapper
return decorator
速率限制与重试¶
重试解决"偶发失败",但有些 API 失败的根因是我们自己打太快了——超过 API 的速率限制。对这类 API,光重试不够,还要主动限流。常用的是令牌桶算法:以固定速率往桶里放令牌,每次请求消耗一个令牌,桶空了就等:
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpError fill:#fff1f1,stroke:#da1e28,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
F[固定速率<br/>放令牌] --> BUCKET[令牌桶<br/>容量=100]
REQ[请求到来] --> TAKE{桶里有令牌?}
TAKE -->|是| CONSUME[消耗1个令牌<br/>发送请求]
TAKE -->|否| WAIT2[等待令牌补充<br/>或排队]
class F,REQ bpProcess
class BUCKET bpData
class TAKE bpDecision
class CONSUME bpSuccess
class WAIT2 bpDecision
图 16-5 速率限制与重试
| 限流机制 | 做法 | 适用 |
|---|---|---|
| 令牌桶 | 固定速率放令牌,请求消耗令牌,桶空则等 | 允许短时突发(桶容量内) |
| 漏桶 | 请求进队列,固定速率出队处理 | 严格匀速,无突发 |
| 并发数限制 | 限制同时进行中的请求数 | 保护下游连接池 |
表 16-5 速率限制与重试
限流要和重试配合——限流在前(主动避免超限),重试在后(被动应对超限后的 429)。两者一起构成 API 连接器的"流量治理"层。在分页迭代器里,每个 request() 调用前先过令牌桶,429 后由重试装饰器兜底。
Trade-off
重试是必要的,但"暴力重试"可能加剧 API 服务端压力("惊群效应")。指数退避(每次等待时间翻倍)+ 抖动(加随机偏移)是标准做法。另外要区分"可重试错误"(5xx/429)和"不可重试错误"(4xx),避免无意义重试。
16.2 SaaS 平台批量抽取与双向任务监控¶
上一节把认证、分页、重试收敛成了可配置策略。这对「偶尔调用一次」的内部 API 够用。但以 Salesforce 为代表的 SaaS 源不是这个量级:企业内部往往有多套基于 Salesforce 开发的 CRM(客户主数据、拜访活动、MTM 等),对象多,日增量与周期性全量都大,连接器必须同时扛住吞吐与稳定性。于是 SaaS 连接器多出两个硬需求:批量抽取和双向任务监控。
批量抽取¶
项目第一年做 SaaS 连接器时,我面对的不是「接一个 Salesforce org」,而是多 org、多业务对象并行进湖。处方、客户、活动这类核心对象动辄百万行;业务又要求 T+1 可用,失败重跑不能拖垮整条日批。规模一旦到这个档位,§16.1 的 REST 分页客户端就会从「慢」变成「不可用」。
我最初确实按通用 REST 思路走:SOQL SELECT ... LIMIT 200 翻页拉处方。拉到大约第 50 页,Salesforce 返回 429。原因不是 QPS 打爆,而是 REST 调用计入 24 小时滚动 API Request Limits(按 org license 计算,Enterprise Edition 常见量级是 100k/24h + 用户数×200)。几百万行按 200 条/页要几万次请求,远超配额。SaaS 限流模型是日/小时滚动配额,不是内部 API 常见的 QPS;把「通用 REST 连接器」原样套到 Salesforce,是迁移时最容易踩的坑。
接下来要在三条路上选:继续 REST(加更狠的限流与隔夜续跑)、Bulk API 1.0,还是 Bulk API 2.0。
| 路径 | 限额与吞吐模型 | 作业形态 | 对 Glue Python Shell 的代价 |
|---|---|---|---|
| REST Query | 计入 24h API Request Limits;百万级对象易耗尽配额 | 同步分页,客户端自己拼页 | 实现简单,但规模上不可用 |
| Bulk API 1.0 | 面向大批量,但仍要自行管理 batch/job 细节 | XML/作业+批次两级模型 | 能跑,运维与排障面更大 |
| Bulk API 2.0 | 异步作业;查询一次提交、结果按 CSV 分页下载;ingest 单次约 100MB CSV | REST 作业化:创建 → 执行/上传 → 轮询 → 下载 | 与长任务天然契合,客户端状态机更短 |
表 16-10 Salesforce 抽取路径对比:REST / Bulk 1.0 / Bulk 2.0
我最终钉在 Bulk API 2.0:一次提交 SOQL,Salesforce 后台异步跑完,客户端轮询状态后批量下载,几百万行不必再按页「啃」配额。不是「Bulk 比 REST 更时髦」,而是规模决定架构(M11):SaaS 连接器默认就要假设数据量会逼你走作业化批量通道。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
sequenceDiagram
participant G as Glue Job
participant SF as Salesforce Bulk API
activate G
G->>SF: 创建批量作业(SOQL 查询)
activate SF
SF-->>G: 返回 Job ID
deactivate SF
G->>SF: 轮询 Job 状态
activate SF
SF-->>G: Completed
deactivate SF
G->>SF: 下载结果(CSV/JSON)
activate SF
SF-->>G: 返回批量数据
deactivate SF
G->>G: 解析并写入 S3
deactivate G
图 16-6 批量抽取
图 16-6 是 Aurora SaaS 连接器对 Bulk 2.0 的最小闭环:创建作业 → 轮询 → 下载 → 落 S3。重点不在多几个 HTTP 步骤,而在把「拉取」从同步请求改成了可观测的长任务。Job ID 可以写入任务表,失败可按作业重试,不用在第 N 页丢游标后整段重来。对接 Salesforce 这类高吞吐 SaaS,连接器的一等公民应是「作业」,不是「单次 HTTP」。
协议选对了,还缺一块:Python 里得有一个扛得住生产的 Bulk 2.0 客户端。下一节讲我当时为什么没能「直接 pip install 完事」,以及最后怎么补上这个缺口。
补齐生态缺口:为 simple-salesforce 贡献 Bulk 2.0¶
Aurora 的摄取跑在 Glue Python Shell 上,团队栈是 Python。协议层选定 Bulk API 2.0 之后,我去找现成的 SDK,结论很不舒服:当时没有达到我们吞吐与稳定性标准的官方 Python Bulk 2.0 客户端;社区里相对成熟的是非官方库 simple-salesforce,但它主路径还停在 REST 与较老的 Bulk 1.0,缺 Bulk 2.0。
摆在桌上有几条路:
- 自己写一层薄封装:上线最快,但作业状态机、CSV 分片、结果下载、错误分类全要自建,安全补丁与 API 变更也全自己扛。
- 连接器里直接拼 REST:可行,等于把 SDK 该做的事搬进每个 Glue Job,配置驱动会迅速腐化成「每个对象一份私货」。
- 站在成熟社区库上补齐缺口:评审和兼容要花时间,但登录、会话、重试、对象语法可以复用,平台只维护「多出来的那一截」。
我选了第三条。不是因为开源情怀,而是平台工程的算账:Salesforce 是 Aurora 的关键入湖路径之一,私有 fork 三年后一定比上游贡献更贵。于是我在 simple-salesforce 上设计并实现了 bulk2:查询作业与 ingest 作业(insert/update/upsert/delete)、状态轮询、结果下载,以及面向 100MB 上限的 CSV 分片;并向上游提交了 PR #648(2023 年合并)。后来生产里又撞上「字段内逗号、换行、非常规分隔符」把朴素按行切分搞崩的问题。分片一旦静默截断,就是假成功:作业显示完成,落湖数据却缺列或缺行。我用标准库 csv 重做了分片,支持多种列分隔符与换行约定,并修掉相关缺陷,再以 PR #736 合入。
这两次贡献让我更清楚 Bulk 2.0 在卖什么:不是「另一个 endpoint」,而是作业化 + CSV 管道。客户端必须把「创建 / 上传或查询 / 轮询 / 下载」收成稳定状态机;分片必须遵守 CSV 语义,否则特殊字符会把稳定性问题伪装成偶发数据质量事故。Aurora 的 SaaS 连接器就建在补齐后的客户端之上:多 org、多对象靠配置驱动调度,Glue Job 只编排作业生命周期并落 S3。后来 Ch 37 做 DaaS 异步 Bulk 时,状态机直接借鉴了 Salesforce Bulk v2 这套语义,内外一致。
Trade-off
上游贡献不是免费的:评审周期、API 兼容、测试矩阵都要时间;紧急故障时你也不能「只改自己仓库就上线」。但相比长期维护私有 fork,把关键路径摊到社区维护面上,总成本更低,也避免每个项目各自造一套半残 Bulk 客户端。我后来的体会是:生态不够用时,补生态往往比绕开生态更划算。
抽得进只是一半。数据还要在 CDP 与 Salesforce 之间「对得上账」。下一节的双向任务监控,就是为「假成功」准备的第二道闸。
双向任务监控¶
批量通道解决了「怎么稳定地大进大出」;双向监控解决的是跨系统协作里更隐蔽的问题:一侧说成功、另一侧没收到。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
subgraph 双向监控["CDP ↔ Salesforce 双向任务监控"]
CDP_TO_SF@{ icon: "codicon:sync", form: "rounded", label: "CDP → Salesforce<br/>导出任务记录写回 SF", pos: "b", h: 40 }
SF_TO_CDP@{ icon: "logos:salesforce", form: "rounded", label: "Salesforce → CDP<br/>SF 侧任务状态同步回 CDP", pos: "b", h: 40 }
end
CDP_TO_SF --> LOG_SF@{ icon: "logos:salesforce", form: "rounded", label: "SF 侧记录 CDP 任务执行", pos: "b", h: 36 }
SF_TO_CDP --> LOG_CDP@{ icon: "codicon:database", form: "rounded", label: "CDP 侧记录 SF 任务状态", pos: "b", h: 36 }
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
class CDP_TO_SF,SF_TO_CDP bpProcess
class LOG_SF,LOG_CDP bpData
linkStyle default stroke:#697077,stroke-width:2px
图 16-7 双向任务监控
双向监控的价值是端到端可观测——无论从 CDP 侧还是 Salesforce 侧,都能看到任务的完整状态。这在跨系统协作时非常重要——"数据导出到 Salesforce 了没?"不需要登两个系统查。
这个双向监控设计,是我在第一年的一次"跨系统甩锅"事件后加的。当时有个导出任务从 CDP 推数据到 Salesforce,CDP 侧显示"成功",但 Salesforce 侧没有数据。CDP 团队说"我们推成功了",Salesforce 管理员说"我们没收到"——双方各看各的系统,查了一天才发现在 CDP 成功后、Salesforce 接收前,网络中断了——数据丢了。这次事件让我意识到:跨系统任务不能只看一侧的状态——必须有双向监控。CDP 侧记录"我推了什么",Salesforce 侧记录"我收到了什么",两边对账才能发现中间丢失。从那以后,所有跨系统任务都配双向监控+对账——CDP 推送后查 Salesforce 是否收到,Salesforce 接收后回执 CDP。跨系统协作的最大风险不是"失败",而是"假成功"——一侧显示成功但另一侧没收到,这种"静默丢失"比报错更危险。
16.3 企业邮件附件自动化摄取¶
邮件附件是医药行业常见的数据交换方式——业务团队习惯用 Excel 整理数据,通过邮件发送给数据平台。这类"非标准"数据源需要专门的连接器处理。
两类邮件摄取场景¶
| 场景 | 数据源 | 技术栈 | 触发方式 |
|---|---|---|---|
| AWS SES 企业邮箱 | 供应商/分销商 | SES + Lambda | 事件驱动(实时) |
| Outlook 企业邮箱 | 内部业务用户 | Microsoft Graph API + Glue | 定时轮询(T+1) |
表 16-6 两类邮件摄取场景
场景一:AWS SES 企业邮箱(供应商数据)¶
供应商通过企业邮箱发送 CSV/Excel 附件,平台通过 SES 事件驱动实时处理。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart LR
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
subgraph 外部["外部系统"]
SUPPLIER@{ icon: "codicon:person", form: "rounded", label: "供应商", pos: "b", h: 48 }
EMAIL@{ icon: "logos:google-gmail", form: "rounded", label: "邮件附件", pos: "b", h: 48 }
end
subgraph aws["AWS 云平台"]
SES@{ icon: "logos:aws-ses", form: "rounded", label: "SES 接收", pos: "b", h: 48 }
LAMBDA@{ icon: "logos:aws-lambda", form: "rounded", label: "Lambda 处理", pos: "b", h: 48 }
S3@{ icon: "logos:aws-s3", form: "rounded", label: "S3 Landing", pos: "b", h: 48 }
SF@{ icon: "logos:aws-step-functions", form: "rounded", label: "Step Functions", pos: "b", h: 48 }
end
subgraph 安全层["安全过滤"]
FILTER@{ icon: "codicon:shield", form: "rounded", label: "白名单 + 类型限制", pos: "b", h: 48 }
DEDUP@{ icon: "codicon:check", form: "rounded", label: "Message-ID 去重", pos: "b", h: 48 }
end
SUPPLIER -->|发送| EMAIL
EMAIL -->|邮件到达| SES
SES -->|触发| FILTER
FILTER -->|验证通过| LAMBDA
LAMBDA -->|解析附件| DEDUP
DEDUP -->|幂等写入| S3
S3 -->|信号触发| SF
class SUPPLIER,EMAIL bpExternal
class SES,LAMBDA,SF bpProcess
class S3 bpData
class FILTER,DEDUP bpSuccess
图 16-8 AWS SES 邮件摄取架构
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
subgraph SES设计要点["AWS SES 邮件摄取设计要点"]
direction TB
R1@{ icon: "logos:aws-ses", form: "rounded", label: "SES 接收规则<br/>Receipt Rule 匹配发件人白名单", pos: "b", h: 48 }
R2@{ icon: "logos:aws-s3", form: "rounded", label: "S3 存储<br/>邮件和附件存储到 S3", pos: "b", h: 48 }
R3@{ icon: "codicon:shield", form: "rounded", label: "安全过滤<br/>白名单 + .csv/.xlsx 限制", pos: "b", h: 48 }
R4@{ icon: "codicon:check", form: "rounded", label: "去重机制<br/>Message-ID 幂等处理", pos: "b", h: 48 }
end
R1 --> R2
R2 --> R3
R3 --> R4
class R1,R2 bpProcess
class R3,R4 bpSuccess
图 16-9 AWS SES 邮件摄取设计要点
场景二:Outlook 企业邮箱(业务用户数据)¶
这是更常见的场景——业务用户习惯用 Excel 整理数据,通过 Outlook 邮件发送给数据平台专用邮箱。平台通过 Microsoft Graph API 定时扫描未读邮件,按配置规则匹配目标邮件,解析附件后进入标准数据管线。
业务场景¶
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpUser fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
subgraph 用户层["业务用户操作"]
USER@{ icon: "codicon:person", form: "rounded", label: "业务用户", pos: "b", h: 48 }
EXCEL@{ icon: "vscode-icons:file-type-excel", form: "rounded", label: "Excel 数据文件", pos: "b", h: 48 }
end
subgraph 邮件层["邮件系统"]
OUTLOOK@{ icon: "vscode-icons:file-type-outlook", form: "rounded", label: "Outlook 邮箱", pos: "b", h: 48 }
SUBJECT@{ icon: "codicon:tag", form: "rounded", label: "主题规则匹配", pos: "b", h: 48 }
end
subgraph 平台层["数据平台"]
GLUE@{ icon: "logos:aws-glue", form: "rounded", label: "Glue Job 扫描", pos: "b", h: 48 }
GRAPH@{ icon: "logos:microsoft-icon", form: "rounded", label: "Graph API", pos: "b", h: 48 }
S3@{ icon: "logos:aws-s3", form: "rounded", label: "S3 Landing", pos: "b", h: 48 }
end
subgraph 管线层["标准数据管线"]
SF@{ icon: "logos:aws-step-functions", form: "rounded", label: "Step Functions", pos: "b", h: 48 }
PIPELINE@{ icon: "codicon:git-merge", form: "rounded", label: "数据管线", pos: "b", h: 48 }
end
USER -->|整理数据| EXCEL
EXCEL -->|邮件发送| OUTLOOK
OUTLOOK -->|定时扫描| GRAPH
GRAPH -->|查询未读| SUBJECT
SUBJECT -->|规则匹配| GLUE
GLUE -->|下载附件| S3
GLUE -->|标记已读| OUTLOOK
S3 -->|信号触发| SF
SF -->|Landing→Raw→SQL ELT| PIPELINE
class USER bpUser
class EXCEL bpExternal
class OUTLOOK,SUBJECT bpExternal
class GLUE,GRAPH,SF bpProcess
class S3,PIPELINE bpData
图 16-10 Outlook 邮件摄取业务流程
典型场景:
- 市场准入数据:市场团队每月整理医院准入状态 Excel,邮件发送至 data-platform@aurora.com
- 销售目标数据:销售运营每季度更新目标分配 Excel,邮件主题格式 [数据平台] 销售目标-Q2-2026
- 合规报告数据:质量团队按月发送 GxP 合规检查 Excel,邮件主题格式 [数据平台] GxP合规报告-202606
架构设计¶
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8','fontSize':'14px'}}}%%
flowchart TB
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
subgraph trigger["定时触发"]
EB[EventBridge<br/>每日定时]
end
subgraph ingestion["摄取管线"]
SF[Step Functions<br/>Ingestion 状态机]
GLUE[Glue Job<br/>Outlook Connector]
end
subgraph external["外部系统"]
OUTLOOK[Outlook 邮箱<br/>data-platform@aurora.com]
MSG[Microsoft Graph API]
end
subgraph config["配置层"]
DDB[DynamoDB<br/>邮件匹配规则]
end
subgraph storage["存储层"]
S3[S3 Landing]
end
EB -->|触发| SF
SF -->|启动| GLUE
GLUE -->|读取配置| DDB
GLUE -->|Graph API| MSG
MSG -->|查询未读邮件| OUTLOOK
GLUE -->|下载附件| S3
GLUE -->|标记已读| OUTLOOK
class EB bpDecision
class SF,GLUE bpProcess
class OUTLOOK,MSG bpExternal
class DDB bpInfo
class S3 bpData
图 16-11 Outlook 邮件摄取架构设计
邮件匹配规则配置¶
业务开发通过 DynamoDB 配置邮件匹配规则,无需修改代码:
// 示意:DynamoDB 邮件匹配规则配置
{
"connector_id": "outlook-market-access",
"mailbox": "data-platform@aurora.com",
"match_rules": {
"subject_pattern": "\\[数据平台\\]\\s*市场准入.*",
"sender_whitelist": ["user1@aurora.com", "user2@aurora.com"],
"attachment_extensions": [".xlsx", ".xls"],
"attachment_name_pattern": "准入状态.*\\.xlsx"
},
"target": {
"domain": "ma",
"entity": "hospital_access_status",
"s3_path": "s3://ap-aurora-cdp-landing/ma/hospital_access_status/"
},
"schedule": "cron(0 8 * * ? *)"
}
| 配置字段 | 说明 | 示例 |
|---|---|---|
connector_id |
连接器唯一标识 | outlook-market-access |
mailbox |
目标邮箱地址 | data-platform@aurora.com |
subject_pattern |
邮件主题正则匹配 | \\[数据平台\\]\\s*市场准入.* |
sender_whitelist |
发件人白名单 | ["user1@aurora.com"] |
attachment_extensions |
允许的附件扩展名 | [".xlsx", ".xls"] |
attachment_name_pattern |
附件名正则匹配 | 准入状态.*\\.xlsx |
target.domain |
目标业务域 | ma |
target.entity |
目标数据实体 | hospital_access_status |
表 16-7 邮件匹配规则配置字段
Glue Job 核心逻辑(伪代码)¶
# 示意:Outlook Connector Glue Job 核心逻辑
import msal
import requests
import boto3
import re
from datetime import datetime
class OutlookConnector:
"""Outlook 邮件连接器:通过 Microsoft Graph API 扫描邮箱、下载附件、标记已读。"""
def __init__(self, config):
self.config = config
self.mailbox = config["mailbox"]
self.dynamodb = boto3.resource("dynamodb")
def get_access_token(self):
"""获取 Microsoft Graph API 访问令牌(OAuth2 客户端凭证流)。"""
app = msal.ConfidentialClientApplication(
client_id=self.config["client_id"],
client_credential=self.config["client_secret"],
authority=f"https://login.microsoftonline.com/{self.config['tenant_id']}"
)
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
return result["access_token"]
def scan_unread_messages(self, access_token):
"""扫描未读邮件,按配置规则过滤。"""
# 核心意图:查询未读邮件,按主题正则匹配
filter_expr = f"isRead eq false and contains(subject, '{self.config['match_rules']['subject_keyword']}')"
url = f"https://graph.microsoft.com/v1.0/users/{self.mailbox}/messages"
headers = {"Authorization": f"Bearer {access_token}"}
params = {"$filter": filter_expr, "$top": 50}
response = requests.get(url, headers=headers, params=params)
messages = response.json().get("value", [])
# 按发件人白名单和主题正则进一步过滤
matched = []
for msg in messages:
sender = msg["sender"]["emailAddress"]["address"]
subject = msg["subject"]
if sender in self.config["match_rules"]["sender_whitelist"]:
if re.match(self.config["match_rules"]["subject_pattern"], subject):
matched.append(msg)
return matched
def download_attachments(self, access_token, message_id):
"""下载邮件附件到 S3 Landing。"""
url = f"https://graph.microsoft.com/v1.0/users/{self.mailbox}/messages/{message_id}/attachments"
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.get(url, headers=headers)
attachments = response.json().get("value", [])
s3 = boto3.client("s3")
for att in attachments:
if att["@odata.type"] == "#microsoft.graph.fileAttachment":
filename = att["name"]
# 检查附件扩展名和名称模式
if self._match_attachment(filename):
s3_key = f"{self.config['target']['s3_path']}{filename}"
s3.put_object(
Bucket=s3_key.split("/")[2],
Key="/".join(s3_key.split("/")[3:]),
body=att["contentBytes"] # Base64 解码
)
def mark_as_read(self, access_token, message_id):
"""处理完成后将邮件标记为已读。"""
url = f"https://graph.microsoft.com/v1.0/users/{self.mailbox}/messages/{message_id}"
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
payload = {"isRead": True}
requests.patch(url, headers=headers, json=payload)
def _match_attachment(self, filename):
"""检查附件是否匹配配置的扩展名和名称模式。"""
ext = "." + filename.split(".")[-1].lower()
if ext not in self.config["match_rules"]["attachment_extensions"]:
return False
if "attachment_name_pattern" in self.config["match_rules"]:
return re.match(self.config["match_rules"]["attachment_name_pattern"], filename)
return True
Microsoft Graph API 认证配置¶
Outlook 连接器使用 OAuth2 客户端凭证流(Client Credentials Flow)访问 Microsoft Graph API:
| 配置项 | 说明 | 存储位置 |
|---|---|---|
tenant_id |
Azure AD 租户 ID | Secrets Manager |
client_id |
应用注册 Client ID | Secrets Manager |
client_secret |
应用注册 Client Secret | Secrets Manager |
mailbox |
目标邮箱地址 | DynamoDB 配置 |
表 16-8 Microsoft Graph API 认证配置
Azure AD 应用注册权限:
Mail.Read— 读取邮件Mail.ReadWrite— 标记已读
与标准数据管线集成¶
Outlook 连接器是第六类连接器(文件 / JDBC / API / SaaS / 邮件-SES / 邮件-Outlook),复用平台的配置驱动架构:
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#edf5ff','primaryTextColor':'#161616','primaryBorderColor':'#0f62fe','lineColor':'#697077','secondaryColor':'#d9fbfb','tertiaryColor':'#f2f4f8'}}}%%
flowchart LR
classDef bpProcess fill:#edf5ff,stroke:#0f62fe,stroke-width:2px,color:#161616
classDef bpData fill:#d9fbfb,stroke:#007d79,stroke-width:2px,color:#161616
classDef bpDecision fill:#fcf4d6,stroke:#f1c21b,stroke-width:2px,color:#161616
classDef bpSuccess fill:#defbe6,stroke:#198038,stroke-width:2px,color:#161616
classDef bpExternal fill:#f2f4f8,stroke:#697077,stroke-width:2px,color:#161616
classDef bpInfo fill:#f6f2ff,stroke:#8a3ffc,stroke-width:2px,color:#161616
linkStyle default stroke:#697077,stroke-width:2px,fill:none
subgraph 插件层["Outlook Connector 插件"]
direction TB
GLUE[Glue Job<br/>Outlook Connector]
CONFIG[配置规则<br/>主题/发件人/附件]
AUTH[OAuth2 认证<br/>Graph API]
end
subgraph 标准管线["标准数据管线(复用)"]
direction TB
LANDING[S3 Landing]
SF[Step Functions<br/>Ingestion 状态机]
RAW[S3 Raw]
RS[Redshift enriched_*]
end
CONFIG -->|运行时读取| GLUE
AUTH -->|获取凭证| GLUE
GLUE -->|写入附件| LANDING
LANDING -->|信号触发| SF
SF -->|Landing→Raw| RAW
RAW -->|SQL ELT 入仓| RS
class GLUE,SF bpProcess
class LANDING,RAW,RS bpData
class CONFIG,AUTH bpInfo
图 16-12 Outlook 连接器与标准管线集成
| 复用组件 | 作用 |
|---|---|
| 配置驱动 | 邮件匹配规则存 DynamoDB,运行时动态读取 |
| Step Functions | 复用 Ingestion 状态机,无需新建编排 |
| 两跳 ETL | Landing → Raw → Redshift SQL ELT |
| 批次标识 | 邮件 Message-ID 作为批次标识,支持追溯 |
| 审计日志 | 邮件处理记录写入审计日志表 |
表 16-9 Outlook 连接器复用组件
Trade-off 与演进¶
Trade-off
邮件摄取是"不得已而为之"的方案——理想情况下数据应该通过 API 或 SFTP 交换。但现实是业务用户习惯用 Excel 整理数据,邮件是最自然的传递方式。平台的做法是"接受邮件但标准化处理"——通过配置驱动的连接器将邮件附件转化为标准数据管线的输入。
引申
邮件摄取的演进方向: 1. 推动自助化:将邮件匹配规则配置暴露为低代码界面(见 Ch 36),业务用户自行配置而非依赖开发 2. 推动数据源升级:邮件是"过渡方案",应推动业务改用 SFTP/API 交付 3. 智能化:未来可引入 LLM 自动识别邮件主题和附件内容,减少配置依赖
本章小结¶
- 通用 REST 客户端通过可配置策略层抽象差异:认证(Token/Key/OAuth/签名)/ 分页(Offset/Cursor/Page/Link)/ 容错(指数退避+抖动)
- SaaS 连接器需特殊处理:多 CRM / 百万级对象下必须走批量作业;Salesforce 上 REST 分页会撞上 24h API Request Limits,Bulk API 2.0 才是默认通道
- 生态不够用时补生态:在 simple-salesforce 上实现并上游贡献 Bulk 2.0(PR #648)与 CSV 分片精修(PR #736),再支撑配置驱动的多 org 抽取
- 双向任务监控补上跨系统「假成功」:CDP 与 Salesforce 两侧对账,端到端可观测
- 邮件附件摄取支持两类场景:AWS SES(供应商/事件驱动)和 Outlook(业务用户/定时轮询)
- Outlook 连接器通过 Microsoft Graph API 扫描邮箱,按配置规则(主题正则/发件人白名单/附件类型)匹配目标邮件,下载附件到 S3 后标记已读
- 邮件匹配规则存 DynamoDB,配置驱动无需改代码——第六类连接器复用标准数据管线
- 邮件摄取是过渡方案,应推动上游改用 SFTP/API 或自助化配置
下一章
Ch 17 Landing→Raw→Redshift 开发实战 —— 连接器把数据取到 Landing 了,接下来看两跳 ETL(标准化 + SQL ELT 入仓)的开发实战。