第三方服务集成手册
接入支付、短信、对象存储、地图、推送等外部服务时的通用方法:签名与验签、回调幂等与重试补偿、错误码映射、沙箱与正式环境隔离、密钥管理、联调排查步骤,附各类服务的接入要点与高频坑位。
一、为什么需要一个通用方法论
第三方集成的代码形态各异,但出问题的地方高度相似:
| 典型故障 | 根因 |
|---|---|
| 回调重复处理导致重复发货/重复加余额 | 没有幂等 |
| 通知漏收,订单状态卡住 | 完全依赖被动回调,无主动查询补偿 |
| 抓包发现签名错误 | 参数排序/编码与文档不一致 |
| 上线后才发现用的是沙箱地址 | 环境配置未隔离 |
| 密钥泄露在日志或前端 | 没有分级管理 |
结论:与其每个服务重新踩一遍,不如建立一套通用规范。
二、通用集成五步
1. 读文档 → 梳理「请求参数、签名规则、回调格式、错误码」2. 沙箱联调 → 用官方测试工具验证最小闭环3. 封装适配层 → 隔离第三方细节,业务代码不直接调 SDK4. 打通回调与查询 → 主动查询兜底被动回调5. 上监控与告警 → 调用成功率、回调延迟、失败重试2.1 适配层封装(重要)
// ✅ 业务代码只依赖自己的接口,不直接依赖第三方 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 调用统一封装
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 参数排序签名的易错点
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 回调验签
// 收到回调第一步:验签,通过才处理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('签名验证失败')}验签必须用「原始请求体字符串」,不能用解析后重新序列化的对象——键顺序与空格变化会导致验签失败。
// 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. 返回成功(必须快速返回,耗时操作丢队列)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 主动查询兜底(必做)
回调可能丢失(网络、宕机、配置错误)。只依赖回调的系统一定会出现「订单永远待支付」:
// 定时任务:补偿查询长时间未收到回调的订单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) }})五、错误码映射
第三方错误码不能直接抛给用户,需要映射到自己的错误体系:
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;权限设为仅服务账号可读 |
对账是最后一道防线:即使回调与查询都出错,日终对账也能发现问题。
// 每日对账示例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 与状态,便于排查「用户没收到」 |
// 验证码发送与校验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 三套环境严格隔离
| 环境 | 用途 | 注意 |
|---|---|---|
| 沙箱 / 测试 | 联调 | 官方提供的测试账号与测试卡号 |
| 预发 | 上线前验证 | 用正式配置但小额 |
| 生产 | 真实交易 | 密钥轮换需流程 |
最常见事故:生产环境配置里残留了沙箱地址或测试密钥。必须在配置中显式标注环境,并在启动时校验。
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 是什么?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)
- 成功率、耗时、回调量有监控告警
- 支付类有日终对账与差异告警
十一、总结
第三方集成的好坏,不取决于对接了多少家服务,而取决于是否建立了这套通用能力:
- 适配层隔离——换供应商只改一个文件,测试环境能 Mock,统一加日志与超时。
- 验签用原始报文——这是最容易出错、也最难自查的一点。
- 回调必须幂等 + 必须有主动查询兜底——只依赖被动回调的系统,一定会出现状态卡死。
- 金额以服务端为准,用整数分——永远不要相信客户端传来的金额。
- 环境严格隔离 + 启动校验——生产指向沙箱是代价最高、也最容易犯的错。
最后一条建议:把「对接第三方」沉淀成一份内部模板(适配层骨架 + 回调处理骨架 + 对账骨架)。下一个服务接入时,改动量通常不到 100 行——而每次从零写,就可能把幂等或补偿漏掉。
完