# 跑腿上游渠道接入文档(2B)

本文档为跑腿 2B SaaS 统一接入主文档。询价下单相关接口已按下单模式拆分为 3 个子文档,请根据业务模式选择对应文档:

# 1. 接入须知

# 1.1 约定

类别 描述
坐标系 腾讯侧使用的经纬度坐标一律为国标(腾讯地图默认坐标)
城市编码 GB/T 2260,WebService API | 腾讯位置服务 (opens new window)
距离 使用 作为统一单位
费用 使用 作为单位

# 1.2 ChangeLog

日期 主要内容 修订人
2025-3-6 初版 @reeceliu(刘亚男) @banchlmao(毛文浩) @yvesdong(董雪峰)
2025-10-27 询价、下单、取消、回调调整(1. 询价接口 2. 下单接口 3. 订单状态同步) @banchlmao(毛文浩)
2025-11-26 新增订单状态同步接口渠道方订单不存在或未派给腾讯侧错误码;新增错误码 400201 @lancezzhu(祝浪)
2025-12-16 新增下单带询价金额参数 @lancezzhu(祝浪)
2026-01-26 新增详情接口返回取送件照片 @lancezzhu(祝浪)
2026-03-11 下单接口新增回调地址 @lancezzhu(祝浪)
2026-05-14 新增骑手轨迹查询接口 @shelbyliang(梁宸)
2026-05-19 新增推单接口(2.2.9),加价推单接口(2.2.10),加小费接口(2.2.11) @marcusgma(马钊)
2026-06-03 完单回调新增签收类型 @shelbyliang(梁宸)

# 1.3 确认项

  • 测试环境仅支持"北京"城市询价及相关测试
  • 创单后金额是否固定不可变更,是否需要创单金额和完单金额保证一致
  • 取消订单是否会重试,重试次数、间隔分别是多少,是否需要支持送件中取消
  • 创单/取消失败后(超时、未收到响应)是否会重试创单/取消接口,如不重试在后续接收到回调初始化订单如何处理
  • 是否存在直接创单的场景,不经过询价直接创建订单
  • 询价阶段传入信息是否可作为下单直接使用,询价是否会脱敏用户信息
  • 节假日峰值询价 QPS 为多少
  • 是否存在一段时间内集中下单 & 取消的场景
  • 是否有自主联调平台供联调/测试使用,or 需要人工发单
  • 完单的回调是否需要骑手位置信息
  • 取消费需要以回调接口里的金额为准(部分场景会存在骑手/服务商取消也收取消费的情况)(服务商取消为直接通过回调使接入方感知)
  • 注意,目前腾讯侧城市编码均为地级市标准城市编码,WebService API | 腾讯位置服务 (opens new window),'北京'、'上海'、'重庆'、'天津'、'香港'、'澳门' 这几个直辖市的 city_code 为省级编码,需要适配,如北京 city_code:110000,上海:310000
  • 上线后可否全量打开入口,由腾讯侧控制实际开城节奏(双方业务可提前商议节奏)
  • 提供产品运营研发联系方式

# 2. 接口

# 2.1 通用约定

url_prefix:

如非特殊约定,统一使用如下规范

类型 描述
协议 HTTPS
请求方式 Post
数据格式 JSON,请设置 Header 的 Content-Type 为 application/json
编码 UTF-8
时间戳 毫秒
日期格式 日期格式采用 GB/T 7408-2005 标准中 "YYYY-MM-DD" 格式(示例:2022-08-01),时区统一采用东八区(即北京时间)。日期时间格式采用 "YYYY-MM-DD hh:mm:ss" 格式(示例:2022-08-01 19:00:00)。Java: "yyyy-MM-dd HH:mm:ss",PHP: "Y-m-d H:i:s",Go: "2006-01-02 15:04:05",Python: "%Y-%m-%d %H:%M:%S"

# 请求Body通用字段

名称 类型 描述 必须
seq_id string(36) 请求流水号,调用方自动生成一个随机 ID,建议使用 uuid Yes
timestamp long(13) 请求时间,Unix Timestamp 单位毫秒 Yes
nonce string(32) 随机字符串,数字与字母组合,区分大小写 Yes
api_key string(32) 腾讯出行服务分配给服务商的应用标识 Yes
sign string(32) 签名验证参数 Yes

# 请求响应统一数据结构

名称 类型 描述 必须
code int(11) 响应码,默认 0 为成功,其他为错误 Yes
message string(64) 错误消息,code 为 0 时返回空字符串,code 为非 0 时返回具体原因 No
data object 业务响应主体,code 为 0 时返回,具体返回对象结构见下文具体 API;code 为非 0 时不返回 No
{
    "code": 0,
    "message": "",
    "data": {
        // 业务字段
    }
}

# 2.2 被调(接入方调用腾讯出行)

询价与下单接口拆分说明:本主文档不再列举询价及下单接口,请根据实际下单模式查阅对应子文档:

# 2.2.1 取消订单

Route: /cancelOrder

请求Body:

名称 类型 描述 必须
order_id string(64) 订单号 Yes
order_type int(4) 查询订单 ID 类型,详细信息见附录 3.7 Yes
cancel_type int(4) 取消类型 No
cancel_reason string(64) 取消原因 No

响应Body业务字段:

名称 类型 描述 必须
cancel_result bool 取消结果,true: 取消成功,false: 取消失败 Yes(注意,响应 code 是代表业务响应正常,是否取消成功要根据本字段判断)
order_code string(64) 腾讯侧订单号 No
cancel_fail_reason string(64) 取消失败原因枚举,详细见附录 3.6 No
cancel_fee int 取消费 No

# 2.2.2 查询订单详情

Route: /orderDetail

请求Body:

名称 类型 描述 必须
order_id string(64) 订单号 Yes
order_type int(4) 查询订单 ID 类型,详细信息见附录 3.7 Yes

响应Body业务字段:

名称 类型 描述 必须
order_code string(64) 腾讯业务订单号 Yes
third_order_id string(64) 接入方业务订单号 Yes
order_status int(11) 订单状态 Yes
delivery_time int(13) 预计送达时间 No
total_fee int(11) 应付金额 Yes
pay_fee int(11) 实际支付金额 Yes
coupon_fee int(11) 优惠金额 No
cancel_fee int(11) 取消金额 No
sp_info SpInfo 服务商信息,详情结构见附录 SpInfo No
delivery_distance int(11) 配送距离 完单后:Yes
receive_code string(32) 收货码 Yes,打开收货码才有
rider_info RiderInfo 骑手信息 接单后:Yes
pickup_pic List<String> 取货照片 url No
complete_pic List<String> 妥投照片 url No

# 2.2.3 查询骑手信息(位置)

Route: /riderLocation

请求Body:

名称 类型 描述 必须
order_id string(64) 订单号 Yes
order_type int(4) 查询订单 ID 类型,详细信息见附录 3.7 Yes

响应Body业务字段:

名称 类型 描述 必须
data RiderLocationInfo 骑手信息 Yes

# 2.2.4 查询订单状态变更节点(可选)

Route: /orderStatusChangeNode

请求Body:

名称 类型 描述 必须
order_id string(64) 订单号 Yes
order_type int(4) 查询订单 ID 类型,详细信息见附录 3.7 Yes

响应Body业务字段:

名称 类型 描述 必须
data List<OrderStatusChangeNode> 订单状态变更节点,按变更时间正序排序,详细信息见附录对象实体表 Yes

# 2.2.5 二次确认(暂时不支持)

骑手接单后需要指定窗口内调用确认选单,否则超时将自动取消订单。

# 2.2.6 骑手轨迹查询

Route: /driverTrajectory

请求Body:

名称 类型 描述 必须
order_id string(64) 订单号 Yes
order_type int(4) 查询订单 ID 类型,详细信息见附录 3.7 Yes
start_time long 查询起始时间,不传或传入时间早于司机接单时间,按照司机接单时间查询。Unix Timestamp 单位秒 No
end_time long 不传且订单未到达终态,按照当前时间查询;不传或传入时间晚于订单终态时间,按照订单到达终态时间查询。Unix Timestamp 单位秒 No

响应Body业务字段:

名称 类型 描述 必须
data List<TrajectoryInfo> 司机轨迹点信息,详细信息见附录对象实体表 Yes

# 2.2.7 加小费接口

Route: /addTips

注意:加小费接口,同一笔订单,要么只传 fee,要么只传 total_fee,不可同时有值,不可同时为空或 0。

  • total_fee 表示总的小费金额(覆盖式)
  • fee 表示单次添加的小费(可累加)

请求Body:

名称 类型 描述 必须
third_order_id string(64) 接入方运单 ID Yes
fee int 单次小费金额,单位分(累加式) No
total_fee int 总的小费金额,单位分(覆盖式) No

响应Body业务字段:

名称 类型 描述 必须
third_order_id string(64) 接入方运单 ID(透传) Yes
order_code string(64) 腾讯侧 order_code Yes
total_fee int 累计的小费总额,单位分 Yes

# 2.2.8 预取消订单

Route: /preCancelOrder

请求Body:

名称 类型 描述 必须
order_id string(64) 订单号 Yes
order_type int(4) 查询订单 ID 类型,详细信息见附录 3.7 Yes

响应Body业务字段:

名称 类型 描述 必须
can_cancel bool 是否能取消 Yes
deduction_fee int 取消费 Yes
cancel_rule_detail CancelRuleDetail 取消规则 No

# 2.3 主调(腾讯调用接入方同步订单状态)

接入方提供 Host。

# 2.3.1 订单状态同步

Route: /status

请求Body:

名称 类型 描述 必须
order_code string(64) 腾讯业务订单号 Yes
third_order_id string(64) 接入方业务订单号 Yes
sp_info SpInfo 实际运力信息,详情结构见附录 SpInfo No
event int(11) 事件类型,见附录 3.2 事件类型 Yes
status int(11) 订单状态 Yes
fee_info FeeInfo 费用信息 Yes(接单后)
accepted_info AcceptedInfo 已接单事件相关信息 根据具体状态填充
reassign_info ReassignInfo 骑手转单事件相关信息 根据具体状态填充
arrived_info ArrivedInfo 已到达取件点事件相关信息 根据具体状态填充
delivering_info DeliveringInfo 骑士出发开启送货事件相关信息 根据具体状态填充
send_back_info SendBackInfo 送回物品事件相关信息 根据具体状态填充
cancel_info CancelInfo 取消事件相关 根据具体状态填充
completed_info CompletedInfo 完成事件相关 根据具体状态填充

响应Body业务字段:

名称 类型 描述 必须
accept_time int(13) 通知接收处理时间 Yes

# 3. 附录

# 3.1 对象实体表

ProductDetail

名称 类型 描述 必须
goods_name string(32) 物品名称 No
goods_num int 物品数量 No

AddressInfo

名称 类型 描述 必须
name string(32) 姓名/昵称 Yes
phone string(32) 手机,传入真实手机号 Yes
address_detail string(64) 详细地址 No,与 poi_address 中必须传递一个
poi_address string(64) poi 地址信息 No,与 address_detail 中必须传递一个
poi_title string(64) poi 标题 Yes
poi_lng double 经度 Yes
poi_lat double 纬度 Yes

SpInfo

名称 类型 描述 必须
sp_name string(64) 服务商名称 Yes
sp_code string(64) 服务商唯一标识 Yes

RiderInfo

名称 类型 描述 必须
name string(32) 名称 Yes
phone_type int(11) 1-AXN/真实号,2-AXB No,无值默认为 1
phone string(32) 手机号(真实号或 AXN 的虚拟号),phone_type=1 时有值 No
phones List<PhoneInfo> 下单人、发货人、收货人对应关系,phone_type=2 时有值 No

PhoneInfo

名称 类型 描述 必须
role_type int(11) 角色(1-下单人,2-收货人,3-发货人);下单人手机号以注册手机号为准 Yes
virtual_phone string(32) 虚拟手机号 Yes

RiderLocationInfo

名称 类型 描述 必须
rider_lng double 骑手经度 Yes
rider_lat double 骑手纬度 Yes
name String 骑手姓名 No
phone String 骑手电话 No

EstimateTimeInfo

名称 类型 描述 必须
accept_time int(13) 预计骑手接单时间 根据订单状态决定
depart_pick_time int(13) 预计出发时间 根据订单状态决定
arrive_pickup_point_time int(13) 预计取件时间 根据订单状态决定
finish_time int(13) 预计送达时间 根据订单状态决定
send_back_finish_time int(13) 送回预计送达时间 根据订单状态决定

CancelInfo

名称 类型 描述 必须
timestamp long 取消时间,Unix Timestamp 单位毫秒 Yes
reason_id int(11) 取消原因 Id No
reason_desc string(64) 取消原因 No
cancel_from int(11) 取消来源,1: 用户,2: 接入方系统取消 Yes
cancel_fee int(11) 取消费 No

ExceptionInfo

名称 类型 描述 必须
timestamp long 异常产生时间,Unix Timestamp 单位毫秒 Yes
exception_id int(11) 异常编码 Yes
exception_desc string(64) 异常原因 No

CancelRule

名称 类型 描述 必须
name string(64) 规则名称 Yes
start int(11) 开始时间 Yes
end int(11) 截止时间 Yes
fee int(11) 费用 Yes

OrderStatusChangeNode

名称 类型 描述 必须
order_status int(11) 订单状态 Yes
event int(11) 事件类型,见附录 3.2 事件类型 Yes
order_change_time long 订单状态变更时间戳,单位: 毫秒 Yes

GoodsDetail

名称 类型 描述 必须
name string 货品名称
count int 货品数量
price double 货品价值,选填,数值不小于 0,精确到小数点后两位(如果小数点后位数多于两位,则四舍五入保留两位小数)
unit string 货品单位

FeeInfo

名称 类型 描述 必须
total_fee int(11) 应付金额 Yes
pay_fee int(11) 实际支付金额(total_fee - coupon_fee) Yes
coupon_fee int(11) 优惠金额 No
cancel_fee int(11) 取消金额 No

TrajectoryInfo

名称 类型 描述 必须
name string(32) 名称 Yes
phone string(32) 手机号(真实号或 AXN 的虚拟号) No
rider_lng double 骑手经度 Yes
rider_lat double 骑手纬度 Yes
timestamp long 轨迹点采集时间,Unix Timestamp 单位秒 Yes

回调相关数据结构

AcceptedInfo

名称 类型 描述 必须
timestamp long 事件发生时间点,Unix Timestamp 单位毫秒 Yes
estimate_time_info EstimateTimeInfo 预估时间信息 No
rider_info RiderInfo 骑手信息 Yes
send_code string(32) 发货码 No
receive_code string(32) 收货码 Yes
rider_location_info RiderLocationInfo 骑手位置信息 Yes

ReassignInfo

名称 类型 描述 必须
timestamp long 事件发生时间点,Unix Timestamp 单位毫秒 Yes
reassign_type int(11) 转单原因类型,1-骑手转单,2-服务商转单 Yes
estimate_time_info EstimateTimeInfo 新预估时间信息 No
rider_info RiderInfo 新骑手信息 Yes
rider_location_info RiderLocationInfo 骑手位置信息 Yes

ArrivedInfo

名称 类型 描述 必须
timestamp long 事件发生时间点,Unix Timestamp 单位毫秒 Yes
estimate_time_info EstimateTimeInfo 新预估时间信息 No
rider_location_info RiderLocationInfo 骑手位置信息 Yes
rider_info RiderInfo 骑手信息 Yes

DeliveringInfo

名称 类型 描述 必须
timestamp long 事件发生时间点,Unix Timestamp 单位毫秒 Yes
estimate_time_info EstimateTimeInfo 新预估时间信息 No
pickup_pic List<String> 取件照片 url,回调的照片 url 为未经过风控及压缩图片且存在过期可能【慎用】 No
rider_location_info RiderLocationInfo 骑手位置信息 Yes
rider_info RiderInfo 骑手信息 Yes

CompletedInfo

名称 类型 描述 必须
timestamp long 事件发生时间点,Unix Timestamp 单位毫秒 Yes
rider_location_info RiderLocationInfo 骑手位置信息 Yes
complete_pic List<String> 妥投照片 url,回调的照片 url 为未经过风控及压缩图片且存在过期可能【慎用】 No
rider_info RiderInfo 骑手信息 Yes
receipt_type int 签收类型。1:正常签收;2:商家退回签收 No

SendBackInfo

名称 类型 描述 必须
timestamp long 事件发生时间点,Unix Timestamp 单位毫秒 Yes
estimate_time_info EstimateTimeInfo 新预估时间信息 No
rider_location_info RiderLocationInfo 骑手位置信息 Yes
rider_info RiderInfo 骑手信息 Yes

CancelRuleDetail

名称 类型 描述 必须
name string 规则名称 Yes
start int 开始时间 No
end int 结束时间 Yes
fee int 费用 Yes

# 3.2 订单状态表:供参考

状态编码 名称 描述 支付/退款状态说明
10205 DISPATCHING_SP_ORDER_FAIL 派单失败 已支付
30050 DISPATCHING 派单中 已支付
30100 PICKING 取件中 已支付
30200 ARRIVED_PICK_POINT 到达取件点 已支付
30300 DELIVERING 送件中 已支付
30400 SENDING_BACK 送回中 已支付
30700 CANCELING 取消中 已支付未退款
30710 CANCELED 已取消 已退款
30900 ARRIVED 已送达 已支付

事件类型

事件编码 名称 描述 必须
101 CREATE_FAILED 创单失败 Yes
150 DISPATCH 派单/改派 Yes
200 RIDER_ACCEPT 骑手接单 Yes
250 RIDER_REASSIGN 骑手转单 Yes: 有转单,No: 无转单
300 ARRIVE_PICK_POINT 到达取件点 Yes
400 DEPART_SEND 开始送件 Yes
450 ARRIVED 已送达 Yes
500 RIDER_SEND_BACK 骑手送回 Yes: 有送回,No: 无送回
670 SP_REASSIGN 运力骑手改派(回到派单中) Yes: 有改派,No: 无改派
950 CANCEL 取消,内部可能需要区分具体取消原因 Yes: 有取消,No: 无取消
960 REFUND 退款通知 Yes: 有退款,No: 无退款

# 3.3 物品类型表

id 类型
1 文件证照
2 服饰
3 食品饮料
4 蛋糕
5 鲜花
6 数码
7 水果生鲜
8 药品
9 汽配
10 个护美妆
11 家居家纺
12 其他

# 3.4 跑腿类型

id 类型
1 特惠拼送
2 极速直送

# 3.5 订单来源

id 类型
10 美团
20 饿了么
30 抖音
40 京东外卖
50 淘宝闪购
60 歪马微商城
70 有赞云

# 3.6 取消原因表--用户

id 父类别 子类别 类型
1 和自己有关 填写的取件/收件信息有误 单选
2 计划有变,暂时不需要寄件 单选
3 和平台有关 没有骑手接单 单选
4 派的骑手距离太远 单选
5 和骑手有关 联系不上骑手 单选
6 骑手要求我取消订单 单选
7 骑手行驶过于缓慢或长时间不移动 单选
20 其他 其他 输入框

# 3.6.1 取消失败原因表

取消失败原因
当前订单状态不支持
服务商侧取消失败
其它

# 3.7 查询订单ID类型表

id 订单类型
1 腾讯业务订单号
2 接入方业务订单号

# 3.8 腾讯错误码

code message 说明
0 success
400000 请求参数错误,备注超长
400001 请求 URL 错误
400002 请求方法错误
400003 appId 错误
400004 时间戳过期或者 nonce 重复
400005 签名验证失败
400101 请求频繁,请稍后重试
400102 询价价格已失效,请重新询价
400103 询价地址不支持
400100 通用非影响主流程错误码,无需关注的业务错误码
400201 渠道方订单状态不匹配
500000 其他系统错误,需要接入方以一定策略进行重试,建议指数递增重试:需要在 1s,2s,4s,8s,16s,32s,64s 进行重试,且最后一次重试应当在第一次重试 1min 以上

# 3.9 签名算法

# 3.9.1 描述

  1. 从请求串中获得 api_keyseq_idtimestampnonce 通用字段以及其他的业务字段和鉴权结果字段 sign
  2. 根据签名算法,对参与签名的内容进行签名:按照除 sign 外参数名称排序(字典升序排列)成 key1=value1&key2=value2&.... 的原始字符串 src1;请求报文中不存在的参数不参与签名。将原始字符串 + 分配给调用方的 api_secret 形成字符串 src2;将 src2 进行 md5 后转成大写形成签名内容 dest。
  3. 将步骤 2 中得到的签名内容 dest 与请求中的 sign 字段内容做比较,如果相同则验证成功,否则判定请求非法。

举例:假设注册接口文档中业务字段为 [name, phone, address],分配的 api_secret = DZaslH9B9ycqRrE77laCPB2Om,请求参数如下:

api_key=PSUBZLHOKUO6HV52A5CAUSSE5KSB6Y
seq_id=b8b4f0b8-01fb-4c06-80b9-3ab895a8c616
timestamp=1726214265
nonce=123456
name=test
phone=11111111111
address=北京市朝阳区亚洲金融大厦

则需要签名的内容:

address=北京市朝阳区亚洲金融大厦&api_key=PSUBZLHOKUO6HV52A5CAUSSE5KSB6Y&name=test&nonce=e2fa80izRF&phone=11111111111&seq_id=b8b4f0b8-01fb-4c06-80b9-3ab895a8c616&timestamp=1726214265DZaslH9B9ycqRrE77laCPB2Om
  1. 计算的 MD5 值为 afc4a5b550c1f5c92bca917aba66faf5,大写值为 AFC4A5B550C1F5C92BCA917ABA66FAF5
  2. 将步骤 4 中得到的 MD5 值,与请求中 sign 字段的值比较。两者相同请求合法。

注意:签名时对对象类型的 value 需进行 json 序列化后的字符串拼接,这里的 json 需与主调 HTTP 客户端传输的 json 保持 key 顺序一致,验签时会原样提取 json 中的 value。

# 3.9.2 示例

# Java

算法工具类

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.TreeMap;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.collections.MapUtils;
import org.apache.commons.lang3.StringUtils;

/**
 * OpenApiSignUtil
 *
 * @Description: 开放平台接入鉴权工具类
 */
@Slf4j
public class OpenApiSignUtil {

    private static final String SIGN_KEY = "sign";

    private static final ObjectMapper objectMapper;

    static {
        objectMapper = new ObjectMapper();
    }

    /**
     * @param requestJson 请求的 json,注意如果为 null 的字段 key 不要放进 json 里,不参与签名,也不要包含 sign
     * @param secretKey   秘钥
     * @return 签名结果
     */
    public static String sign(String requestJson, String secretKey) {
        JsonNode node = toJsonNode(requestJson);
        return sign(node, secretKey);
    }

    private static String sign(JsonNode node, String secretKey) {
        Map<String, String> params = trans2Map(node);
        return sign(params, secretKey);
    }

    /**
     * @param params    注意如果为 null 的字段 key 不要放进 map 里,不参与签名,也不要包含 sign
     * @param secretKey 秘钥
     * @return 签名结果
     */
    public static String sign(Map<String, String> params, String secretKey) {
        // 缺省即为字典升序
        TreeMap<String, Object> signMap = new TreeMap<>();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            if (StringUtils.equals(entry.getKey(), SIGN_KEY)) {
                continue;
            }
            String key = entry.getKey();
            String value = entry.getValue();
            signMap.put(key, value);
        }
        String signatureParam = join(signMap, "&", "=", true) + secretKey;
        String sign = DigestUtils.md5Hex(signatureParam).toUpperCase();
        log.info("sign params: {}, sign: {}", signatureParam, sign);
        return sign;
    }

    public static boolean verify(String requestJson, String secretKey) {
        JsonNode node = toJsonNode(requestJson);
        return verify(node, secretKey);
    }

    public static boolean verify(JsonNode node, String secretKey) {
        Map<String, String> params = trans2Map(node);
        return verify(params, secretKey);
    }

    public static boolean verify(Map<String, String> params, String secretKey) {
        if (MapUtils.isEmpty(params)) {
            return false;
        }
        String signatureVerify = null;
        for (Map.Entry<String, String> entry : params.entrySet()) {
            if (StringUtils.equals(entry.getKey(), SIGN_KEY)) {
                signatureVerify = entry.getValue();
                break;
            }
        }
        String sign = sign(params, secretKey);
        return StringUtils.equals(sign, signatureVerify);
    }

    private static JsonNode toJsonNode(String jsonStr) {
        try {
            return objectMapper.readTree(jsonStr);
        } catch (JsonProcessingException e) {
            log.error("json jsonStr to jsonNode failed for jsonStr:{}", jsonStr, e);
            throw new RuntimeException("反序列化异常");
        }
    }

    private static Map<String, String> trans2Map(JsonNode node) {
        Iterator<String> fields = node.fieldNames();
        Map<String, String> params = new HashMap<>();
        while (fields.hasNext()) {
            String key = fields.next();
            JsonNode value = node.get(key);
            if (value.isTextual()) {
                params.put(key, value.asText());
            } else {
                params.put(key, value.toString());
            }
        }
        return params;
    }

    private static <K, V> String join(Map<K, V> map, String separator, String keyValueSeparator, boolean isIgnoreNull) {
        final StringBuilder strBuilder = new StringBuilder();
        boolean isFirst = true;
        if (MapUtils.isNotEmpty(map)) {
            for (Entry<K, V> entry : map.entrySet()) {
                if (!isIgnoreNull
                        || null != entry.getKey()
                        && null != entry.getValue()) {
                    if (isFirst) {
                        isFirst = false;
                    } else {
                        strBuilder.append(separator);
                    }
                    strBuilder.append(null == entry.getKey() ? StringUtils.EMPTY : entry.getKey().toString())
                            .append(keyValueSeparator)
                            .append(null == entry.getValue() ? StringUtils.EMPTY : entry.getValue().toString());
                }
            }
        }
        return strBuilder.toString();
    }
}

用例

import org.junit.jupiter.api.Test;

class OpenApiSignUtilTest {

    @Test
    void testOpenApiSignUtil() {
        String requestBody = "{\"name\":\"demoData\",\"spId\":1,\"spGoodsCategoryId\":1,\"goodsCategoryId\":1,\"sign\":\"676E5EFB9EED600E5F2795E27941EA00\"}";
        String secretKey = "123456";
        String sign = OpenApiSignUtil.sign(requestBody, secretKey);
        boolean isVerified = OpenApiSignUtil.verify(requestBody, secretKey);
        System.out.println("Signed Data: " + sign);
        System.out.println("Is Verified: " + isVerified);
    }

    @Test
    void testOpenApiSignUtil2() {
        String requestBody = "{\"b\":\"2\",\"a\":1,\"c\":3,\"ext\": {\"b\":\"2\",\"a\":1, \"c\":3},\"sign\":\"58963F0D37D6E04910D873D77B277ADF\"}";
        String secretKey = "123456";
        String sign = OpenApiSignUtil.sign(requestBody, secretKey);
        boolean isVerified = OpenApiSignUtil.verify(requestBody, secretKey);
        System.out.println("Signed Data: " + sign);
        System.out.println("Is Verified: " + isVerified);
    }
}
# Golang
import "github.com/tidwall/gjson"

func Verify(params map[string]interface{}, apiSecretKey string) (bool, error) {
    var keys []string
    for key := range params {
        if key == signKey {
            continue
        }
        keys = append(keys, key)
    }
    sort.Strings(keys)
    sb := strings.Builder{}
    lastKeyIndex := len(keys) - 1
    for index, key := range keys {
        sb.WriteString(key)
        sb.WriteString(splitKey1)
        value := gjson.Get(body, key)
        sb.WriteString(value.String())
        if index < lastKeyIndex {
            sb.WriteString(splitKey2)
        }
    }
    sb.WriteString(apiSecretKey)
    signStr := sb.String()
    m := md5.New()
    _, _ = io.WriteString(m, signStr)
    signVerify := fmt.Sprintf("%x", m.Sum(nil))
    signParam, _ := params[signKey]
    return signVerify == signParam, nil
}
# Python
# -*- coding: utf-8 -*-
"""
OpenApiSignUtil
开放平台接入鉴权工具类(由 Java 版本翻译而来)

签名规则:
1. 参数按 key 字典升序排列
2. 跳过 key == "sign" 的字段
3. key 或 value 为 None 的条目不参与签名(对应 Java 的 isIgnoreNull=true)
4. 拼接格式:key1=value1&key2=value2... + secretKey
5. 对拼接结果做 MD5,结果转大写
"""

import hashlib
import json
import logging
from typing import Dict, Optional, Union

logger = logging.getLogger(__name__)

SIGN_KEY = "sign"


def _to_str_value(value) -> str:
    """
    将 JSON 的 value 转换为字符串,等价于 Java 中的 trans2Map 逻辑:
    - 文本类型直接 asText()
    - 其他类型用 toString()(即原始 JSON 字符串形式)
    """
    if value is None:
        return None
    if isinstance(value, str):
        return value
    if isinstance(value, bool):
        # Java 中 JsonNode 的布尔 toString 为 "true"/"false"
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        # 与 Java JsonNode.toString() 表现一致
        return str(value)
    # dict / list 等结构化对象,输出紧凑 JSON(与 Java JsonNode.toString() 等价)
    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))


def _trans2map(node: Union[str, dict]) -> Dict[str, str]:
    """
    将 json 字符串或 dict 转换为 Map<String, String>
    """
    if isinstance(node, str):
        try:
            node = json.loads(node)
        except json.JSONDecodeError as e:
            logger.error("json jsonStr to jsonNode failed for jsonStr: %s", node, exc_info=e)
            raise RuntimeError("反序列化异常") from e

    if not isinstance(node, dict):
        raise RuntimeError("反序列化异常: 输入不是 JSON 对象")

    params: Dict[str, str] = {}
    for key, value in node.items():
        params[key] = _to_str_value(value)
    return params


def _join_map(params: Dict[str, Optional[str]],
              separator: str = "&",
              kv_separator: str = "=",
              ignore_null: bool = True) -> str:
    """
    等价于 Java 中的 join 方法,按 TreeMap 字典升序拼接
    """
    if not params:
        return ""

    parts = []
    # TreeMap 字典升序
    for key in sorted(params.keys()):
        value = params[key]
        if ignore_null and (key is None or value is None):
            continue
        k_str = "" if key is None else str(key)
        v_str = "" if value is None else str(value)
        parts.append(f"{k_str}{kv_separator}{v_str}")
    return separator.join(parts)


def sign(params: Union[str, dict, Dict[str, str]], secret_key: str) -> str:
    """
    生成签名

    :param params: 请求参数,可以是 json 字符串、dict 或 Map<String, String>
                   注意:如果为 None 的字段 key 不要放进里面,不参与签名,也不要包含 sign
    :param secret_key: 秘钥
    :return: 大写 MD5 签名结果
    """
    if isinstance(params, str):
        params_map = _trans2map(params)
    elif isinstance(params, dict):
        # 如果 dict 的值已经全是 str,直接用;否则按 trans2Map 规则转
        params_map = {k: _to_str_value(v) for k, v in params.items()}
    else:
        raise TypeError("params 必须是 str、dict 或 Map")

    # 跳过 sign 字段
    sign_map = {k: v for k, v in params_map.items() if k != SIGN_KEY}

    signature_param = _join_map(sign_map, "&", "=", True) + secret_key
    sign_result = hashlib.md5(signature_param.encode("utf-8")).hexdigest().upper()
    logger.info("sign params: %s, sign: %s", signature_param, sign_result)
    return sign_result


def verify(params: Union[str, dict, Dict[str, str]], secret_key: str) -> bool:
    """
    验签

    :param params: 请求参数,可以是 json 字符串、dict 或 Map<String, String>
    :param secret_key: 秘钥
    :return: 验签是否成功
    """
    if isinstance(params, str):
        params_map = _trans2map(params)
    elif isinstance(params, dict):
        params_map = {k: _to_str_value(v) for k, v in params.items()}
    else:
        raise TypeError("params 必须是 str、dict 或 Map")

    if not params_map:
        return False

    signature_verify = params_map.get(SIGN_KEY)
    expected_sign = sign(params_map, secret_key)
    return expected_sign == signature_verify


# ============== 使用示例 ==============
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")

    # 示例 1:使用 dict 签名
    demo_params = {
        "api_key": "3b875f0fc3ce445e84d0",
        "city_code": "110105",
        "city_name": "北京市",
        "enable_receive_code": "false",
        "express_type": "1",
        "goods_type": "12",
        "goods_weight": "8.0",
        "nonce": "EKNZhj7k0k",
        "order_phone": "18843246756",
        "receiver_address": '{"poi_lng":116.35722151393145,"name":"收某某","phone":"18899990000","poi_address":"","poi_lat":39.95082511963979,"address_detail":"北京","poi_title":"北京"}',
        "sender_address": '{"poi_lng":116.35567034726574,"name":"chichichi","phone":"18843246756","poi_address":"","poi_lat":39.92758260361654,"address_detail":"123【chichichi】","poi_title":"123【chichichi】"}',
        "seq_id": "82981d92-6023-4836-b9e5-071276e5566a",
        "third_order_id": "1623460265",
        "timestamp": "1781604216",
    }
    secret = "1acd6439e1c443dba684ac0df0a7f178"
    result = sign(demo_params, secret)
    print("签名结果:", result)

    # 示例 2:验签
    demo_params_with_sign = dict(demo_params)
    demo_params_with_sign["sign"] = result
    print("验签结果:", verify(demo_params_with_sign, secret))

# 3.10 运力列表 code

sp_code 名称
dada 达达
shunfeng2b 顺丰 B 端
shunfeng 顺丰 C 端
uu UU 跑腿
shansong 闪送
didi 滴滴
fengniao2b 蜂鸟 B 端

# 3.11 事件-状态-实体对应关系

事件-状态-实体对应关系说明

# 3.12 状态流转

状态流转说明