和外部服务打交道的那一侧:接收第三方回调、通过邮件短信推送和企业 IM 发通知、管理通知偏好与退订、用 OAuth 接入用户的第三方账号,以及调用第三方 API 时要处理的限流、配额和沙箱。
业务代码只调用自己的通知或集成适配层,服务商 SDK 和服务商地址只出现在适配层里
怎么检查:用依赖边界 lint(如 dependency-cruiser、import-linter、ArchUnit)禁止适配层以外的模块引用服务商 SDK 包和服务商域名
为什么、来源
为什么:服务商 SDK 散落在业务代码里,换服务商、加测试替身、统一处理超时和日志都要改遍全仓库。
- 小系统在同一进程里做一层适配即可,不必为此拆服务;有多租户、偏好、回执需求后再考虑独立的通知服务。
- 适配层同时声明能力:支持哪些消息类型、能否查状态、能否收回执、是否支持幂等键。调用方按能力降级,不假设各家一样。
调用第三方时遵守对方的限流:收到 429 或 503 按 Retry-After 等待,没有时退避加抖动;按服务商公布的配额做客户端限流,各层重试不叠加
怎么检查:用模拟服务返回 429/503(带和不带 Retry-After),配合假时钟检查等待时间和总次数;评审时检查 SDK、网关、任务队列是否各自开着重试
为什么、来源
为什么:不理会限流会被服务商封禁账号;SDK、网关、队列各重试三次,叠起来就是几十倍的请求。
Retry-After 可能是秒数,也可能是一个 HTTP 日期,两种都要解析。
- 服务商常有自己的限流响应头(剩余配额、重置时间),适配层读出来用于客户端限流。
- 多副本共享配额时,用共享的计数,见 18。
- 超时和重试的通用原则见 COD-033、COD-034。
有副作用的外部请求(发送、扣款、开通)在发出后超时或断连,把结果标为「未知」,先查状态或对账再决定,不直接当失败重发
怎么检查:故障注入测试:让模拟服务执行成功但不返回响应,检查系统进入「未知」状态并走查询或对账,没有重复发送
为什么、来源
为什么:请求可能已经在对方那里成功了;盲目重发会重复扣费、重复发短信。
- 对方提供状态查询接口的,自动补查;没有的,按副作用成本决定是否人工处理。
- 长时间查不到结果的,保持「未知」并交人工,不自动改成失败或成功。
- 连接还没建立就失败的(如 DNS 失败、连接被拒),可以按正常的重试处理。
可能重试的外部写请求带一个持久化的去重键;服务商支持幂等键时必须传
怎么检查:测试用同一个业务操作连续调用两次,检查服务商侧(模拟服务)只产生一次副作用;去重键在本地有唯一约束
为什么、来源
为什么:网络重试、任务重放都会让同一个请求发出多次,只有去重键能让对方识别出是同一笔。
去重键在发请求前生成并存盘,重试时复用同一个,不在每次重试时重新生成。幂等键的语义见 API-019,幂等表的结构见 DB-028。
按错误类别处理第三方返回:参数错误和业务拒绝不重试;认证失败暂停该连接并告警;退订、拉黑、永久退信记入抑制名单
怎么检查:适配层合同测试逐类返回错误,检查是否重试、是否暂停、是否写抑制名单
为什么、来源
为什么:用一个统一的「重试三次」对付所有错误,会对失效的密钥反复请求,对已退订的用户反复发送。
- 不要为了绕过接收方的拒绝、黑名单或合规拦截而切换服务商重发。
- 服务商整体故障时可以降级(如改发站内信);切换到备用服务商前确认对方有同样的资质和能力,验证码不发到未经批准的新通道。
测试和 CI 不得真实发送消息或产生扣费:用 fake 或服务商沙箱;沙箱、预发、生产的凭据和推送环境分开
怎么检查:CI 默认禁止出网(或只放行沙箱域名),统计真实外发次数必须为 0;检查各环境的密钥命名和配置,生产凭据不出现在非生产环境
为什么、来源
为什么:测试里给真实手机号发短信、用生产凭据扣款,既是事故也是费用。APNs 等服务的沙箱和生产环境不能混用。
- 邮件测试用 Mailpit 或 GreenMail 截获,不发到真实邮箱。
- 服务商的沙箱行为和生产不完全一样(限流、回执、支付方式),上线前还要在生产做小流量验证。
每个适配层有合同测试,覆盖成功、4xx、5xx、429、超时、断连、乱序回执和缺字段
怎么检查:在 CI 里对每个适配层跑同一套合同测试,用模拟服务返回各类情况
为什么、来源
为什么:只测成功路径的集成,上线后第一次遇到限流或超时就出事。
webhook 地址、机器人地址、预签名链接都当作凭据:加密保存,不进日志、错误信息和群聊
怎么检查:日志脱敏测试;对日志和配置跑密钥扫描,规则包含常见的机器人地址格式
为什么、来源
为什么:Slack、飞书、钉钉等的 webhook 地址本身就带着发送权限,泄露了任何人都能往群里发消息。
声称支持断网部署时,必须跑断网验收;断网模式下不得悄悄回退去调用公网服务
怎么检查:在禁止出网的环境里启动并跑核心流程,同时记录 DNS 和出站连接,必须为 0
为什么、来源
为什么:适配层能在内网部署,不等于 APNs、FCM、云短信、Slack 这些公网服务能在内网用;悄悄回退公网会违反客户的网络要求。
「服务能断网启动」和「消息能送到目标」是两件事,分别写明。断网时只有内网邮件服务器、内网 IM、站内信这类目标还能送达。
短信、推送、地图等按量计费的外部服务,按项目和租户设预算上限和异常告警
怎么检查:检查预算配置存在;用模拟流量验证超限时告警并停发
为什么、来源
为什么:被刷一个晚上的短信费可能超过一年的正常用量。
收到第三方回调时,先按服务商的算法对原始请求字节验签,通过后才解析和使用其中的字段
怎么检查:用服务商的官方测试向量或官方验签库写测试:正确签名通过;改动请求体、时间戳或签名任意一处都失败;先解析 JSON 再重新序列化验签的写法被测试抓出
为什么、来源
为什么:不验签任何人都能伪造回调;先解析再序列化会改变字节,导致验签失败后有人干脆关掉验签。
- 优先用服务商的官方 SDK 验签,不自己实现;只能自己实现时,用官方测试向量覆盖。
- 签名比较用常量时间比较。
- 服务商不支持签名的,用专属的随机回调地址加网络限制,并记录这是风险接受,不把它标成「已验签」。只靠 IP 白名单不够。
服务商按 Standard Webhooks 签名时,用官方库验签:检查 webhook-timestamp 在容忍窗口内,逐个尝试 webhook-signature 里的签名,任一匹配即通过
怎么检查:用官方库的测试:过期和未来过远的时间戳被拒;轮换期间带两个签名时新旧密钥都能通过;篡改 webhook-id 失败
参考值:容忍窗口常用 5 分钟,是参考值;规范没有规定具体值。
为什么、来源
为什么:时间戳窗口挡住重放;签名头里可能同时有多个签名(密钥轮换期间),只取第一个会误拒。
签名内容是「webhook-id.webhook-timestamp.原始请求体」。webhook-id 在重试时不变,用作去重键。发布端的规则见 API-028。
回调内容既有签名又有加密时,先验签再解密;「能解密」不能当作「已验签」
怎么检查:测试用签名错误但能正常解密的回调,必须被拒绝
为什么、来源
为什么:解密用的密钥和验签用的密钥不是一回事,能解密只说明对方知道加密密钥,不说明消息来自服务商。微信支付 APIv3 官方文档专门指出过这个错误。
验签通过后,先把回调写入收件箱表(按服务商和事件 ID 唯一),再返回 2xx;业务处理放到异步任务里
怎么检查:测试同一事件并发投递多次,收件箱只有一条、业务只处理一次;在处理逻辑里注入异常,检查已返回 2xx 的事件之后仍会被处理
为什么、来源
为什么:在回调请求里同步跑完所有下游,容易超时让对方重发;不落库就返回 2xx,进程一崩事件就丢了。
- 服务商没有事件 ID 的,用其文档说明的字段组合去重,不用整个 JSON 的哈希。
- 收件箱表和任务处理见 17、18。
回调处理按至少一次投递设计:容忍重复、乱序和迟到;状态只能沿规定方向推进,迟到的旧状态不覆盖更新的状态
怎么检查:测试按乱序投递一组回调(如先「退信」后「已受理」),检查最终状态正确;用带旧状态条件的更新语句或乐观锁保证不倒退
为什么、来源
为什么:服务商普遍只保证至少一次,顺序不保证;晚到的「已受理」覆盖「已退信」会让系统继续向无效地址发信。
支付等资金结果以「服务端验签回调 + 主动查单 + 定期对账」为准,不凭客户端成功页发货;金额用最小货币单位的整数
怎么检查:测试只有客户端成功、没有回调时不发货;对账任务用有差异的账单样例跑,差异进入人工处理队列;金额字段类型检查不允许浮点数
为什么、来源
为什么:回调可能丢、可能被伪造,客户端可以被篡改;只有和渠道账单逐笔核对才能发现漏单和错单。
- 退款、部分退款、关闭、过期分开建模。
- 不自己收集和存储银行卡号,用支付商的托管收银台或令牌化。
- 以微信支付为例,按日下载交易和资金账单,并校验文件哈希后逐笔核对。
重要通知的发送意图和业务写入在同一个事务里提交(outbox),不在业务代码里直接调用发送接口
怎么检查:测试业务事务回滚时没有通知发出、提交后通知一定会发出;评审时查找业务事务里直接调用发送接口的写法
为什么、来源
为什么:直接发送时,业务回滚了信可能已经发出去,业务提交了进程崩溃信又没发。
第三方调用不放在数据库事务里。outbox 的表结构和中继见 DB-026、MID-013。
送达状态分清「服务商已受理」「已送达」「已读」;没有对应回执的保持「未知」或「不支持」,不把接口返回成功当成已送达
怎么检查:状态机测试:只有收到明确回执才能进入「已送达」「已读」;统计报表区分各状态
为什么、来源
为什么:接口返回 200 只说明服务商收下了;把它当送达会算出虚高的送达率,也看不出真正的问题。
- 邮件的「已送达」通常只表示对方邮件服务器收下了,不代表进了收件箱。
- 记录每个状态的来源(接口返回、回执、查询、人工)和时间。
通知带用途(交易、安全、营销、告警)和紧急程度两个字段;营销退订不影响交易和安全通知,强制发送的通知类别要经产品和合规确认
怎么检查:检查通知定义都带用途字段;测试退订营销后交易通知照常发送;强制发送的类别列表有评审记录
为什么、来源
为什么:把营销也标成「高优先级」,会绕过用户的退订和静默设置;反过来退订误伤交易通知,用户收不到订单信息。
通知偏好按「类别 × 渠道」保存;静默时段按用户时区计算;退订记录来源和时间,并立即生效
怎么检查:用假时钟测试跨时区和夏令时的静默时段;测试退订后下一条营销通知不再发出
为什么、来源
为什么:偏好只做成一个总开关,用户要么全收要么全关;按服务器时区算静默会在凌晨打扰用户。
偏好、模板和本地化放在自己的系统里,不放进第三方 SDK 的配置。营销同意与退订的法规要求见 27。
频控分层:同一事件先合并,再按用户、号码或设备、租户、服务商分别限频;告警默认逐级升级,不同时多渠道轰炸
怎么检查:用假时钟和并发测试验证各层限频;同一故障的告警按事件编号聚合
为什么、来源
为什么:一次故障触发几百条重复告警,用户会关掉所有通知。
通知模板的文案放进资源文件,按收件人的语言和时区渲染;模板带版本号,发送记录里记下用了哪个版本
怎么检查:i18n 检查覆盖邮件和通知模板;发送记录字段检查
为什么、来源
为什么:邮件和推送模板最容易漏掉翻译;没有模板版本就查不清用户当时看到的是什么。
文案写法、复数和日期格式见 09(A11Y-037 等)。
生产发信域必须配置 SPF、DKIM 和 DMARC,且 From 域与 SPF 或 DKIM 对齐;有效的反向解析和 TLS 发信
怎么检查:上线前和定期用 DNS 查询检查三条记录;发一封到测试邮箱,检查认证结果头里 SPF、DKIM、DMARC 都是 pass
参考值:Gmail 把 24 小时内向个人 Gmail 账号发送约 5,000 封及以上的定为批量发件人,DMARC 策略至少 p=none,垃圾邮件投诉率要低于 0.3%。
为什么、来源
为什么:Gmail 要求批量发件人三者都配,不合规的邮件会被临时或永久拒收;不等量大了再补,一开始就配。
营销和订阅邮件必须有可见的退订入口,并带 RFC 8058 一键退订头(List-Unsubscribe 和 List-Unsubscribe-Post),两天内处理退订
怎么检查:邮件头测试检查两个头存在且格式正确;集成测试向退订地址发 POST 后,该地址进入退订名单
为什么、来源
为什么:Gmail 对批量发件人强制要求一键退订;退订难的邮件会被标成垃圾邮件,连累同域的交易邮件。
交易邮件(验证码、订单、账单)不需要一键退订,也不要把它们误发成营销邮件。
交易、安全、营销邮件用不同的发送身份或子域;硬退信和投诉的地址进入抑制名单,停止非必要发送
怎么检查:回放退信和投诉回执,检查地址进入抑制名单、下一次发送被拦截
为什么、来源
为什么:营销邮件的投诉会拖累同一发送身份的验证码邮件;继续向退信地址发信会损害发信声誉。
营销群发不要和验证码这类安全邮件共用一套系统和发送身份。
邮件模板里的变量必须做 HTML 转义;每封 HTML 邮件同时提供纯文本版本
怎么检查:模板快照测试里放入带 HTML 和脚本的变量,检查输出已转义;检查发出的邮件有 text/plain 部分
为什么、来源
为什么:未转义的用户输入会把钓鱼链接或伪造内容注入到以你的域名发出的邮件里。
用 React Email 或 MJML 生成邮件 HTML,不复用网页 CSS、不手拼 HTML;在主流客户端(含 Outlook、Gmail、移动端)预览。
发短信的接口在服务端按号码、账号、设备、IP、租户多维限频,并设每日费用上限和告警;不只靠前端倒计时和服务商的默认限频
怎么检查:用模拟攻击流量(轮换号码、轮换 IP)测试限频和费用熔断生效
为什么、来源
为什么:短信接口是盗刷的常见目标,攻击者用大量号码刷费用;服务商的限频只按「签名 × 号码」,挡不住轮换号码。
- 号码统一成 E.164 格式后再计数。
- 可疑号段或地区可以自动熔断。
- 验证码的有效期、一次性消费和校验次数见 19。
验证码和完整手机号不写进日志、监控标签、队列错误信息和群聊告警
怎么检查:日志脱敏测试;在测试数据里放已知验证码和号码,扫描日志和告警输出中不出现
为什么、来源
为什么:日志里的验证码等于可用的登录凭据;完整手机号是个人信息。
国内短信只用已审核通过的签名和模板,验证码、通知、营销分别用对应用途的模板;营销短信上线前按 27 做法规检查
怎么检查:上线前核对服务商控制台里的签名和模板审核结果,并和代码里使用的模板编号对照
为什么、来源
为什么:未审核的签名和模板发不出去;为提高送达率换用其他用途的模板会违反服务商规则。
APNs 用基于令牌的认证(.p8 密钥签 JWT),复用 HTTP/2 长连接;JWT 每 20 到 60 分钟刷新一次,不每条消息新建连接或新签令牌
怎么检查:评审推送客户端的连接和令牌缓存逻辑;压测时观察连接数和是否出现 TooManyProviderTokenUpdates
为什么、来源
为什么:Apple 规定令牌刷新不能早于 20 分钟、超过 1 小时会被拒;频繁换令牌或新建连接会被限流。
多台服务器共用一个密钥时,由一处统一签发令牌,或错开刷新时间。APNs 接收了不等于设备已展示。
FCM 用 HTTP v1 接口和服务账号的 OAuth 2.0 访问令牌(或官方 Admin SDK),不得使用已关停的旧版接口和服务器密钥
怎么检查:代码扫描旧接口地址 fcm.googleapis.com/fcm/send 和服务器密钥的用法
为什么、来源
为什么:旧版 FCM 接口已在 2024 年关停,按旧教程写的代码发不出去。
中国大陆的安卓设备大多收不到 FCM,国内厂商推送通道见 28。
设备令牌按用户、应用、平台、环境分开保存;服务商返回令牌失效时立即停用,用户退出登录或解绑时删除
怎么检查:测试模拟服务返回令牌失效错误后,该令牌不再被使用;测试退出登录后令牌被删除
为什么、来源
为什么:向失效令牌持续发送会被服务商限流;退出登录后还推送,可能把别人的通知发到旧设备上。
敏感内容不放进推送标题和正文,需要时显示为中性提示,用户打开应用后再拉取详情。
IM 适配层按「纯文本、卡片、交互」三档声明能力;目标不支持交互回调时(如群自定义机器人),不发带操作按钮的卡片
怎么检查:每个 IM 适配层有能力声明,渲染测试检查不支持交互的目标收到的消息里没有操作按钮
为什么、来源
为什么:飞书等的群自定义机器人只能往群里发消息,点了卡片按钮服务端收不到回调;用户以为点了「批准」,其实什么都没发生。
- 纯文本是所有目标都要有的降级格式。
- 内部用中性的消息结构(标题、段落、字段、操作、降级文本),由各适配层渲染,不把 Slack 或飞书的卡片格式当通用格式。
- Slack 的 Incoming Webhook 不能删改已发消息,交互要用 Slack 应用。
接收 IM 交互回调时按平台要求验签;按钮动作绑定业务幂等键,并校验点击者有权执行这个动作
怎么检查:测试伪造签名的交互请求被拒;同一按钮点两次只执行一次;无权限的用户点击被拒
为什么、来源
为什么:群里的人不一定有权限批准;按钮被重复点击或回调被重放,会重复执行业务动作。
新建的 Microsoft Teams 集成不得使用已退役的 Office 365 Connectors(旧式 Incoming Webhook),改用 Workflows 或 Teams 机器人
怎么检查:代码和配置扫描旧连接器的地址格式(webhook.office.com/webhookb2/)
为什么、来源
为什么:微软已于 2026 年 5 月完成旧连接器的停用,按旧教程接入的消息发不出去。
Workflows 依赖 Power Automate,上线前确认授权和流程的归属人。
接入用户的第三方账号时用授权码流程加 PKCE 和 state,只申请当前功能需要的权限范围
怎么检查:评审授权请求参数(code_challenge、state、scope);测试回调里 state 不匹配时被拒
为什么、来源
为什么:OAuth 安全最佳实践(RFC 9700)要求用授权码加 PKCE、不用隐式流程;state 防止攻击者把自己的账号绑到受害者名下。
回调地址精确匹配,不用通配。我方用户自己的登录见 19。
第三方访问令牌和刷新令牌加密存储,只在服务端使用,不下发到前端、不写进日志
怎么检查:数据库字段检查为加密存储;日志脱敏测试;接口响应扫描不含令牌
为什么、来源
为什么:刷新令牌长期有效,泄露后攻击者可以一直以用户身份访问其第三方账号。
刷新令牌时同一连接只允许一个刷新在执行,保存服务商返回的新刷新令牌;刷新被拒(如 invalid_grant)时把连接标为失效并提示用户重新授权,不反复重试
怎么检查:并发测试多个请求同时遇到令牌过期,只发生一次刷新;测试刷新被拒后连接状态变为失效
为什么、来源
为什么:很多服务商会轮换刷新令牌,并发刷新时后到的请求拿着已作废的旧令牌,会让整个连接失效。
用户断开第三方连接或注销账号时,服务商支持撤销接口的调用撤销(RFC 7009),并删除本地保存的令牌
怎么检查:测试断开连接后本地令牌被删除、撤销接口被调用;评审每个连接的撤销实现
为什么、来源
为什么:只删本地记录,令牌在服务商那边仍然有效,泄露过的副本照样能用;用户也会以为已经断开了。
没有符合条件的规则。
通知编排与站内信
项目内的轻量通知模块(outbox + 适配层),复用现有的数据和权限 · Novu 核心为 MIT,`enterprise/` 等目录为商业许可(企业版功能),自托管前确认要用的功能属于哪部分
备选:Novu(需要用户偏好中心、多步骤工作流、可嵌入的收件箱,且团队能运维它);商业通知平台(不要求私有化、希望少运维)
不推荐:只发两类告警就引入完整通知平台
轻量多目标通知
Apprise · BSD-2-Clause
不推荐:把 Apprise 的 URL 配置格式开放给不可信用户填写
事务邮件发送
云邮件服务的 API(如 Amazon SES、Postmark、Resend;国内如阿里云、腾讯云邮件推送),或客户现有的 SMTP 中继 · Postal 为 MIT
备选:Postal(必须自建发信平台,且有人负责 IP 声誉、反向解析和投诉处理)
不推荐:自己写或自己搭公网 SMTP 服务就指望高送达
邮件模板
React Email(React/TypeScript 项目) · 均为 MIT
备选:MJML(跨语言或非 React 项目)
不推荐:手拼 HTML 或复用网页 CSS
营销与订阅邮件
listmonk · AGPL-3.0;作为独立服务运行与集成进产品分发的要求不同,口径见 27
备选:商业邮件营销平台(不要求私有化)
不推荐:用营销系统发验证码等安全邮件
邮件测试
Mailpit · Mailpit 为 MIT;GreenMail 为 Apache-2.0
备选:GreenMail(Java 项目要在测试里内嵌 SMTP/IMAP 服务)
不推荐:测试环境直接发到真实邮箱
国内短信
阿里云短信或腾讯云短信
备选:有合规资质的短信聚合商(需要多通道灾备)
不推荐:用国际短信通道发国内业务
回调验签
服务商官方 SDK 的验签函数;服务商用 Standard Webhooks 的,用 Standard Webhooks 官方库
备选:自己实现验签(官方没有提供该语言的库;必须用官方测试向量覆盖)
不推荐:先解析 JSON 再重新序列化后验签;只靠 IP 白名单
iOS 推送(服务端)
直连 APNs 的 HTTP/2 接口,基于令牌认证;用所用语言里成熟的 APNs 客户端库
不推荐:证书认证的新接入
Android 推送(服务端,海外)
Firebase Admin SDK(底层为 FCM HTTP v1)
备选:直接调用 FCM HTTP v1(所用语言没有 Admin SDK)
不推荐:FCM 旧版接口
企业 IM 通知
只推送通知用群机器人或 Incoming Webhook;需要交互、读用户信息、私聊时用应用机器人
不推荐:群自定义机器人做审批按钮;Teams 旧 Office 365 Connectors
接入第三方账号(OAuth 客户端)
服务商官方 SDK 或所用语言里成熟的 OAuth 2.0 客户端库,开启 PKCE
不推荐:自己拼授权和换令牌请求;隐式授权流程
✕ 错误做法:在订单服务里直接调用发短信、发邮件
✓ 应该:通知意图和业务一起写入 outbox,由后台任务发送
✕ 错误做法:请求超时了就赶紧再发一条
✓ 应该:标为结果未知,用幂等键、查状态或对账确认
✕ 错误做法:服务商接口返回 200 就记为「已送达」
✓ 应该:记为「已受理」,有回执再推进状态
✕ 错误做法:所有错误统一重试三次
✓ 应该:按错误类别决定重试、暂停或进抑制名单
✕ 错误做法:先把回调 JSON 解析成对象,再序列化回去验签
✓ 应该:保留原始请求字节验签
✕ 错误做法:回调能正常解密就当它是真的
✓ 应该:先验签,再解密
✕ 错误做法:用户在客户端看到支付成功页就发货
✓ 应该:以验签回调、主动查单和对账为准
✕ 错误做法:把 webhook 机器人地址贴在文档、日志或工单里
✓ 应该:当作密钥保管,泄露后立即重置
✕ 错误做法:用群自定义机器人的卡片按钮做审批
✓ 应该:用应用机器人,验签并校验点击者权限
✕ 错误做法:照着旧教程给 Teams 接 Office 365 Connector
✓ 应该:用 Workflows 或 Teams 机器人
✕ 错误做法:只靠前端 60 秒倒计时防短信盗刷
✓ 应该:服务端多维限频加费用封顶
✕ 错误做法:用营销系统发验证码
✓ 应该:交易、安全、营销分开发送身份和系统
✕ 错误做法:第三方刷新令牌明文存库、多个请求同时刷新
✓ 应该:加密存储,同一连接只允许一个刷新
✕ 错误做法:私有化部署就声称所有通知渠道都能离线用
✓ 应该:分开写明「服务能断网启动」和「消息能送到哪里」,并跑断网验收