获取 clientId、clientSecret、apiKey、apiSecret 和已登记的回调地址。
接入概览
先领取凭证,再完成 OAuth2 授权与 token 获取,最后基于签名规则调用开放业务接口。
第三方系统接入 FMS Open Platform 时,需要先取得平台侧分配的客户端凭证和开放接口签名凭证。授权链路用于获取访问 token,业务接口链路统一通过
/openapi/** 调用,并在请求头中携带 token、API Key、时间戳、随机串和签名。
使用一次性授权码调用 /oauth2/token,成功后取得 access_token。
对请求体、路径和 Query 生成签名,携带必需请求头访问 /openapi/**。
接入前准备
接入前需要平台发放客户端凭证、签名凭证和已登记的回调地址。
接入前需要由平台侧分配以下凭证:
clientIdclientSecretapiKeyapiSecret- 已登记的
redirectUri
| 凭证 | 用途 | 注意事项 |
|---|---|---|
clientId + clientSecret |
调用 /oauth2/token 获取或刷新 token。 |
clientSecret 为敏感信息,平台不提供查询现有明文 secret 的接口。 |
apiKey + apiSecret |
调用 /openapi/** 时生成请求签名。 |
apiSecret 为敏感信息,不应出现在前端页面、日志或公开配置中。 |
redirectUri |
接收授权完成后的回调参数。 | 必须提前登记,并在 token 换取时严格匹配。 |
clientSecret 丢失,需要联系平台管理员重置;不要通过日志、截图、工单正文传递完整 secret 或 token。
OAuth2 授权与 token 获取
用户完成授权后会返回 code、state、domain,再通过授权码换取 access token。
授权结果
用户完成授权后,第三方回调地址会收到 code、state、domain 三个参数。
code:一次性授权码。state:第三方发起授权时传入的透传状态值。domain:用于第三方后续请求开放平台服务域名时做路由选择。
使用授权码换取 token
POST /oauth2/token
Content-Type: application/json
{
"grantType": "authorization_code",
"code": "authorization-code",
"redirectUri": "https://third.example.com/callback",
"clientId": "your-client-id",
"clientSecret": "your-client-secret"
}
成功响应示例:
{
"code": 0,
"message": "Success",
"data": {
"access_token": "access-token",
"token_type": "Bearer",
"scope": "default"
},
"requestId": "req-1234567890abcd",
"timestamp": 1760000000000
}
- 请求入参字段为驼峰格式,例如
grantType。 - 响应中的 token 字段为下划线格式,例如
access_token。 - 授权码只能消费一次。
- 授权码不存在、已失效、已被消费等场景当前统一返回
10004。 - token 不采用固定绝对过期时间策略;只要持续有有效请求,平台会自动续期。
刷新 token
{
"grantType": "refresh_token",
"refreshToken": "old-access-token",
"clientId": "your-client-id",
"clientSecret": "your-client-secret"
}
refreshToken 参数传入上一次获取到的 access token。
业务接口调用要求
所有开放业务接口统一走 /openapi/**,并携带公共鉴权头与签名字段。
所有开放业务接口统一使用 /openapi/** 前缀,例如 /openapi/customer/page、/openapi/farm/page、/openapi/task/page。
必需请求头
Authorization: Bearer <access_token>
X-Api-Key: <apiKey>
X-Timestamp: <unix毫秒时间戳>
X-Nonce: <每次请求唯一值>
X-Signature-Version: v1
X-Signature: <签名结果>
Content-Type: application/json
HTTP 状态码
| 状态码 | 含义 |
|---|---|
200 OK |
请求已被成功处理,具体业务结果继续判断响应体中的 code。 |
400 Bad Request |
请求参数错误、缺失,或请求格式不符合要求。 |
401 Unauthorized |
未通过鉴权,例如 Authorization 缺失、access token 无效、签名缺失或签名校验失败。 |
403 Forbidden |
请求已到达服务端,但当前客户端已被禁用,或下游业务校验失败。 |
404 Not Found |
请求路径不存在。 |
429 Too Many Requests |
触发限流。 |
对开放接口 /openapi/**,签名相关失败当前统一返回 HTTP 401。部分 10001 响应的 message
可能包含更具体的失败原因;做程序判断时请以 code 为准。
统一响应结构
{
"code": 0,
"message": "Success",
"data": {},
"requestId": "req-xxxx",
"timestamp": 1760000000000
}
{
"code": 10014,
"message": "Request signature is invalid",
"data": null,
"requestId": "req-xxxx",
"timestamp": 1760000000000
}
建议调用侧记录请求路径、请求时间、HTTP 状态码、requestId 和响应体中的 code。
签名规则
签名采用 HmacSHA256 + Base64,对方法、路径、Query、Body 哈希和公共头字段组合签名。
算法
- 算法:
HmacSHA256 - 输出:
Base64 - 版本:
v1 - 密钥:第三方接入方分配到的
apiSecret
签名校验用于证明请求确实由持有 apiSecret 的接入方发起,且请求在传输过程中没有被篡改。
待签名字串
HTTP_METHOD + "\n" +
REQUEST_PATH + "\n" +
CANONICAL_QUERY_STRING + "\n" +
BODY_SHA256 + "\n" +
X-Api-Key + "\n" +
X-Timestamp + "\n" +
X-Nonce
HTTP_METHOD必须为大写,例如GET、POST。REQUEST_PATH必须使用完整请求路径,不包含域名、协议、QueryString。CANONICAL_QUERY_STRING是排序并编码后的 Query 参数字符串,不包含前导?。- Query 参数必须按参数名升序排序;参数名相同时按参数值升序排序。
- Query 参数的 key 和 value 都应使用 UTF-8 编码后再做 URL 编码,建议使用 Java
URLEncoder,并将+替换为%20。 - 没有 Query 参数时,也必须保留这一行空行,不能省略换行。
BODY_SHA256基于原始请求体字节计算 SHA-256 后转小写十六进制。X-Api-Key、X-Timestamp、X-Nonce必须与请求头中的实际值完全一致。
空请求体的 SHA-256 固定为:
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
Canonical Query String 示例
/openapi/task/page?pageNum=1&pageSize=20&name=demo%20task
参与签名的 CANONICAL_QUERY_STRING 为:
name=demo%20task&pageNum=1&pageSize=20
不要把 ? 或完整 URL 拼进待签名字串。
Body 哈希规则
参与哈希的内容必须是最终发出的原始 JSON 字节序列。不要在签名后再重新序列化请求体,否则字段顺序或空格变化会导致签名不一致。
{"name":"demo","pageNum":1,"pageSize":20}
签名流程
- 准备请求方法、路径、Query 参数、请求体原文。
- 计算
CANONICAL_QUERY_STRING。 - 计算
BODY_SHA256。 - 按固定顺序拼接待签名字串,字段间使用
\n分隔。 - 使用
apiSecret对待签名字串执行HmacSHA256。 - 将结果做
Base64编码后放入X-Signature请求头。
Java 签名工具示例
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
public final class OpenPlatformSignUtil {
private static final String HMAC_SHA256 = "HmacSHA256";
private static final String SIGNATURE_VERSION = "v1";
private static final String EMPTY_BODY_SHA256 =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
private OpenPlatformSignUtil() {
}
public static SignHeaders buildSignHeaders(
String apiKey,
String apiSecret,
String httpMethod,
String requestPath,
Map<String, List<String>> queryParams,
String requestBody,
long timestamp,
String nonce) {
String canonicalQueryString = buildCanonicalQueryString(queryParams);
String bodySha256 = (requestBody == null || requestBody.isEmpty())
? EMPTY_BODY_SHA256
: sha256Hex(requestBody);
String stringToSign = buildStringToSign(
httpMethod,
requestPath,
canonicalQueryString,
bodySha256,
apiKey,
String.valueOf(timestamp),
nonce
);
String signature = hmacSha256Base64(stringToSign, apiSecret);
return new SignHeaders(
apiKey,
String.valueOf(timestamp),
nonce,
SIGNATURE_VERSION,
signature,
stringToSign,
bodySha256
);
}
public static String buildCanonicalQueryString(Map<String, List<String>> queryParams) {
if (queryParams == null || queryParams.isEmpty()) {
return "";
}
List<QueryEntry> entries = new ArrayList<QueryEntry>();
for (Map.Entry<String, List<String>> entry : queryParams.entrySet()) {
String key = Objects.requireNonNull(entry.getKey(), "query param key can not be null");
List<String> values = entry.getValue();
if (values == null || values.isEmpty()) {
entries.add(new QueryEntry(key, ""));
continue;
}
for (String value : values) {
entries.add(new QueryEntry(key, value == null ? "" : value));
}
}
return entries.stream()
.sorted(Comparator.comparing(QueryEntry::key).thenComparing(QueryEntry::value))
.map(entry -> urlEncode(entry.key()) + "=" + urlEncode(entry.value()))
.collect(Collectors.joining("&"));
}
public static String buildStringToSign(
String httpMethod,
String requestPath,
String canonicalQueryString,
String bodySha256,
String apiKey,
String timestamp,
String nonce) {
return httpMethod.toUpperCase() + "\n"
+ requestPath + "\n"
+ canonicalQueryString + "\n"
+ bodySha256 + "\n"
+ apiKey + "\n"
+ timestamp + "\n"
+ nonce;
}
public static String sha256Hex(String content) {
try {
byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8);
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(bytes);
StringBuilder builder = new StringBuilder(hash.length * 2);
for (byte b : hash) {
builder.append(String.format("%02x", b));
}
return builder.toString();
} catch (Exception ex) {
throw new IllegalStateException("Failed to calculate sha256", ex);
}
}
public static String hmacSha256Base64(String data, String secret) {
try {
Mac mac = Mac.getInstance(HMAC_SHA256);
SecretKeySpec secretKeySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_SHA256);
mac.init(secretKeySpec);
byte[] signBytes = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signBytes);
} catch (Exception ex) {
throw new IllegalStateException("Failed to sign request", ex);
}
}
private static String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
private record QueryEntry(String key, String value) {
}
public record SignHeaders(
String apiKey,
String timestamp,
String nonce,
String signatureVersion,
String signature,
String stringToSign,
String bodySha256) {
}
}
常见签名错误排查
10013 SIGNATURE_REQUIRED:通常是缺少X-Signature或X-Signature-Version。10014 SIGNATURE_INVALID:优先检查请求路径是否带了域名、Query 是否排序、Body 是否在签名后又被重新序列化。10015 SIGNATURE_TIMESTAMP_EXPIRED:检查客户端机器时间是否与标准时间相差过大。10016 SIGNATURE_NONCE_REPLAY:同一个X-Nonce被重复使用。- Header 正确但仍校验失败:检查
apiSecret是否用错环境,测试环境和生产环境凭证不能混用。
防重放要求
时间戳必须在允许窗口内,nonce 只能在有效窗口内使用一次。
X-Timestamp必须落在允许时间窗口内。X-Nonce在有效窗口内只能使用一次。- 重复使用 nonce 会返回
10016。
当前开放接口清单
当前公开接口按主数据、业务作业、处方采样三大类组织。
主数据接口
POST /openapi/customer/pagePOST /openapi/farm/pagePOST /openapi/farmland/pagePOST /openapi/boundary/pagePOST /openapi/baseline/pagePOST /openapi/employee/page
业务与作业接口
POST /openapi/task/pagePOST /openapi/agriPlan/pagePOST /openapi/graderTask/pagePOST /openapi/grader/pagePOST /openapi/machine/pagePOST /openapi/machineTools/pagePOST /openapi/marker/pagePOST /openapi/kitAutoSteering/page
处方与采样接口
POST /openapi/imagePrescription/pagePOST /openapi/simplePrescription/pagePOST /openapi/soilPrescription/pagePOST /openapi/yieldPrescription/pagePOST /openapi/sampleGroup/pagePOST /openapi/sampleGroup/samplePoints/list
常见错误码
错误码主要覆盖 token 与授权、签名与安全、参数校验、限流幂等和系统错误。
| 分类 | 错误码 | 说明 |
|---|---|---|
| token 与授权 | 10001 INVALID_TOKEN10004 INVALID_AUTHORIZATION_CODE20001 CLIENT_NOT_FOUND20002 CLIENT_DISABLED20003 CLIENT_SECRET_MISMATCH30001 REDIRECT_URI_MISMATCH30002 REDIRECT_URI_NOT_ALLOWED |
授权码不存在、已失效、已被消费等场景当前统一返回 10004。 |
| 签名与安全 | 10010 API_KEY_INVALID10011 API_KEY_DISABLED10012 API_KEY_EXPIRED10013 SIGNATURE_REQUIRED10014 SIGNATURE_INVALID10015 SIGNATURE_TIMESTAMP_EXPIRED10016 SIGNATURE_NONCE_REPLAY |
10010 为协议预留;当前实现中未知 apiKey 可能表现为 20001 CLIENT_NOT_FOUND。 |
| 参数与业务校验 | 20004 ACCESS_VALIDATION_FAILED40001 INVALID_PARAM40002 PARAM_REQUIRED40003 PARAM_OUT_OF_RANGE40004 PARAM_FORMAT_ERROR |
20004 表示请求已通过开放平台鉴权,但下游 FMS 校验未通过。 |
| 限流与幂等 | 50001 RATE_LIMIT_EXCEEDED50002 IDEMPOTENCY_KEY_REQUIRED50003 IDEMPOTENCY_KEY_CONFLICT |
50002、50003 仅在启用幂等校验且对应接口要求 Idempotency-Key 时返回。 |
| 系统错误 | 90001 INTERNAL_ERROR90004 REMOTE_CALL_ERROR |
系统类异常通常统一收敛为 90001 或 90004;90002、90003 当前版本第三方通常不会直接收到。 |
接入建议
联调时建议封装统一客户端、记录 requestId,并先在测试环境跑通后再申请正式环境。
- 在调用侧封装统一 HTTP Client,集中处理签名和公共请求头。
- 记录每次响应中的
requestId,便于联调和问题定位。 - 不要复用
X-Nonce。 - 联调时优先按响应体中的
code做程序判断,message更适合用于日志排查。 - 第三方需在测试环境调试通过后,方可向平台侧申请正式环境的请求地址和相关信息;未完成测试环境联调的接入方,平台有权拒绝发放正式环境凭证。
- 日志中不要打印完整
apiSecret、完整access_token或完整授权码。