FMS Open Platform 第三方接入公示 | FieldFusion Developer Center

FMS Open Platform

第三方接入公示

本文面向第三方接入方,公示开放平台凭证准备、OAuth2 token 获取、开放接口调用、安全签名、错误码和联调建议。

接入概览

先领取凭证,再完成 OAuth2 授权与 token 获取,最后基于签名规则调用开放业务接口。

第三方系统接入 FMS Open Platform 时,需要先取得平台侧分配的客户端凭证和开放接口签名凭证。授权链路用于获取访问 token,业务接口链路统一通过 /openapi/** 调用,并在请求头中携带 token、API Key、时间戳、随机串和签名。

1. 领取凭证

获取 clientIdclientSecretapiKeyapiSecret 和已登记的回调地址。

2. 换取 token

使用一次性授权码调用 /oauth2/token,成功后取得 access_token

3. 调用开放接口

对请求体、路径和 Query 生成签名,携带必需请求头访问 /openapi/**

接入前准备

接入前需要平台发放客户端凭证、签名凭证和已登记的回调地址。

接入前需要由平台侧分配以下凭证:

  • clientId
  • clientSecret
  • apiKey
  • apiSecret
  • 已登记的 redirectUri
凭证 用途 注意事项
clientId + clientSecret 调用 /oauth2/token 获取或刷新 token。 clientSecret 为敏感信息,平台不提供查询现有明文 secret 的接口。
apiKey + apiSecret 调用 /openapi/** 时生成请求签名。 apiSecret 为敏感信息,不应出现在前端页面、日志或公开配置中。
redirectUri 接收授权完成后的回调参数。 必须提前登记,并在 token 换取时严格匹配。
安全提示:如果 clientSecret 丢失,需要联系平台管理员重置;不要通过日志、截图、工单正文传递完整 secret 或 token。

OAuth2 授权与 token 获取

用户完成授权后会返回 code、state、domain,再通过授权码换取 access token。

授权结果

用户完成授权后,第三方回调地址会收到 codestatedomain 三个参数。

  • 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 必须为大写,例如 GETPOST
  • REQUEST_PATH 必须使用完整请求路径,不包含域名、协议、QueryString。
  • CANONICAL_QUERY_STRING 是排序并编码后的 Query 参数字符串,不包含前导 ?
  • Query 参数必须按参数名升序排序;参数名相同时按参数值升序排序。
  • Query 参数的 key 和 value 都应使用 UTF-8 编码后再做 URL 编码,建议使用 Java URLEncoder,并将 + 替换为 %20
  • 没有 Query 参数时,也必须保留这一行空行,不能省略换行。
  • BODY_SHA256 基于原始请求体字节计算 SHA-256 后转小写十六进制。
  • X-Api-KeyX-TimestampX-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}

签名流程

  1. 准备请求方法、路径、Query 参数、请求体原文。
  2. 计算 CANONICAL_QUERY_STRING
  3. 计算 BODY_SHA256
  4. 按固定顺序拼接待签名字串,字段间使用 \n 分隔。
  5. 使用 apiSecret 对待签名字串执行 HmacSHA256
  6. 将结果做 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-SignatureX-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/page
  • POST /openapi/farm/page
  • POST /openapi/farmland/page
  • POST /openapi/boundary/page
  • POST /openapi/baseline/page
  • POST /openapi/employee/page

业务与作业接口

  • POST /openapi/task/page
  • POST /openapi/agriPlan/page
  • POST /openapi/graderTask/page
  • POST /openapi/grader/page
  • POST /openapi/machine/page
  • POST /openapi/machineTools/page
  • POST /openapi/marker/page
  • POST /openapi/kitAutoSteering/page

处方与采样接口

  • POST /openapi/imagePrescription/page
  • POST /openapi/simplePrescription/page
  • POST /openapi/soilPrescription/page
  • POST /openapi/yieldPrescription/page
  • POST /openapi/sampleGroup/page
  • POST /openapi/sampleGroup/samplePoints/list

常见错误码

错误码主要覆盖 token 与授权、签名与安全、参数校验、限流幂等和系统错误。

分类 错误码 说明
token 与授权 10001 INVALID_TOKEN
10004 INVALID_AUTHORIZATION_CODE
20001 CLIENT_NOT_FOUND
20002 CLIENT_DISABLED
20003 CLIENT_SECRET_MISMATCH
30001 REDIRECT_URI_MISMATCH
30002 REDIRECT_URI_NOT_ALLOWED
授权码不存在、已失效、已被消费等场景当前统一返回 10004
签名与安全 10010 API_KEY_INVALID
10011 API_KEY_DISABLED
10012 API_KEY_EXPIRED
10013 SIGNATURE_REQUIRED
10014 SIGNATURE_INVALID
10015 SIGNATURE_TIMESTAMP_EXPIRED
10016 SIGNATURE_NONCE_REPLAY
10010 为协议预留;当前实现中未知 apiKey 可能表现为 20001 CLIENT_NOT_FOUND
参数与业务校验 20004 ACCESS_VALIDATION_FAILED
40001 INVALID_PARAM
40002 PARAM_REQUIRED
40003 PARAM_OUT_OF_RANGE
40004 PARAM_FORMAT_ERROR
20004 表示请求已通过开放平台鉴权,但下游 FMS 校验未通过。
限流与幂等 50001 RATE_LIMIT_EXCEEDED
50002 IDEMPOTENCY_KEY_REQUIRED
50003 IDEMPOTENCY_KEY_CONFLICT
5000250003 仅在启用幂等校验且对应接口要求 Idempotency-Key 时返回。
系统错误 90001 INTERNAL_ERROR
90004 REMOTE_CALL_ERROR
系统类异常通常统一收敛为 90001900049000290003 当前版本第三方通常不会直接收到。

接入建议

联调时建议封装统一客户端、记录 requestId,并先在测试环境跑通后再申请正式环境。

  • 在调用侧封装统一 HTTP Client,集中处理签名和公共请求头。
  • 记录每次响应中的 requestId,便于联调和问题定位。
  • 不要复用 X-Nonce
  • 联调时优先按响应体中的 code 做程序判断,message 更适合用于日志排查。
  • 第三方需在测试环境调试通过后,方可向平台侧申请正式环境的请求地址和相关信息;未完成测试环境联调的接入方,平台有权拒绝发放正式环境凭证。
  • 日志中不要打印完整 apiSecret、完整 access_token 或完整授权码。
本页面根据第三方接入公示内容整理生成,用于第三方接入说明。接口字段、错误码和签名规则以当前代码实现及正式发布说明为准。