全栈手账 订阅 RSS

第三方服务集成手册

接入支付、短信、对象存储、地图、推送等外部服务时的通用方法:签名与验签、回调幂等与重试补偿、错误码映射、沙箱与正式环境隔离、密钥管理、联调排查步骤,附各类服务的接入要点与高频坑位。

读了 26 分钟 更新于 11 月 3 日

一、为什么需要一个通用方法论

第三方集成的代码形态各异,但出问题的地方高度相似:

典型故障 根因
回调重复处理导致重复发货/重复加余额 没有幂等
通知漏收,订单状态卡住 完全依赖被动回调,无主动查询补偿
抓包发现签名错误 参数排序/编码与文档不一致
上线后才发现用的是沙箱地址 环境配置未隔离
密钥泄露在日志或前端 没有分级管理

结论:与其每个服务重新踩一遍,不如建立一套通用规范。


二、通用集成五步

文本
1. 读文档 → 梳理「请求参数、签名规则、回调格式、错误码」2. 沙箱联调 → 用官方测试工具验证最小闭环3. 封装适配层 → 隔离第三方细节,业务代码不直接调 SDK4. 打通回调与查询 → 主动查询兜底被动回调5. 上监控与告警 → 调用成功率、回调延迟、失败重试

2.1 适配层封装(重要)

javascript
// ✅ 业务代码只依赖自己的接口,不直接依赖第三方 SDK// providers/sms/provider.jsclass SmsProvider {  async send(phone, templateId, params) { throw new Error('not implemented') }}// providers/sms/aliyun.jsclass AliyunSms extends SmsProvider {  async send(phone, templateId, params) {    const res = await this.client.sendSms({ PhoneNumbers: phone, TemplateCode: templateId, TemplateParam: JSON.stringify(params) })    if (res.Code !== 'OK') throw new SmsError(res.Code, res.Message)    return { requestId: res.RequestId }  }}// providers/sms/index.js —— 统一出口const provider = process.env.SMS_PROVIDER === 'aliyun' ? new AliyunSms() : new MockSms()module.exports = provider

收益:换供应商只改一个文件;测试环境用 Mock 不发真实短信;便于统一加日志、重试、限流。

2.2 HTTP 调用统一封装

javascript
async function callThirdParty({ url, method = 'POST', data, timeout = 5000 }) {  const start = Date.now()  try {    const res = await axios({ url, method, data, timeout })    logger.info({      event: 'thirdparty.call',      host: new URL(url).host,      duration: Date.now() - start,      success: true,    })    return res.data  } catch (err) {    logger.error({      event: 'thirdparty.call.failed',      host: new URL(url).host,      duration: Date.now() - start,      status: err.response?.status,      code: err.response?.data?.code,      message: err.response?.data?.message,    })    throw err  }}

必须设置超时:第三方无响应时若不设超时,会拖垮自己的线程/连接池。


三、签名与验签

3.1 三种常见签名方式

方式 原理 常见于
参数排序 + 拼接 + HMAC/MD5 参数按 key 排序拼成字符串再算摘要 支付、短信
RSA/SM2 非对称签名 私钥签名,公钥验签 支付、开放平台
Bearer Token / API Key 请求头带凭证 通用 API

3.2 参数排序签名的易错点

javascript
function buildSign(params, secret) {  // 1. 剔除空值与签名字段本身  const entries = Object.entries(params)    .filter(([k, v]) => k !== 'sign' && v !== '' && v !== null && v !== undefined)  // 2. 按 key 的字典序升序排列(注意:大小写敏感)  entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))  // 3. 拼接为 k1=v1&k2=v2(不要 URL 编码,除非文档明确要求)  const raw = entries.map(([k, v]) => `${k}=${v}`).join('&')  // 4. 追加密钥并计算摘要  return crypto.createHmac('sha256', secret).update(raw + '&key=' + secret).digest('hex').toUpperCase()}

必须在沙箱用官方工具校验一次签名结果,而不是靠读文档推测。常见的四个分歧点:

分歧点 说明
是否排序 有的要求按参数名排序,有的按提交顺序
是否 URL 编码 编码规则不同会导致签名不一致
空值是否参与 有的要剔除,有的要拼空字符串
大小写 摘要输出大写还是小写

3.3 回调验签

javascript
// 收到回调第一步:验签,通过才处理function verifyCallback(headers, rawBody, publicKey) {  const signature = headers['x-signature']  const timestamp = headers['x-timestamp']  // 防重放:时间戳超出窗口直接拒绝  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {    throw new Error('回调时间戳过期')  }  const expected = crypto    .createVerify('RSA-SHA256')    .update(`${timestamp}\n${rawBody}`)    .verify(publicKey, signature, 'base64')  if (!expected) throw new Error('签名验证失败')}

验签必须用「原始请求体字符串」,不能用解析后重新序列化的对象——键顺序与空格变化会导致验签失败。

javascript
// Koa:先拿到原始 body 再解析app.use(async (ctx, next) => {  if (ctx.path.startsWith('/callback/')) {    ctx.state.rawBody = await getRawBody(ctx.req)    ctx.request.body = JSON.parse(ctx.state.rawBody)  }  await next()})

四、回调设计与幂等

4.1 回调处理标准流程

文本
1. 验签(不通过 → 返回失败,记录日志)2. 幂等检查(已处理过 → 直接返回成功)3. 落库(记录原始回调内容,便于对账与复现)4. 业务处理(更新订单状态等)5. 返回成功(必须快速返回,耗时操作丢队列)
javascript
router.post('/callback/pay', async (ctx) => {  const raw = ctx.state.rawBody  // 1. 验签  if (!verifyCallback(ctx.headers, raw, PUBLIC_KEY)) {    ctx.status = 400    ctx.body = { code: 'FAIL', message: 'sign error' }    return  }  const payload = ctx.request.body  const eventId = payload.notifyId || payload.transactionId  // 2. 幂等:Redis 占位 + 数据库唯一索引双保险  const first = await redis.set(`cb:${eventId}`, '1', 'EX', 86400, 'NX')  if (!first) {    ctx.body = { code: 'SUCCESS' }            // 已处理,仍返回成功    return  }  try {    // 3. 落库原始回调(审计与对账)    await callbackLogRepo.save({ eventId, provider: 'pay', raw, receivedAt: new Date() })    // 4. 业务处理(用状态机 + 条件更新保证二次幂等)    await orderService.markPaid(payload.orderNo, payload.transactionId, payload.amount)    ctx.body = { code: 'SUCCESS' }  } catch (err) {    await redis.del(`cb:${eventId}`)           // 释放占位,允许对方重试    logger.error({ event: 'callback.failed', eventId, err })    ctx.status = 500    ctx.body = { code: 'FAIL' }                // 返回失败,触发对方重试  }})

4.2 三个关键原则

原则 原因
快速响应 多数网关要求 5 秒内响应,超时会重试;耗时逻辑丢队列
幂等处理 回调必然重复(网络重试 + 对方主动重发 + 人工补发)
永不信任回调内容做决策 金额、状态等关键字段要以主动查询接口为准

4.3 主动查询兜底(必做)

回调可能丢失(网络、宕机、配置错误)。只依赖回调的系统一定会出现「订单永远待支付」:

javascript
// 定时任务:补偿查询长时间未收到回调的订单cron.schedule('*/5 * * * *', async () => {  const stuck = await orderRepo.find({    status: 'pending',    createdAt: { $lt: new Date(Date.now() - 5 * 60 * 1000) },   // 5 分钟前创建    checkedCount: { $lt: 10 },                                  // 最多查 10 次  })  for (const order of stuck) {    const remote = await payProvider.query(order.orderNo)    if (remote.status === 'paid') {      await orderService.markPaid(order.orderNo, remote.transactionId, remote.amount)    }    await orderRepo.increment(order.id, 'checkedCount', 1)  }})

五、错误码映射

第三方错误码不能直接抛给用户,需要映射到自己的错误体系:

javascript
const PROVIDER_ERROR_MAP = {  'INSUFFICIENT_BALANCE': { code: 30001, message: '账户余额不足', retryable: false },  'CARD_EXPIRED':         { code: 30002, message: '银行卡已过期', retryable: false },  'SYSTEM_ERROR':         { code: 30003, message: '支付通道繁忙,请稍后重试', retryable: true },  'SIGN_ERROR':           { code: 30004, message: '签名校验失败', retryable: false, alert: true },}function mapError(providerCode) {  return PROVIDER_ERROR_MAP[providerCode] || {    code: 30099,    message: '服务暂时不可用',    retryable: false,    alert: true,                  // 未知错误要告警  }}
要点 说明
可重试标记 网络类、系统错误可重试;参数/业务拒绝不可重试
未知码要告警 说明对方新增了错误类型,需跟进适配
原始码要记录 日志里保留 providerCode,便于对方排查
不暴露内部细节 用户看到「支付通道繁忙」,而不是「SIGN_ERROR」

六、各类服务接入要点

6.1 支付类(最需要严谨)

环节 要点
金额 用整数「分」,不用浮点;服务端以自己数据库的金额为准,不接受客户端传入
订单号 自己生成、全局唯一;幂等键用订单号
签名 按文档规则;沙箱先用官方工具验证
回调 验签 + 幂等 + 落库 + 快速响应 + 主动查询兜底
退款 单独接口,需幂等;部分退款要记录明细
对账 每日下载对账单,与本地流水比对,差异要告警
证书 私钥文件不能进 Git;权限设为仅服务账号可读

对账是最后一道防线:即使回调与查询都出错,日终对账也能发现问题。

javascript
// 每日对账示例async function dailyReconcile(date) {  const remote = await payProvider.downloadBill(date)      // 对方账单  const local = await paymentRepo.findByDate(date)         // 本地流水  const remoteMap = new Map(remote.map((r) => [r.orderNo, r]))  const diffs = []  for (const l of local) {    const r = remoteMap.get(l.orderNo)    if (!r) diffs.push({ type: 'local_only', orderNo: l.orderNo, amount: l.amount })    else if (r.amount !== l.amount) diffs.push({ type: 'amount_mismatch', orderNo: l.orderNo, local: l.amount, remote: r.amount })    remoteMap.delete(l.orderNo)  }  for (const [orderNo, r] of remoteMap) {    diffs.push({ type: 'remote_only', orderNo, amount: r.amount })  }  if (diffs.length) {    logger.error({ event: 'reconcile.mismatch', date, count: diffs.length, diffs })    await alert.notify(`对账差异 ${diffs.length} 笔,日期 ${date}`)  }}

6.2 短信 / 邮件

要点 说明
频率限制 同一手机号 60 秒 1 次、每天最多 10 次(服务端强制)
验证码存储 Redis 存 key(手机号+场景),TTL 5 分钟,用后立即删除
校验次数限制 同一验证码错误超过 5 次即失效,防暴力枚举
模板管理 模板 ID 配置化,不硬编码在业务代码
内容合规 营销类短信需用户授权,避免被判垃圾短信
发送记录 记录 requestId 与状态,便于排查「用户没收到」
javascript
// 验证码发送与校验async function sendCode(phone, scene) {  const rateKey = `sms:rate:${phone}`  if (!(await redis.set(rateKey, '1', 'EX', 60, 'NX'))) {    throw new Error('请求过于频繁,请稍后再试')  }  const code = String(Math.floor(100000 + Math.random() * 900000))  await redis.set(`sms:code:${scene}:${phone}`, code, 'EX', 300)  await smsProvider.send(phone, TEMPLATE_ID[scene], { code })}async function verifyCode(phone, scene, input) {  const key = `sms:code:${scene}:${phone}`  const saved = await redis.get(key)  if (!saved) throw new Error('验证码已过期')  const errKey = `sms:err:${key}`  const errCount = Number((await redis.get(errKey)) || 0)  if (errCount >= 5) {    await redis.del(key)    throw new Error('错误次数过多,请重新获取')  }  if (saved !== input) {    await redis.incr(errKey)    await redis.expire(errKey, 300)    throw new Error('验证码错误')  }  await redis.del(key)                 // 用后立即失效}

6.3 对象存储

要点 说明
大文件直传 服务端只签发临时凭证,限制路径与大小
私有读 敏感文件用签名 URL,有效期尽量短
跨域配置 直传需在存储侧配置 CORS 白名单
生命周期 配置自动转低频/清理临时文件,控制成本
回源与 CDN 静态资源走 CDN,注意缓存刷新策略

6.4 地图 / 定位

要点 说明
密钥分级 前端用的 key 要限制域名白名单与调用配额(前端 key 一定会暴露)
坐标系统 常见两套坐标系(WGS84 与 GCJ02),混用会导致偏移几百米;明确每层用哪套
逆地理编码 有配额与频率限制,结果可缓存
降级 地图服务不可用时要有兜底(手动输入地址)

6.5 消息推送 / IM

要点 说明
Token 有效期 一般需要定期刷新,注意续期逻辑
离线推送 需在 App 侧接入各厂商通道(国内安卓生态碎片化严重)
消息幂等 客户端按消息 ID 去重,服务端保证不重复投递
敏感词 用户可见内容需过审

6.6 OAuth 第三方登录

要点 说明
state 参数 必须校验,防 CSRF;随机生成并回校验
redirect_uri 必须白名单精确匹配,不能模糊匹配
code 一次性 只能用一次,且短期有效
不信任前端传的 openid 用 code 换取的才算数
绑定逻辑 同一 openid 重复绑定需处理;解绑需二次确认

七、环境与密钥管理

7.1 三套环境严格隔离

环境 用途 注意
沙箱 / 测试 联调 官方提供的测试账号与测试卡号
预发 上线前验证 用正式配置但小额
生产 真实交易 密钥轮换需流程

最常见事故:生产环境配置里残留了沙箱地址或测试密钥。必须在配置中显式标注环境,并在启动时校验。

javascript
const REQUIRED_KEYS = ['PAY_APP_ID', 'PAY_PRIVATE_KEY', 'PAY_NOTIFY_URL']function validateConfig() {  const missing = REQUIRED_KEYS.filter((k) => !process.env[k])  if (missing.length) throw new Error(`缺少配置: ${missing.join(', ')}`)  if (process.env.NODE_ENV === 'production') {    // 生产环境禁止指向沙箱域名    if (process.env.PAY_API_HOST.includes('sandbox')) {      throw new Error('生产环境不能使用沙箱地址')    }  }}

7.2 密钥分级

级别 存放 使用范围
服务端密钥(私钥、Secret) 环境变量 / 密钥管理服务 只允许服务端使用
前端密钥(地图 key、AppID) 构建变量 必然暴露,需限制域名与配额
回调验签公钥 配置或代码常量 公开,但需防篡改(走 HTTPS 获取)

绝对禁止:把服务端密钥写进前端、写入日志、提交到 Git。


八、可观测与排障

8.1 每个调用都要能回答四个问题

文本
谁调的?(哪个业务、哪个用户、哪个订单)耗时多少?成功还是失败?失败原因是什么?对应的第三方请求 ID 是什么?
javascript
logger.info({  event: 'pay.create',  orderNo,  userId,  amount,  provider: 'pay',  providerRequestId: res.requestId,   // 找对方排查的凭证  duration,  success: true,})

8.2 建议的监控指标

指标 告警阈值
调用成功率 < 95% 持续 5 分钟
调用 P95 耗时 > 3 秒
回调到达量 相比同时段下降 50% 以上
回调验签失败数 > 0 就要关注
待处理订单积压 持续增长
对账差异笔数 > 0

8.3 联调排查顺序

文本
1. 凭据是否正确(AppID / 密钥 / 证书)2. 请求地址是否正确(沙箱 vs 生产)3. 签名是否正确(用官方工具对照)4. 参数是否完整(必填项、格式、编码)5. 网络是否可达(白名单、IP 限制、出网限制)6. 回调地址是否公网可达(本地开发需内网穿透)7. 查看对方返回的原始报文(不要只看自己封装的错误)

九、常见问题

现象 原因 处理
签名一直失败 排序/编码/空值处理与文档不符 用官方工具核对;打印待签名字符串比对
回调验签失败 用了重新序列化的 body 取原始请求体字符串验签
回调重复导致重复处理 无幂等 Redis 占位 + 数据库唯一索引
订单长时间待支付 回调丢失且无补偿 加主动查询定时任务
金额对不上 用浮点计算 / 客户端传金额 整数分 + 服务端为准
生产环境调用沙箱 配置未隔离 启动时校验环境与域名
第三方超时拖垮服务 未设超时 统一封装强制超时
无法提供排查凭证 未记录对方请求 ID 日志记录 providerRequestId

十、检查清单

接入前

  • 文档要点已梳理(参数、签名规则、回调格式、错误码)
  • 沙箱最小闭环已跑通(用官方工具验证签名)
  • 已封装适配层,业务代码不直接依赖 SDK

签名与回调

  • 签名规则经官方工具验证
  • 回调用原始 body 验签
  • 回调有时间戳窗口校验(防重放)
  • 回调有幂等(Redis + 数据库唯一索引双保险)
  • 回调落库原始报文(审计与对账)
  • 回调快速响应,耗时逻辑入队

可靠性

  • 有主动查询兜底未收到回调的业务
  • 定时补偿任务有次数上限
  • 错误码已映射,可重试/不可重试已区分
  • 未知错误码会告警
  • 所有外部调用都有超时

安全

  • 服务端密钥不入前端、不入日志、不入 Git
  • 前端 key 已限制域名与配额
  • 回调地址使用 HTTPS
  • OAuth 校验 state,redirect_uri 白名单精确匹配
  • 生产环境配置经启动校验(禁止沙箱地址)

运营

  • 关键调用有日志(含对方请求 ID)
  • 成功率、耗时、回调量有监控告警
  • 支付类有日终对账与差异告警

十一、总结

第三方集成的好坏,不取决于对接了多少家服务,而取决于是否建立了这套通用能力:

  1. 适配层隔离——换供应商只改一个文件,测试环境能 Mock,统一加日志与超时。
  2. 验签用原始报文——这是最容易出错、也最难自查的一点。
  3. 回调必须幂等 + 必须有主动查询兜底——只依赖被动回调的系统,一定会出现状态卡死。
  4. 金额以服务端为准,用整数分——永远不要相信客户端传来的金额。
  5. 环境严格隔离 + 启动校验——生产指向沙箱是代价最高、也最容易犯的错。

最后一条建议:把「对接第三方」沉淀成一份内部模板(适配层骨架 + 回调处理骨架 + 对账骨架)。下一个服务接入时,改动量通常不到 100 行——而每次从零写,就可能把幂等或补偿漏掉。


完

前端、后端、部署、客服都是我一个人。写这里的东西都是真踩过的坑,不是抄来的教程。