| 项 | 规则 |
|---|---|
| 传输 | HTTPS |
| 方法 | POST |
| 请求头 | Content-Type: application/json |
| 字符编码 | UTF-8,不带 BOM |
| 请求体 | JSON 对象,固定 4 个成员 key、timestamp、body、sign,不超过 256 KiB |
| 业务参数 | JSON 对象,AES-256-CBC 加密(PKCS#7 填充),标准 Base64 编码后放进 body |
| 签名 | sign = MD5(body + timestamp + secret),32 个十六进制字符,大写 |
| 时间戳 | Unix 毫秒数,JSON 整数,只参与签名 |
| 响应 | Base64 密文,或以 { 开头的明文 JSON,见响应处理 |
| 凭证 | 长度 | 用途 |
|---|---|---|
key | 32 个字符 | 应用实例标识,随每次请求明文传递 |
secret | 64 个字符 | 签名密钥,只参与签名计算,不随请求传递 |
aesKey | 32 个 ASCII 字符(UTF-8 编码后 32 字节,即 256 bit) | 业务参数加密密钥,同样不随请求传递 |
POST,请求头 Content-Type: application/json,字符编码为 UTF-8,请求体固定为 4 个字段:| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
key | string | 是 | 应用实例的 key |
timestamp | number | 是 | 发起请求时的毫秒时间戳(JSON 整数),如 1794370000000,格式见「timestamp 格式」 |
body | string | 是 | 业务参数 JSON 字符串加密后的 Base64 文本,见「加密规则」 |
sign | string | 是 | 签名,32 个十六进制字符(大写),见「签名规则」 |
{
"key": "调用方的key",
"timestamp": 1794370000000,
"body": "43EYRVvX8lxqL6rR/XZ+iBSNgsos0HHX8JUVQJsDDhk=",
"sign": "6D1F10D8A10FB9580628DA5C4C00E1CD"
}{} 加密。POST(RFC 9110 §9.3.3)。上面的 JSON 文本就是整个 HTTP 请求体:不得放进查询串或表单,不得压缩(不带 Content-Encoding,RFC 9110 §8.4)。请求体不得超过 256 KiB(262144 字节),超出时返回 code 为 500(HTTP 状态码 200)。Content-Type(RFC 9110 §8.3)必须为 application/json,可以带 ; charset=utf-8,不得声明其他字符集(application/json 本身不定义 charset 参数,RFC 8259 §11)。缺少该请求头,或使用 application/x-www-form-urlencoded、multipart/form-data、text/plain 等,返回 405 无效的请求内容。/ 写成 \/(如 PHP json_encode 的默认行为)不影响结果;签名必须用转义前的原值计算。key、body、sign 的值首尾不得带空格、制表符或换行(body 首尾带空白时的结果见「Base64 变体与 URL 编码」)。1794370000000 正确;"1794370000000"、1794370000000.0、1.79437E12 都不符合本规范。System.currentTimeMillis()、JavaScript Date.now() 相同。目前为 13 个数字,处于 RFC 8259 §6 所说的可互操作整数范围(绝对值不超过 2^53−1)内,JavaScript 的 Number 也能精确表示。timestamp 读成 64 位整数后,按十进制重新生成数字串来验签,所以带小数、指数或前导零的写法按原样签名会返回 1037;带引号且含小数点、指数或空格的写法返回 code 为 500。body:| 项 | 规则 | 标准 |
|---|---|---|
| 明文 | 业务参数 JSON 文本,顶层为对象;按 UTF-8 编码成字节,不带 BOM。服务端按 UTF-8 解码解密后的字节,非法字节序列会被静默替换为 U+FFFD,不报错:明文用 GBK 等其他编码时,中文字段(如 refundReason、cancelReason)会变成乱码。请用示例与自测第五步含中文的向量核对 UTF-8 编码 | RFC 8259;RFC 3629 |
| 算法 | AES,密钥 256 bit(32 字节),即 AES-256(Nk = 8) | FIPS 197(2023 年更新版 FIPS 197-upd1) |
| 模式 | CBC | NIST SP 800-38A §6.2 |
| 填充 | PKCS#7:在明文末尾补 n 个值为 n 的字节,n = 16 − (明文字节数 mod 16),取值 1–16;明文恰为 16 字节的倍数时补一整块 16 个 0x10。Java 的名称 PKCS5Padding 沿用 PKCS #5(RFC 8018 §6.1.1,原定义为 8 字节块),用于 AES 时字节结果与 PKCS#7 相同;OpenSSL 和 .NET 的默认填充也是这种 | RFC 5652 §6.3(源自 PKCS #7 v1.5,RFC 2315 §10.3 注 2) |
| 密钥 | aesKey 字符串的 UTF-8 字节,共 32 字节。直接取字节,不得做 Hex 解码、Base64 解码或任何密钥派生,不得截断或补零 | —(本协议自定) |
| IV | 密钥的前 16 个字节,即 aesKey 的前 16 个字符。IV 固定,不随请求传递。这一取法不符合 NIST SP 800-38A 附录 C「CBC 的 IV 必须不可预测」的要求,是本协议的约定:不得改用随机 IV 或加密库生成的默认 IV,不得把 IV 拼在密文前面发送;安全扫描工具提示「固定 IV」时也不要自行修改,否则返回 1020 | —(本协议自定) |
| 输出 | 密文字节(16 的整数倍)按 标准 Base64 编码:字母表 A–Z a–z 0–9 + /;必须带 = 填充;不得插入换行或任何空白;不得使用 URL-safe 字母表。结果是一行 ASCII 文本,长度为 4 的倍数 | RFC 4648 §4(§3.1 不换行,§3.2 带填充) |
\uXXXX,都不影响验签:服务端只对密文做签名校验,解密后再解析业务 JSON。业务参数必须按 RFC 8259 生成、顶层为对象;解密后不是 JSON 对象时(例如把 JSON 序列化了两次、顶层是数组),返回 1020,msg 为「业务参数格式有误,必须为JSON对象」。sign = Base16大写( MD5( UTF-8字节( body + timestamp + secret ) ) )body:按「加密规则」得到的 Base64 文本,与请求中 body 的值逐字符相同。签名针对密文而不是明文,所以必须先加密、后签名。timestamp:请求中 timestamp 的十进制数字串,格式见「timestamp 格式」。secret:签名密钥原文,逐字符使用。secret 只是拼在末尾的普通字符串;不是 HMAC-MD5(RFC 2104),不得使用 HmacMD5、hash_hmac、createHmac 之类的 API。A–F。不得只取 16 个字符,不得用 Base64 表示摘要。timestamp 只参与签名,服务端不校验时效,也没有防重放机制。 同一个 已签名的请求可以被重复提交。申请退单、取消预约在订单状态不变时,重放只会得到已有结果;上一笔退单被关闭或取消后重新预约时,重放会被当作新请求办理。请务必开启 IP 白名单并保管好 secret,只通过 HTTPS 调用。| 环节 | 标准(章节) | 本协议的取值 | 规则所在 |
|---|---|---|---|
| HTTP 方法、请求头 | RFC 9110 §9.3.3(POST)、§8.3(Content-Type) | POST;application/json | 「HTTP 与 JSON 报文」 |
| 字符编码 | RFC 3629(UTF-8);RFC 8259 §8.1(不带 BOM) | UTF-8,无 BOM | 「HTTP 与 JSON 报文」、「加密规则」、响应处理 |
| JSON | RFC 8259(§4 对象、§6 数字、§7 字符串、§8.3 成员名比较) | 请求报文、业务参数、统一响应 | 「HTTP 与 JSON 报文」、「加密规则」 |
| 时间戳 | RFC 8259 §6(整数文本形式);IEEE Std 1003.1(POSIX)「Seconds Since the Epoch」 | Unix 毫秒(不计闰秒),十进制整数 | 「timestamp 格式」 |
| 分组密码 | FIPS 197(2023 年更新版 FIPS 197-upd1) | AES-256(Nk = 8) | 「加密规则」 |
| 工作模式 | NIST SP 800-38A §6.2(CBC) | CBC;IV 由本协议规定为密钥前 16 字节,不符合该标准附录 C 的 IV 要求 | 「加密规则」 |
| 填充 | RFC 5652 §6.3(源自 PKCS #7,通称 PKCS#7 填充) | 块长 16 字节,补 1–16 字节 | 「加密规则」 |
| Base64 | RFC 4648 §4,及 §3.1(不换行)、§3.2(带填充)、§3.3(拒绝字母表外字符) | 标准字母表 + /,带 =,单行 | 「加密规则」、响应处理 |
| 摘要 | RFC 1321(MD5);不是 HMAC(RFC 2104) | 16 字节 | 「签名规则」 |
| 十六进制 | RFC 4648 §8(Base16,不区分大小写) | 32 个字符,大写(本协议规定) | 「签名规则」 |
| 百分号编码 / 表单编码 | RFC 3986 §2.1;WHATWG URL Standard §5(application/x-www-form-urlencoded) | 不使用 | 「Base64 变体与 URL 编码」 |
aesKey 的 UTF-8 字节,IV 取密钥前 16 字节,再按示例与自测的自测判据核对整条流水线。请求
业务参数 JSON 文本 ──UTF-8──▶ 明文字节
明文字节 ──PKCS#7 填充 + AES-256-CBC(密钥 = aesKey 的 UTF-8 字节,IV = 密钥前 16 字节)──▶ 密文字节
密文字节 ──Base64(RFC 4648 §4,带 =,单行)──▶ body
body + timestamp 数字串 + secret ──UTF-8──▶ 签名输入字节 ──MD5──▶ 16 字节 ──Base16 大写──▶ sign
{key, timestamp, body, sign} ──JSON 序列化 + UTF-8──▶ HTTP 请求体
响应
HTTP 响应体字节 ──UTF-8──▶ 文本 ──以 { 开头──▶ 明文 JSON,直接读取
└─其他──▶ Base64 解码 ──▶ AES-256-CBC 解密,去 PKCS#7 填充 ──UTF-8──▶ 统一响应 JSONbody 和 sign 写进 JSON 字符串时保持原样。百分号编码(RFC 3986 §2.1)和表单编码(application/x-www-form-urlencoded,空格写成 +,+ / = 写成 %2B %2F %3D)只用于 URL 和表单,本协议的请求体是 JSON,任何字段都不做 URL 编码,接口路径也不需要编码。key、timestamp、body、sign 做 URL 解码;URL-safe 字母表、百分号编码、表单编码和文本中间的任何空白或换行都会导致失败。body 必须按「加密规则」带 = 填充发送。body 中间被替换或插入了字符:签名按实际发送的值计算,返回 1020;按变换前的值计算,返回 1037。body 只在首尾多了空白或换行:服务端先去掉首尾空白再验签,按实际发送的值计算的签名也返回 1037(开发环境实测)。body 为例:| 错误做法 | 发送出去的 body | 返回 |
|---|---|---|
URL-safe Base64(RFC 4648 §5,- _ 代替 + /) | 43EYRVvX8lxqL6rR_XZ-iBSNgsos0HHX8JUVQJsDDhk= | 1020 或 1037 |
对 body 做百分号编码 | 43EYRVvX8lxqL6rR%2FXZ%2BiBSNgsos0HHX8JUVQJsDDhk%3D | 1020 或 1037 |
经过表单解码,+ 变成空格 | 43EYRVvX8lxqL6rR/XZ iBSNgsos0HHX8JUVQJsDDhk= | 1020 或 1037 |
MIME Base64(RFC 2045 §6.8,每 76 个字符插入 CRLF)、PEM 风格(RFC 7468,每 64 个字符换行)或每 60 个字符换行(Ruby encode64) | 超过换行宽度时,文本中间含 \r\n 或 \n | 1020 或 1037 |
编码器在末尾加换行(上述编码器处理较短的密文,或 openssl enc -base64 不加 -A) | 43EYRVvX8lxqL6rR/XZ+iBSNgsos0HHX8JUVQJsDDhk=\n | 1037 |
整个请求按表单提交(key=…&body=…) | — | 405 无效的请求内容 |
body 中出现 %、-、_、空格或换行,就不是本协议要求的形式。JSON 序列化器输出的 \/、+ 之类是 JSON 转义(RFC 8259 §7),解析后还原,不属于上述错误。| 语言 | 正确用法 | 易混淆的错误用法 |
|---|---|---|
| Java | java.util.Base64.getEncoder() / getDecoder();Cipher.getInstance("AES/CBC/PKCS5Padding");getBytes(StandardCharsets.UTF_8);String.format("%02X", b) | Base64.getMimeEncoder()(每 76 字符插入 CRLF)、getUrlEncoder()、getEncoder().withoutPadding();不带参数的 getBytes()(JDK 18 之前取平台默认字符集);new BigInteger(1, digest).toString(16)(丢掉前导 0);Mac.getInstance("HmacMD5");URLEncoder.encode |
| Android | android.util.Base64.encodeToString(bytes, Base64.NO_WRAP) | Base64.DEFAULT(输出带换行)、Base64.URL_SAFE |
| C# / .NET | Convert.ToBase64String / Convert.FromBase64String;Aes.Create() 后显式设置 Key = keyBytes、IV = keyBytes[0..16]、Mode = CipherMode.CBC、Padding = PaddingMode.PKCS7;Encoding.UTF8.GetBytes;Convert.ToHexString(.NET 5+,大写);new StringContent(json, Encoding.UTF8, "application/json") | 不设置 IV(沿用 Aes.Create() 随机生成的 IV);Base64FormattingOptions.InsertLineBreaks;WebEncoders.Base64UrlEncode;BitConverter.ToString(字节间带 -);HMACMD5;FormUrlEncodedContent;HttpUtility.UrlEncode |
| Go | base64.StdEncoding;crypto/aes + cipher.NewCBCEncrypter(标准库不带 PKCS#7,需自行补齐);fmt.Sprintf("%X", md5.Sum(b));strconv.FormatInt(ts, 10) | base64.URLEncoding、RawStdEncoding(不带填充)、RawURLEncoding;hmac.New(md5.New, …);url.Values、http.PostForm。注意 StdEncoding.DecodeString 会忽略 \r、\n,不能用来检验 body 是否合规 |
| PHP | openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv) 后 base64_encode;strtoupper(md5($s));json_encode 生成请求体,CURLOPT_POSTFIELDS 传该字符串;时间戳先转成 int | openssl_encrypt 的 options 传 0 时已输出 Base64,再 base64_encode 会变成双重 Base64;chunk_split(base64_encode(...))(每 76 字符插入 \r\n,末尾也有);md5($s, true)(原始 16 字节);hash_hmac('md5', …);CURLOPT_POSTFIELDS 传数组或 http_build_query 的结果(按表单提交);urlencode |
| Python | base64.b64encode;base64.b64decode(s, validate=True);hashlib.md5(b).hexdigest().upper();int(time.time() * 1000);requests.post(url, json=payload) | base64.urlsafe_b64encode;base64.encodebytes(每 76 字符换行,末尾也有换行);binascii.b2a_base64(默认末尾带换行);b64decode 不加 validate=True 会静默丢弃字母表外字符;hmac.new(…, hashlib.md5);requests.post(url, data=payload)(表单编码);未取整的 time.time() * 1000(JSON 和签名串里都成为 1794420120822.102 这样的小数) |
| Node.js | Buffer.toString('base64');crypto.createCipheriv('aes-256-cbc', key, iv)(默认 PKCS#7);digest('hex').toUpperCase();Date.now();fetch + JSON.stringify | 'base64url';crypto.createHmac('md5', …);请求体用 URLSearchParams 或 querystring.stringify;encodeURIComponent。注意 Buffer.from(s, 'base64') 接受 URL-safe 字母表并忽略空白,不能用来检验 body 是否合规 |
| Ruby | Base64.strict_encode64 / strict_decode64 | Base64.encode64(每 60 字符换行,末尾也有换行)、urlsafe_encode64;OpenSSL::HMAC |
| Perl | MIME::Base64::encode_base64($bytes, "");uc(Digest::MD5::md5_hex($s)) | encode_base64($bytes)(每 76 字符换行,末尾带 \n);md5_base64;URI::Escape::uri_escape |
| C / OpenSSL | EVP_EncryptInit_ex(ctx, EVP_aes_256_cbc(), NULL, key, iv)(默认 PKCS#7);EVP_EncodeBlock(不换行);EVP_Digest(..., EVP_md5(), NULL);printf("%02X");命令行 openssl enc -base64 -A。用 EVP_DecodeBlock 解码响应时,返回长度包含 = 填充产生的 0 字节,必须减去末尾 = 的个数(88 个字符的示例响应:66 − 2 = 64 字节),或改用 EVP_DecodeUpdate / EVP_DecodeFinal | BIO_f_base64 未设置 BIO_FLAGS_BASE64_NO_NL、EVP_EncodeUpdate / EVP_EncodeFinal、命令行不加 -A(每 64 字符换行,末尾也有换行);EVP_CIPHER_CTX_set_padding(ctx, 0);直接用 EVP_DecodeBlock 的返回值作为密文长度;HMAC(EVP_md5(), …) |
| 其他(Delphi、IVR 平台脚本等) | Base64 选「标准字母表、带填充、不换行」;HTTP 组件选「原始请求体 / raw JSON」;MD5 输出转大写 | 组件默认按行折返的 Base64;「表单参数」「URL 参数」方式提交;HMAC-MD5 |