OpenAPI 与第三方接口对接规范
OpenAPI 3.0 / 3.1 规范
什么是 OpenAPI
OpenAPI 规范(原名 Swagger Specification)是 Linux 基金会旗下的 RESTful API 描述标准,使用 JSON 或 YAML 格式定义 API 的端点、参数、请求/响应结构、认证方式等。OpenAPI 3.0 于 2017 年发布,3.1 于 2021 年发布,新增了对 JSON Schema 2020-12 的完整支持。
OpenAPI 3.0 vs 3.1
| 对比维度 | OpenAPI 3.0 | OpenAPI 3.1 |
|---|---|---|
| JSON Schema 版本 | 自定义子集(近似 Draft-07) | 完整支持 JSON Schema 2020-12 |
| 根字段标识 | openapi: 3.0.x | openapi: 3.1.x |
| 示例字段 | example(单值) | examples(数组)+ example 并存 |
| Webhook 支持 | 不支持 | 原生支持 webhooks 字段 |
| 路径模板 | {param} | 兼容 {param},推荐 {param} |
| $schema 字段 | 不支持 | 允许在文档中声明 JSON Schema 版本 |
| null 类型 | 通过 nullable: true 实现 | 直接使用 type: null |
YAML 基础结构
yaml
openapi: 3.0.3
info:
title: 用户中心 API
description: 提供用户注册、登录、信息管理等基础能力
version: 1.0.0
contact:
name: 后端团队
email: backend@example.com
url: https://api.example.com/support
license:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0
servers:
- url: https://api.example.com/v1
description: 生产环境
- url: https://staging-api.example.com/v1
description: 预发布环境
- url: http://localhost:8080/api/v1
description: 本地开发
paths:
/users:
get:
summary: 分页查询用户列表
operationId: listUsers
tags:
- 用户管理
parameters:
- name: page
in: query
description: 页码,从 1 开始
required: false
schema:
type: integer
minimum: 1
default: 1
- name: size
in: query
description: 每页条数,最大 100
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
responses:
'200':
description: 查询成功
content:
application/json:
schema:
$ref: '#/components/schemas/PageResponse'
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
post:
summary: 创建用户
operationId: createUser
tags:
- 用户管理
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: 创建成功
content:
application/json:
schema:
$ref: '#/components/schemas/IdResponse'
'400':
$ref: '#/components/responses/BadRequest'
'409':
description: 用户已存在
components:
schemas:
ApiResponse:
type: object
properties:
code:
type: integer
description: 业务状态码
example: 200
message:
type: string
description: 提示信息
example: 操作成功
data:
type: object
description: 响应数据
nullable: true
required:
- code
- message
PageResponse:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: object
properties:
list:
type: array
description: 数据列表
items:
$ref: '#/components/schemas/UserVO'
total:
type: integer
description: 总记录数
example: 100
page:
type: integer
description: 当前页码
example: 1
size:
type: integer
description: 每页条数
example: 20
pages:
type: integer
description: 总页数
example: 5
required:
- list
- total
- page
- size
IdResponse:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
data:
type: integer
description: 新创建记录的 ID
example: 1001
CreateUserRequest:
type: object
description: 创建用户请求体
properties:
username:
type: string
description: 用户名
minLength: 4
maxLength: 32
pattern: '^[a-zA-Z0-9_]+$'
example: zhangsan
email:
type: string
format: email
description: 邮箱地址
example: zhangsan@example.com
phone:
type: string
description: 手机号
pattern: '^1[3-9]\d{9}$'
example: '13800138000'
password:
type: string
description: 密码(至少 8 位)
minLength: 8
maxLength: 64
writeOnly: true
required:
- username
- password
UserVO:
type: object
description: 用户视图对象
properties:
id:
type: integer
description: 用户 ID
example: 1001
username:
type: string
description: 用户名
example: zhangsan
email:
type: string
description: 邮箱
example: zhangsan@example.com
phone:
type: string
description: 手机号
example: '13800138000'
status:
type: string
description: 用户状态
enum:
- ACTIVE
- INACTIVE
- LOCKED
example: ACTIVE
createdAt:
type: string
format: date-time
description: 创建时间
example: '2026-07-09T10:30:00+08:00'
updatedAt:
type: string
format: date-time
description: 更新时间
example: '2026-07-09T10:30:00+08:00'
responses:
BadRequest:
description: 请求参数错误
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
code:
example: 400
message:
example: 请求参数校验失败
Unauthorized:
description: 未授权
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiResponse'
- type: object
properties:
code:
example: 401
message:
example: 未登录或 Token 已过期
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: 使用 JWT Token 进行身份认证,格式: Bearer {token}
security:
- BearerAuth: []路径定义规范
路径命名规则:
| 元素 | 规范 | 示例 |
|---|---|---|
| 资源路径 | 名词复数,小写,连字符分隔 | /users, /order-items |
| 子资源 | 嵌套路径表达从属关系 | /users/{userId}/orders |
| 操作路径 | 避免动词,用 HTTP 方法表达操作 | POST /users(创建), DELETE /users/{id}(删除) |
| 版本号 | 统一前缀 | /v1/users, /v2/users |
| 查询操作 | 使用专用的 /search 端点 | POST /orders/search |
路径参数定义示例:
yaml
paths:
/users/{userId}/orders/{orderId}:
get:
parameters:
- name: userId
in: path
required: true
description: 用户 ID
schema:
type: integer
example: 1001
- name: orderId
in: path
required: true
description: 订单 ID
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
responses:
'200':
description: 成功请求体(requestBody)定义
yaml
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
description: 名称
items:
type: array
description: 订单项
minItems: 1
items:
type: object
properties:
productId:
type: integer
quantity:
type: integer
minimum: 1
required:
- productId
- quantity
example:
name: 测试订单
items:
- productId: 101
quantity: 2
application/xml:
schema:
$ref: '#/components/schemas/CreateOrderRequest'响应定义
yaml
responses:
'200':
description: 操作成功
headers:
X-Request-Id:
schema:
type: string
format: uuid
description: 请求追踪 ID
X-RateLimit-Remaining:
schema:
type: integer
description: 剩余请求配额
content:
application/json:
schema:
$ref: '#/components/schemas/ApiResponse'
'4XX':
description: 客户端错误
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'参数类型
OpenAPI 3.0 支持四种参数位置:
| 参数位置 | 说明 | 适用场景 |
|---|---|---|
in: path | URL 路径参数 | 资源标识符,如 /users/{id} |
in: query | URL 查询参数 | 筛选、排序、分页条件 |
in: header | HTTP 请求头 | 认证 Token、请求 ID、内容类型 |
in: cookie | Cookie 值 | 会话标识、跟踪 ID |
参数定义完整示例:
yaml
parameters:
- name: X-Request-Id
in: header
description: 请求追踪 ID,用于链路追踪和问题排查
required: false
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
- name: fields
in: query
description: 指定返回字段,多个字段用逗号分隔
required: false
schema:
type: string
example: id,name,email
- name: sort
in: query
description: '排序字段,格式: field1,-field2(负号表示降序)'
required: false
schema:
type: string
example: -createdAt,idAPI 规范设计
RESTful 命名规范
资源命名
# 正确:名词复数形式
GET /api/v1/users # 查询用户列表
POST /api/v1/users # 创建用户
GET /api/v1/users/{id} # 查询单个用户
PUT /api/v1/users/{id} # 全量更新用户
PATCH /api/v1/users/{id} # 部分更新用户
DELETE /api/v1/users/{id} # 删除用户
# 子资源
GET /api/v1/users/{id}/orders # 查询用户订单列表
# 操作类接口(RPC 风格,有限使用)
POST /api/v1/orders/{id}/cancel # 取消订单
POST /api/v1/orders/{id}/refund # 发起退款
# 错误:使用动词
GET /api/v1/getUserList # 错误
POST /api/v1/createUser # 错误
POST /api/v1/deleteUser # 错误版本号策略
推荐在 URL 路径中嵌入版本号,保持向后兼容:
| 策略 | 示例 | 说明 |
|---|---|---|
| URL 路径版本 | /api/v1/users | 最直观,易于路由 |
| 请求头版本 | Accept: application/vnd.example.v1+json | 更灵活,URL 干净 |
| 参数版本 | /api/users?version=1 | 不推荐,缓存困难 |
HTTP 动词语义
| 动词 | 语义 | 幂等 | 安全 | 请求体 |
|---|---|---|---|---|
| GET | 查询资源 | 是 | 是 | 无 |
| POST | 创建资源 / 触发操作 | 否 | 否 | 有 |
| PUT | 全量替换资源 | 是 | 否 | 有 |
| PATCH | 部分更新资源 | 否 | 否 | 有 |
| DELETE | 删除资源 | 是 | 否 | 可选 |
请求响应格式
统一响应结构
所有 API 响应遵循统一的 JSON 结构:
json
{
"code": 200,
"message": "操作成功",
"data": {},
"traceId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1780444800000
}java
@Data
public class ApiResult<T> implements Serializable {
private Integer code;
private String message;
private T data;
private String traceId;
private Long timestamp;
public static <T> ApiResult<T> success(T data) {
ApiResult<T> result = new ApiResult<>();
result.setCode(200);
result.setMessage("操作成功");
result.setData(data);
result.setTraceId(TraceContext.getTraceId());
result.setTimestamp(System.currentTimeMillis());
return result;
}
public static <T> ApiResult<T> error(Integer code, String message) {
ApiResult<T> result = new ApiResult<>();
result.setCode(code);
result.setMessage(message);
result.setTraceId(TraceContext.getTraceId());
result.setTimestamp(System.currentTimeMillis());
return result;
}
}请求头规范
| 请求头 | 必填 | 说明 |
|---|---|---|
Content-Type | 是 | application/json;charset=utf-8 |
Accept | 是 | application/json |
Authorization | 鉴权时必填 | Bearer {token} 或 AK/SK 签名 |
X-Request-Id | 推荐 | 请求追踪 ID,UUID 格式 |
X-Client-Version | 推荐 | 客户端版本号,用于兼容性判断 |
Accept-Language | 否 | 国际化语言,zh-CN / en-US |
分页规范
基于页码的分页(适合前台列表)
json
// 请求
GET /api/v1/users?page=1&size=20&sort=-createdAt
// 响应
{
"code": 200,
"message": "操作成功",
"data": {
"list": [],
"page": 1,
"size": 20,
"pages": 50,
"total": 1000,
"hasMore": true
}
}java
@Data
public class PageResult<T> {
private List<T> list;
private Integer page;
private Integer size;
private Integer pages;
private Long total;
private Boolean hasMore;
public PageResult(List<T> list, Long total, Integer page, Integer size) {
this.list = list;
this.total = total;
this.page = page;
this.size = size;
this.pages = (int) Math.ceil((double) total / size);
this.hasMore = page < this.pages;
}
}分页请求参数:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
page | Integer | 1 | 页码,从 1 开始 |
size | Integer | 20 | 每页条数,最大值 100 |
sort | String | 可选 | 排序字段,如 -createdAt,id |
基于游标的分页(适合大数据量、实时数据)
json
// 请求
GET /api/v1/feed?cursor=MTY1NDMyMDAwMA&size=20
// 响应
{
"code": 200,
"message": "操作成功",
"data": {
"list": [],
"cursor": "MTY1NDMyMDEwMA",
"hasMore": true,
"size": 20
}
}游标分页规则:
cursor使用 Base64 编码的排序字段值(如时间戳、ID)- 首次请求不传
cursor,服务端返回第一页 - 服务端返回
cursor字段,客户端下次请求时携带 - 游标分页适合增量加载场景(Feed 流、消息列表)
- 不适合跳转指定页面的场景
错误码设计
全局错误码
| 错误码 | 含义 | HTTP 状态码 |
|---|---|---|
| 200 | 操作成功 | 200 |
| 400 | 请求参数校验失败 | 400 |
| 401 | 未认证(未登录或 Token 无效) | 401 |
| 403 | 无权限(禁止访问) | 403 |
| 404 | 请求的资源不存在 | 404 |
| 405 | 请求方法不允许 | 405 |
| 408 | 请求超时 | 408 |
| 409 | 资源冲突(如重复创建) | 409 |
| 415 | 不支持的媒体类型 | 415 |
| 429 | 请求过于频繁,触发限流 | 429 |
| 500 | 服务器内部错误 | 500 |
| 502 | 网关错误 | 502 |
| 503 | 服务暂不可用 | 503 |
| 504 | 网关超时 | 504 |
业务错误码
业务错误码按模块划分,使用 6 位数字编码:
XX YY ZZ
| | |
| | +-- 具体错误序号(01-99)
| +----- 模块编号(01-99)
+-------- 系统/业务标识(10=业务, 20=系统)示例:
| 错误码 | 含义 |
|---|---|
| 100101 | 用户不存在 |
| 100102 | 用户名已存在 |
| 100103 | 用户已被锁定 |
| 100201 | 邮箱格式不正确 |
| 100202 | 手机号已注册 |
| 200101 | 订单不存在 |
| 200102 | 订单状态不允许取消 |
| 200103 | 订单已超时关闭 |
| 300101 | 支付金额不一致 |
| 300102 | 支付已超时 |
java
public enum ErrorCode {
// 全局错误
SUCCESS(200, "操作成功"),
BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或 Token 无效"),
FORBIDDEN(403, "无权限访问"),
NOT_FOUND(404, "资源不存在"),
TOO_MANY_REQUESTS(429, "请求过于频繁"),
// 用户模块(10)
USER_NOT_FOUND(100101, "用户不存在"),
USERNAME_ALREADY_EXISTS(100102, "用户名已存在"),
USER_LOCKED(100103, "用户已被锁定"),
PASSWORD_ERROR(100104, "密码错误"),
// 订单模块(20)
ORDER_NOT_FOUND(200101, "订单不存在"),
ORDER_CANNOT_CANCEL(200102, "订单状态不允许取消"),
ORDER_TIMEOUT(200103, "订单已超时关闭");
private final int code;
private final String message;
}HTTP 状态码与业务码配合
- HTTP 状态码用于表达传输层状态(协议级别)
- 业务码用于表达业务逻辑状态(应用级别)
- 正常情况下返回 HTTP 200 + 业务 code=200
- 发生业务异常时返回 HTTP 200 + 业务 code=具体错误码
- 发生系统级异常时返回 HTTP 5xx + 业务 code=对应错误码
json
// 业务异常(HTTP 200,但业务 code 非 200)
HTTP/1.1 200 OK
{
"code": 100101,
"message": "用户不存在",
"data": null,
"traceId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1780444800000
}
// 系统异常(HTTP 500)
HTTP/1.1 500 Internal Server Error
{
"code": 500,
"message": "服务器内部错误,请稍后重试",
"data": null,
"traceId": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": 1780444800000
}字段命名规范
JSON 字段命名
| 规范 | 规则 | 示例 |
|---|---|---|
| 命名风格 | 小驼峰(camelCase) | userId, createdAt, orderItems |
| 缩写 | 保持全小写,不全部大写 | orderId(非 orderID), htmlContent |
| 布尔类型 | 以 is/has/can/should 开头 | isActive, hasPermission, canDelete |
| 日期时间 | 统一使用 ISO 8601 格式 | 2026-07-09T10:30:00+08:00 |
| 枚举值 | 全大写 + 下划线 | ACTIVE, IN_PROGRESS, WAIT_PAYMENT |
| 金额 | 统一以分为单位(Long 类型) | amountInCents: 1000(表示 10 元) |
数据库字段与 API 字段映射
数据库 snake_case <-> API camelCase
user_id <-> userId
created_at <-> createdAt
order_status <-> orderStatus第三方接口对接最佳实践
接口文档管理
文档获取方式
| 方式 | 说明 | 适用场景 |
|---|---|---|
| Swagger / OpenAPI JSON | 服务提供方暴露 /v3/api-docs 端点 | 动态获取最新的接口定义 |
| Knife4j UI | 增强版的 Swagger UI,提供在线调试 | 开发联调阶段 |
| YApi | 国产接口管理平台,支持文档托管 | 团队协作,统一的文档入口 |
| 离线 OpenAPI YAML/JSON | 通过文件方式交付 OpenAPI 规范 | 外部系统对接 |
| Markdown 文档 | 人工编写的接口说明文档 | 简单的对接场景 |
YApi 使用规范
yaml
# YApi 项目配置建议
project:
name: 第三方对接-XX系统
basePath: /api/v1
tag:
- 用户接口
- 订单接口
- 通知接口
env:
- name: 开发环境
domain: http://dev-api.example.com
- name: 测试环境
domain: http://test-api.example.com
- name: 生产环境
domain: https://api.example.com文档管理流程:
- 合作伙伴提供 OpenAPI JSON/YAML 文件
- 导入到内部 YApi 或 Swagger UI 进行预览
- 团队评审接口设计合理性
- 生成 Mock 数据用于并行开发
- 联调阶段使用在线调试功能验证
签名验签机制
AK/SK 签名机制
AK(Access Key):身份标识,相当于用户名 SK(Secret Key):签名密钥,严格保密,不得在网络中明文传输
签名流程:
客户端 服务端
| |
| 1. 构造规范请求(CanonicalRequest) |
| 2. 使用 SK 对规范请求进行 HMAC-SHA256 签名 |
| 3. 拼接 Authorization 请求头 |
|-------------------------------------------->|
| | 4. 根据 AK 查询对应的 SK
| | 5. 使用同样的算法计算签名
| | 6. 比较签名是否一致
| 7. 返回响应数据 |
|<--------------------------------------------|签名计算步骤(Java 实现):
java
public class SignUtil {
private static final String ALGORITHM = "HMAC-SHA256";
private static final String HEADER_AUTH = "Authorization";
private static final String HEADER_TIMESTAMP = "X-Timestamp";
private static final String HEADER_NONCE = "X-Nonce";
private static final String HEADER_AK = "X-Access-Key";
private static final String HEADER_SIGNED_HEADERS = "X-Signed-Headers";
/**
* 生成签名
*
* @param ak Access Key
* @param sk Secret Key
* @param method HTTP 方法 (GET/POST/PUT/DELETE)
* @param path 请求路径 (如 /api/v1/users)
* @param queryString 查询字符串 (如 page=1&size=20)
* @param headers 需要签名的心跳头
* @param body 请求体内容
* @param timestamp 当前时间戳(毫秒)
* @param nonce 随机字符串
* @return 签名值
*/
public static String generateSignature(
String ak, String sk, String method, String path,
String queryString, Map<String, String> headers,
String body, long timestamp, String nonce) throws Exception {
// 1. 构建规范请求
String canonicalRequest = buildCanonicalRequest(method, path, queryString, headers, body);
// 2. 构建签名字符串
String stringToSign = timestamp + "\n" + nonce + "\n" + canonicalRequest;
// 3. 计算 HMAC-SHA256
Mac mac = Mac.getInstance(ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(sk.getBytes(StandardCharsets.UTF_8), ALGORITHM);
mac.init(keySpec);
byte[] signBytes = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signBytes);
}
private static String buildCanonicalRequest(
String method, String path, String queryString,
Map<String, String> headers, String body) {
StringBuilder sb = new StringBuilder();
sb.append(method.toUpperCase()).append("\n");
sb.append(path).append("\n");
sb.append(queryString != null ? queryString : "").append("\n");
// 对参与签名的请求头排序后拼接
if (headers != null) {
headers.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(entry ->
sb.append(entry.getKey().toLowerCase())
.append(":")
.append(entry.getValue().trim())
.append("\n"));
}
// 如果 body 为空,使用空字符串
String bodyContent = (body != null && !body.isEmpty()) ? body : "";
// 对 body 进行 SHA256 摘要
String bodyHash = DigestUtils.sha256Hex(bodyContent);
sb.append(bodyHash);
return sb.toString();
}
}请求头组装:
java
public class SignClient {
private final String ak;
private final String sk;
public HttpHeaders buildSignedHeaders(String method, String path,
String queryString, String body) {
long timestamp = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString().replace("-", "");
Map<String, String> headers = new LinkedHashMap<>();
headers.put("X-Timestamp", String.valueOf(timestamp));
headers.put("X-Nonce", nonce);
headers.put("X-Access-Key", ak);
headers.put("Content-Type", "application/json;charset=utf-8");
HttpHeaders httpHeaders = new HttpHeaders();
headers.forEach(httpHeaders::add);
try {
String signature = SignUtil.generateSignature(
ak, sk, method, path, queryString, headers, body, timestamp, nonce);
httpHeaders.add("Authorization", "SHA256-RSA " + signature);
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
}
return httpHeaders;
}
}服务端验签:
java
@Component
public class SignAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private AccessKeyService accessKeyService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("SHA256-RSA ")) {
throw new AuthenticationException("缺少或无效的 Authorization 头");
}
String signature = authHeader.substring("SHA256-RSA ".length());
String ak = request.getHeader("X-Access-Key");
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
// 1. 校验时间戳是否在允许范围内(5 分钟)
long ts = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - ts) > 5 * 60 * 1000) {
throw new AuthenticationException("请求已过期,请检查系统时间");
}
// 2. 校验 Nonce 是否已被使用(防重放)
if (nonceService.isNonceUsed(nonce)) {
throw new AuthenticationException("Nonce 已使用,疑似重放攻击");
}
// 3. 根据 AK 查询 SK
SecretKey secretKey = accessKeyService.getSecretKeyByAccessKey(ak);
if (secretKey == null) {
throw new AuthenticationException("无效的 Access Key");
}
// 4. 重新计算签名
String body = getRequestBody(request);
String queryString = request.getQueryString();
Map<String, String> signedHeaders = extractSignedHeaders(request);
try {
String expectedSign = SignUtil.generateSignature(
ak, secretKey.getSecretKey(),
request.getMethod(), request.getRequestURI(),
queryString, signedHeaders, body, ts, nonce);
if (!expectedSign.equals(signature)) {
throw new AuthenticationException("签名验证失败");
}
} catch (Exception e) {
throw new AuthenticationException("签名验证异常", e);
}
// 5. 标记 Nonce 已使用
nonceService.markNonceUsed(nonce, ts);
chain.doFilter(request, response);
}
}签名字段说明
| 请求头 | 必填 | 说明 |
|---|---|---|
Authorization | 是 | 签名值,格式 SHA256-RSA {signature} |
X-Access-Key | 是 | 分配给调用方的 Access Key |
X-Timestamp | 是 | 当前毫秒级时间戳,服务端校验 5 分钟内有效 |
X-Nonce | 是 | 随机字符串(如 UUID),一次使用,防重放攻击 |
X-Signed-Headers | 否 | 参与签名的请求头列表,多个用分号分隔 |
安全建议
- SK 存储在安全的密钥管理服务中(如 Vault、KMS),不在代码中硬编码
- 定期轮换 AK/SK,建议每 90 天更换一次
- 提供多个 AK/SK 用于轮换过渡期
- 监控 AK/SK 的异常使用行为(短时间内大量 401)
- 敏感接口增加额外的 IP 白名单校验
- Nonce 有效期与时间戳窗口一致,过期后清理
重试机制
指数退避(Exponential Backoff)
java
public class RetryTemplate {
private static final Logger log = LoggerFactory.getLogger(RetryTemplate.class);
private final int maxRetries;
private final long initialDelayMs;
private final long maxDelayMs;
private final double jitterFactor;
/**
* @param maxRetries 最大重试次数
* @param initialDelayMs 初始延迟(毫秒)
* @param maxDelayMs 最大延迟(毫秒)
* @param jitterFactor 抖动因子(0.0 ~ 1.0)
*/
public RetryTemplate(int maxRetries, long initialDelayMs,
long maxDelayMs, double jitterFactor) {
this.maxRetries = maxRetries;
this.initialDelayMs = initialDelayMs;
this.maxDelayMs = maxDelayMs;
this.jitterFactor = jitterFactor;
}
/**
* 执行带重试的操作
*
* @param callable 需要重试的操作
* @param <T> 返回值类型
* @return 操作结果
*/
public <T> T execute(RetryCallable<T> callable) {
Throwable lastException = null;
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
return callable.call(attempt);
} catch (Throwable e) {
lastException = e;
if (attempt >= maxRetries) {
log.error("所有重试均已耗尽,共重试 {} 次", attempt, e);
break;
}
if (!callable.isRetryable(e)) {
log.warn("异常不可重试,停止重试", e);
break;
}
long delay = computeDelay(attempt);
log.warn("第 {} 次重试,等待 {}ms,异常: {}", attempt + 1, delay, e.getMessage());
try {
TimeUnit.MILLISECONDS.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("重试被中断", ie);
}
}
}
if (lastException instanceof RuntimeException) {
throw (RuntimeException) lastException;
}
throw new RuntimeException("重试耗尽", lastException);
}
/**
* 计算延迟时间:指数退避 + 抖动
* delay = min(initialDelay * 2^attempt, maxDelay)
* jitter = delay * (0.5 - random * jitterFactor)
*/
private long computeDelay(int attempt) {
double delay = initialDelayMs * Math.pow(2, attempt);
delay = Math.min(delay, maxDelayMs);
// 添加抖动(jitter),避免惊群效应
double jitter = delay * jitterFactor * (ThreadLocalRandom.current().nextDouble() - 0.5);
delay = delay + jitter;
return (long) Math.max(delay, 0);
}
public interface RetryCallable<T> {
T call(int attempt) throws Throwable;
default boolean isRetryable(Throwable e) {
// 默认只在网络异常和 5xx 时重试
if (e instanceof java.net.ConnectException) return true;
if (e instanceof java.net.SocketTimeoutException) return true;
if (e instanceof java.net.UnknownHostException) return true;
if (e instanceof RetryableException) return true;
return false;
}
}
public static class RetryableException extends RuntimeException {
public RetryableException(String message) {
super(message);
}
public RetryableException(String message, Throwable cause) {
super(message, cause);
}
}
}重试配置示例:
yaml
# application.yml
third-party:
retry:
max-retries: 3
initial-delay-ms: 1000
max-delay-ms: 30000
jitter-factor: 0.2
retryable-http-codes:
- 429 # Too Many Requests
- 500 # Internal Server Error
- 502 # Bad Gateway
- 503 # Service Unavailable
- 504 # Gateway Timeout使用示例:
java
@Service
public class PaymentService {
private final RetryTemplate retryTemplate = new RetryTemplate(3, 1000, 30000, 0.2);
public PaymentResult queryPayment(String orderNo) {
return retryTemplate.execute(attempt -> {
HttpHeaders headers = signClient.buildSignedHeaders(
"GET", "/api/v1/payments/" + orderNo, null, null);
ResponseEntity<String> response = restTemplate.exchange(
"https://third-party.com/api/v1/payments/{orderNo}",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
orderNo);
return objectMapper.readValue(response.getBody(), PaymentResult.class);
});
}
}重试注意事项
- 幂等性保证:重试的操作必须是幂等的,否则可能导致数据重复
- 退避策略选择:
- 普通错误间隔 1s -> 2s -> 4s -> 8s
- 限流错误间隔更长,配合
Retry-After响应头
- 最大重试次数:通常设置为 3 次,敏感操作不超过 5 次
- 抖动:避免多个客户端同时重试造成服务器压力尖峰
- 非幂等操作:POST 创建类接口不要自动重试,除非有去重机制
Spring Retry 集成
java
@Service
public class ExternalApiService {
@Retryable(
value = {RetryableException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 1000,
multiplier = 2.0,
maxDelay = 30000,
random = true // 添加随机抖动
)
)
public String callExternalApi(String param) {
// 调用第三方接口
return restTemplate.postForObject("https://api.example.com/service", param, String.class);
}
@Recover
public String recover(RetryableException e, String param) {
// 所有重试耗尽后的兜底处理
log.error("外部接口调用失败,参数: {}", param, e);
return fallbackResult;
}
}熔断降级
Sentinel 配置
yaml
# application.yml
spring:
cloud:
sentinel:
enabled: true
transport:
dashboard: localhost:8080
datasource:
ds1:
file:
file: classpath:sentinel-rules.json
data-type: json
rule-type: flowjson
// sentinel-rules.json - 熔断降级规则
{
"degradeRules": [
{
"resource": "queryThirdPartyPayment",
"grade": 0,
"count": 500,
"timeWindow": 10,
"minRequestAmount": 5,
"statIntervalMs": 1000
}
]
}参数说明:
| 参数 | 说明 |
|---|---|
resource | 资源名,通常为方法签名或路径 |
grade | 熔断策略:0=RT,1=异常比例,2=异常数 |
count | 阈值(RT 为毫秒,异常比例为 0.0~1.0) |
timeWindow | 熔断持续时间(秒),过后进入半开状态 |
minRequestAmount | 触发熔断的最小请求数 |
statIntervalMs | 统计时间窗口(毫秒) |
java
@Component
public class ThirdPartyClient {
private static final Logger log = LoggerFactory.getLogger(ThirdPartyClient.class);
@SentinelResource(
value = "queryThirdPartyPayment",
fallback = "queryPaymentFallback",
blockHandler = "queryPaymentBlock"
)
public PaymentResult queryPayment(String orderNo) {
// 调用第三方支付查询接口
return doQuery(orderNo);
}
/**
* 熔断降级后的兜底方法
* 注意:参数列表需额外添加 BlockException 参数
*/
public PaymentResult queryPaymentBlock(String orderNo, BlockException e) {
log.warn("支付查询被熔断降级, orderNo: {}", orderNo, e);
return PaymentResult.degraded(orderNo);
}
/**
* 业务异常降级
*/
public PaymentResult queryPaymentFallback(String orderNo, Throwable e) {
log.error("支付查询异常, orderNo: {}", orderNo, e);
return PaymentResult.failed(orderNo, "查询异常");
}
}熔断降级策略
| 策略 | 触发条件 | 恢复机制 |
|---|---|---|
| RT 慢调用 | 请求响应时间超过阈值(如 3s) | 熔断窗口结束后允许少量请求探测 |
| 异常比例 | 异常数占总请求比例超过阈值(如 50%) | 同上 |
| 异常数 | 时间窗口内异常数超过阈值(如 10 次) | 同上 |
降级响应设计
json
// 熔断降级时的响应格式
{
"code": 5000,
"message": "服务暂不可用,请稍后重试",
"degraded": true,
"fallbackData": {}
}数据同步方案
方案对比
| 方案 | 实时性 | 复杂度 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| 回调(Callback) | 高 | 中 | 中 | 支付结果通知、审核状态变更 |
| 轮询(Polling) | 低 | 低 | 高 | 订单状态同步、对账 |
| Webhook | 高 | 中 | 中 | 事件驱动的数据同步 |
| 消息队列 | 高 | 高 | 高 | 跨系统数据同步 |
| 定时批量同步 | 低 | 低 | 高 | 主数据同步(商品、用户) |
回调通知
yaml
# 回调地址配置
third-party:
callback:
payment:
url: https://our-system.com/api/callback/payment
retry-count: 3
retry-interval: 30s
timeout: 5000ms
signature-required: truejava
@RestController
@RequestMapping("/api/callback")
public class CallbackController {
@PostMapping("/payment")
public ResponseEntity<String> handlePaymentCallback(
@RequestBody PaymentCallbackDTO callback,
@RequestHeader("X-Signature") String signature) {
// 1. 验签
if (!signService.verify(callback, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("签名验证失败");
}
// 2. 幂等处理(使用第三方交易号去重)
boolean processed = idempotentService.tryProcess(callback.getTransactionId());
if (!processed) {
return ResponseEntity.ok("SUCCESS"); // 已处理过,直接返回成功
}
// 3. 业务处理
try {
paymentService.handlePaymentCallback(callback);
return ResponseEntity.ok("SUCCESS");
} catch (Exception e) {
log.error("回调处理失败", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("FAILED");
}
}
}回调接口要求:
| 要求 | 说明 |
|---|---|
| 响应格式 | 固定响应字符串 SUCCESS / FAILED(非 JSON) |
| 幂等性 | 相同回调可能重复推送,需根据交易号去重 |
| 超时 | 回调接口应在 5 秒内返回响应 |
| 签名 | 回调请求必须携带签名用于验证 |
| 安全 | 回调地址应配置 IP 白名单,过滤非信任来源 |
轮询同步
java
@Component
public class PollingSyncTask {
@Scheduled(fixedDelay = 30000) // 每 30 秒轮询一次
public void syncPaymentStatus() {
List<String> pendingOrders = orderService.getPendingSyncOrders(50);
for (String orderNo : pendingOrders) {
try {
PaymentResult result = paymentClient.queryPayment(orderNo);
if (result.isFinalStatus()) {
orderService.updatePaymentStatus(orderNo, result.getStatus());
}
} catch (Exception e) {
log.warn("轮询同步失败, orderNo: {}", orderNo, e);
}
}
}
}Webhook
java
@Component
public class WebhookDispatcher {
private static final Logger log = LoggerFactory.getLogger(WebhookDispatcher.class);
private final RetryTemplate retryTemplate = new RetryTemplate(3, 5000, 60000, 0.1);
public void dispatch(WebhookEvent event) {
List<WebhookSubscription> subscriptions = subscriptionService
.findByEventType(event.getEventType());
for (WebhookSubscription sub : subscriptions) {
dispatcher.execute(() -> {
try {
String payload = objectMapper.writeValueAsString(event);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Webhook-Id", event.getEventId());
headers.set("X-Webhook-Signature",
signService.sign(payload, sub.getSecret()));
headers.set("X-Webhook-Timestamp",
String.valueOf(System.currentTimeMillis()));
HttpEntity<String> entity = new HttpEntity<>(payload, headers);
ResponseEntity<String> response = restTemplate.exchange(
sub.getCallbackUrl(),
HttpMethod.POST,
entity,
String.class);
if (response.getStatusCode() != HttpStatus.OK) {
throw new RetryTemplate.RetryableException(
"Webhook 响应异常: " + response.getStatusCode());
}
// 记录推送成功
webhookLogService.recordSuccess(sub.getId(), event.getEventId());
} catch (Exception e) {
log.error("Webhook 推送失败, url: {}, event: {}",
sub.getCallbackUrl(), event.getEventId(), e);
// 记录推送失败
webhookLogService.recordFailure(sub.getId(), event.getEventId(), e.getMessage());
}
});
}
}
}Webhook 事件格式:
json
{
"eventId": "evt_20260709123000_001",
"eventType": "order.paid",
"occurredAt": "2026-07-09T12:30:00+08:00",
"data": {
"orderNo": "ORD20260709123000001",
"paymentNo": "PAY20260709123000001",
"amount": 10000,
"paidAt": "2026-07-09T12:30:00+08:00"
}
}Webhook 重试策略:
| 失败次数 | 延迟时间 | 重试窗口 |
|---|---|---|
| 第 1 次 | 30 秒 | 30 秒内 |
| 第 2 次 | 5 分钟 | 35 分钟内 |
| 第 3 次 | 30 分钟 | 1 小时内 |
| 第 4 次 | 2 小时 | 3 小时内 |
| 第 5 次 | 6 小时 | 9 小时内 |
| 第 6 次 | 24 小时 | 33 小时内(最终放弃) |
超时设置
超时参数规范
yaml
third-party:
timeout:
connect-timeout: 5000ms # 连接超时
read-timeout: 30000ms # 读取超时
write-timeout: 10000ms # 写入超时
connection-request-timeout: 1000ms # 从连接池获取连接的超时HTTP 客户端配置
RestTemplate 配置:
java
@Configuration
public class RestTemplateConfig {
@Value("${third-party.timeout.connect-timeout:5000}")
private int connectTimeout;
@Value("${third-party.timeout.read-timeout:30000}")
private int readTimeout;
@Bean
public RestTemplate restTemplate() {
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(connectTimeout)
.setSocketTimeout(readTimeout)
.setConnectionRequestTimeout(1000)
.build();
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(200);
connectionManager.setDefaultMaxPerRoute(50);
connectionManager.setValidateAfterInactivity(30000);
CloseableHttpClient httpClient = HttpClientBuilder.create()
.setDefaultRequestConfig(requestConfig)
.setConnectionManager(connectionManager)
.setRetryHandler(new DefaultHttpRequestRetryHandler(0, false))
.build();
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory(httpClient);
factory.setConnectTimeout(connectTimeout);
factory.setReadTimeout(readTimeout);
factory.setConnectionRequestTimeout(1000);
RestTemplate restTemplate = new RestTemplate(factory);
restTemplate.setErrorHandler(new DefaultResponseErrorHandler());
return restTemplate;
}
}WebClient 配置(Spring WebFlux):
java
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofSeconds(30))
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(30))
.addHandlerLast(new WriteTimeoutHandler(10)))
.connectionProvider(ConnectionProvider.builder("third-party")
.maxConnections(200)
.maxIdleTime(Duration.ofSeconds(30))
.maxLifeTime(Duration.ofMinutes(10))
.pendingAcquireTimeout(Duration.ofSeconds(5))
.build());
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(config -> config
.defaultCodecs()
.maxInMemorySize(2 * 1024 * 1024))
.build();
}
}