## 10. 门店与订单接口 本章接口路径前缀 `/payment/openapi/alipay`;请求头与签名算法(3.2 / 3.3)逐字适用,错误处理总则(3.6)同样适用。 **关于寻址**:本章三个接口都按**你这条 API Key 所属的租户账号**寻址,取该租户名下由平台运营报备好的商户配置。你不需要在请求里指定归属。 **关于 `merchant_code`(新增,2026-09-24)**:平台**允许一个租户报备多个商户号**(例如你名下有多家门店主体,各自独立结算)。这时「这笔钱收进哪一个商户」必须由你在请求里说清楚,用 `merchant_code` 指定。 - **不传** ⇒ 平台取你租户名下的商户配置。 - **传了** ⇒ 一律以它为准,且必须是你租户名下已报备的编号,否则返回 404。 > ⚠️ **你租户下配了多个商户号、而你又不传 `merchant_code` 时,接口会直接**拒绝**(`code=400`,`msg` 为 `当前租户配置了多个商户编号,请在请求中指定 merchant_code`),不会替你挑一个。**这是有意的** —— 挑错了钱会进另一个商户的账户,而且全程没有任何报错,要等到对账才发现。**只配了一个商户号的调用方不受影响**,什么都不用改。 > **怎么知道自己配了几个**:登录平台,在「商户配置」页可以看到你名下的全部商户号。三个接口都填同一个 `merchant_code` 即可。 **门店与下单的关系**:10.1 里的 `shop_code` / `third_shop_code` 都是**选填**。门店不是下单的前置条件 —— 只有需要把交易归属到某个具体门店时才填。 --- ### 10.1 下单收款 ``` POST /payment/openapi/alipay/order/create ``` **请求** | 字段 | 类型 | 必填 | 说明 | |---|---|----|---| | `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**:这笔钱收进哪一个商户,见本章开头 | | `price` | number | 是 | 金额,**单位元**,必须 > 0,见下 | | `title` | string | 是 | 商品名称,≤64 字符 | | `out_biz_no` | string | 否 | 你自己的订单号,≤64 字符,原样存下供你查询 | | `shop_code` | string | 否 | 平台门店编号 | | `third_shop_code` | string | 否 | 门店编码 | | `ip` | string | 是 | 顾客用户端ip | | `pay_timeout` | int | 否 | 支付过期时间,取值 2–60(单位由上游定义,平台原样透传) | **响应**(B 型,`data` 内) | 字段 | 说明 | |---|---| | `order_no` | **平台订单号**,后续查询(10.2)用它 | | `out_biz_no` | 原样返回 | | `upstream_order_no` | 上游订单号。下单成功才有值,下单超时时为空 | | `platform_trade_no` | 平台交易号,支付成功后才有值 | | `pay_url` | 收款链接,渲染成二维码即可 | | `trade_status` | 订单状态,见 10.5 | | `price` | 订单金额(元) | | `title` | 原样返回 | | `shop_code` / `third_shop_code` | 你传的门店编码原样返回 | | `trade_time` | 支付成功时间 | | `rcv_amount` | 实收金额(元),支付成功后有值 | | `fee_amount` / `channel_amount` | 手续费 / 渠道金额 | | `refund_time` | 退款时间。退款到账(`trade_status` 为 `"2"`)后才有值 | | `notify_status` | 平台向你推送回调的结果,见 10.4 | | `created_time` / `updated_time` | 记录时间 | > ⚠️ **本接口不幂等 —— 重复调用会产生两笔订单、两次扣款。** 每次调用都会生成一个新的 `order_no` 并向上游发起一笔新的交易,平台**不做** `out_biz_no` 去重(也不做唯一约束)。超时后不要直接重发:先用 **10.2 订单查询** 按 `out_biz_no` 查一次,查到就用那一笔的结果。 > ⚠️ **`price` 的单位是「元」,是小数,不是「分」。** `1.5` 表示 1 元 5 角;把「元」当「分」写会差 100 倍。 > ⚠️ **金额的写法会直接影响签名。** 签名是对你**实际发出的那个值**做字符串化后算的(3.3),而平台把 JSON 数字读成双精度数:你写 `0.010`,平台看到的是 `0.01` —— 签名因此对不上,只会返回「签名验证失败」,不会告诉你原因。**请写成不带多余零的最简小数**(`0.01` / `1.5` / `100`),不要写 `0.010`、`1.50`。写成字符串(`"price": "0.01"`)也可以,字符串原样参与签名,最不容易出错。 > ⚠️ **下单失败时本地记录仍在,不要当作「什么都没发生」。** 上游调用失败会以 `code=500` 返回(`msg` 形如 `下单失败: <原因>`),但平台**已经落了本地记录并排入了状态轮询** —— 这笔交易仍可能成功。收到失败后请用 10.2 确认,不要凭这一次返回就向用户宣称交易未发生。 --- ### 10.2 订单查询 ``` POST /payment/openapi/alipay/order/query ``` **请求**(`order_no` 与 `out_biz_no` **至少提供一个**;都提供时以 `order_no` 为准) | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与其它接口同一个值。传了就只查这个商户的订单 | | `order_no` | string | 二选一 | 平台订单号(10.1 响应里的 `order_no`) | | `out_biz_no` | string | 二选一 | 你自己的订单号 | **响应**(B 型,`data` 内)—— 字段与 10.1 的响应一致,另加: | 字段 | 说明 | |---|---| | `upstream_create_time` | 上游建单时间 | > **本接口查的是平台已记录的状态,不会当场向上游发起查询。** 订单状态由平台的任务自动收敛(下单约 30 秒后开始查,之后按退避节奏重试),所以你刚下单就查、拿到的可能仍是 `"0"`;过一会儿再查即可。 > `order_no` 与 `out_biz_no` 都没传,返回 `code=500`,`msg` 为 `order_no 与 out_biz_no 至少填一个`。 > ⚠️ **查不到订单时返回的是 `code=500`,不是 `404`。** `msg` 为 `订单不存在`。**不属于你的订单也返回同一个 `msg`** —— 平台有意不区分「不存在」与「不属于你」,避免被用来探测他人订单是否存在。 --- ### 10.3 订单退款 对一笔**已支付成功**的订单发起退款。 ``` POST /payment/openapi/alipay/refund ``` **请求**(`order_no` 与 `out_biz_no` **至少提供一个**,规则同 10.2) | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `merchant_code` | string | 否 | **你租户下配了多个商户号时必填**,与其它接口同一个值 | | `order_no` | string | 二选一 | 平台订单号(10.1 响应里的 `order_no`) | | `out_biz_no` | string | 二选一 | 你自己的订单号 | | `reason` | string | 否 | 退款原因。**留空时平台补一个默认值**(`商户申请退款`);填了就以你填的为准 | > ⚠️ **本接口只有全额退款,请求里没有金额字段。** 这条通道的上游不支持部分退款 —— 发起即退整笔。需要退一部分的场景请先联系平台。 > ℹ️ **`reason` 为什么是选填**:通道上游把它列为必填,但没公布长度上限,所以平台不强制你填 —— 留空就替你补一个默认值,不会因此失败。想在自己的账上区分退款来源就填上。 **响应**(B 型,`data` 内):结构与 10.2 完全相同(同一份订单)。 > ⚠️ **返回时 `trade_status` 是 `"4"`(退款中),不是 `"2"`。** 同步返回只代表平台已把退款指令发给上游,**不代表钱已经退回用户账上**。 **结果怎么收敛**:`"4"` 由平台的查询任务持续向上游确认,直到拿到终态(上游若推了通知,平台也会转推给你,见 10.4): | 最终 `trade_status` | 含义 | |---|---| | `"2"` | **退款成功,钱已退回** | | `"1"` | 退款**没有发生**,订单回到已支付 | > ⚠️ **不要把 `"4"` 当成已退款,也不要凭它给用户承诺到账。** 用户看到的是钱有没有回来,不是接口返回了什么。同 10.4 的提醒:通知可能丢,**请对停在 `"4"` 的订单做主动查询兜底**。 > ⚠️ **只有 `trade_status = "1"` 的订单能退款。** 其它状态(`"0"` / `"4"` / `"2"` / `"8"`)一律拒绝,`code=500`,`msg` 形如 `订单当前状态不可退款: <状态值>`。查不到订单、或不是你的订单,仍然是 10.2 那句 `code=500` 的 `订单不存在`。 **重复调用会怎样** —— 这是最容易误解的一处: - 退款**不会重复发生**。第二次调用时订单已不在 `"1"`,会被上面那句错误挡下。 - 但它**不是幂等接口**:重复调用拿到的是**错误**,不是第一次的结果。所以不要写「重试到成功为止」,要按下面的分支处理。 **失败分两条路,处理方式相反**: | 返回 | 订单状态 | 你该做什么 | |---|---|---| | `code=500`,`msg` 形如 `退款失败: <原因>` | **退回 `"1"`** | 上游明确拒绝了,什么都没发生。可用 10.2 确认状态是 `"1"` 后**重新发起** | | `code=500`,`msg` 为 `退款结果未知(上游未响应),订单已置为退款中,请稍后查询订单状态确认` | **保持 `"4"`** | 上游没应答,**这笔退款可能已经发生**。**不要重发** —— 用 10.2 查到终态为止 | > ⚠️ **两条路都是 `code=500`,只能靠 `msg` 区分。** 拿不准时一律按「结果未知」处理(不重发、去查询):重发一笔可能已经退掉的款是真实资金损失,多查一次只是麻烦。 > ⚠️ **「退款失败就再退一次」的前提是订单已回到 `"1"`,不是你上次调用的返回。** 并发重复发起时只有一次会成功,另一次会被状态挡下 —— 但两次的返回里只有一次是「已受理」,别把两者搞混。 --- ### 10.4 回调通知 订单状态变化时,平台会向你推送一条回调。 **回调地址取自你这把 API Key 上配置的 `return_url`**(未配置时退回该租户的默认回调地址)。**没配时平台不会丢下这笔** —— 记一次推送失败并继续重试(见下),你补配地址后未达上限的订单仍会收到;但尽快补配,超过上限就不会再推了,届时只能靠主动查询(10.2)拿结果。 ``` POST <你的 return_url> Content-Type: application/x-www-form-urlencoded ``` **请求体**(表单编码,三个字段): | 字段 | 说明 | |---|---| | `notify_id` | 本次通知的唯一 ID,可用于去重 | | `timestamp` | 平台发出通知的时间,Unix 毫秒 | | `content` | **JSON 字符串**,业务内容见下(注意:是字符串,需要再解析一次) | `content` 示例: ```json { "third_order_no": "AP1234567890", "out_biz_no": "T20260924001", "order_no": "2026092499001", "platform_trade_no": "2026092499002", "merchant_code": "M0001", "third_shop_code": "S001", "price": 1.50, "title": "测试商品", "status": "1", "trade_time": "2026-09-24T10:30:00+08:00", "refund_time": null, "rcv_amount": 1.50, "fee_amount": 0.01, "channel_amount": 1.49 } ``` 金额字段是 **JSON 数字**(不是字符串);时间字段是 **ISO-8601 带时区偏移**的字符串。 > ⚠️ **回调里的 `order_no` 与 10.1 响应里的 `order_no` 不是同一个号。** > 回调里的 `order_no` 是**上游订单号**(等于 10.1 响应里的 `upstream_order_no`); > 回调里的 `third_order_no` 才是**平台订单号**(等于 10.1 响应里的 `order_no`)。 > 名字是反的,但对账必须靠它们,请按这层对应关系取用。 > **最省事的做法:始终用你自己生成的 `out_biz_no` 作主键做幂等,不要依赖这两个 `order_no`。** **你方需要做的**: 1. **应答**:处理成功后返回 **HTTP 2xx**。返回非 2xx 视为投递失败。 2. **去重**:同一笔订单可能收到多次通知。请用 `out_biz_no` + `status` 做幂等。 3. **快速应答**:先应答再异步处理。平台**首推只发一次**,失败(你方返回非 2xx、超时或连接异常)由平台自动重试:约每分钟一轮,累计尝试最多 **3 次(含首推)**;始终失败则停止,该单 `notify_status` 停在 `FAILED`。 > ⚠️ **推送的触发不止「上游通知到达」这一种。** 平台在**本地订单状态收敛后**推送:既包括收到上游通知,也包括平台自己的查询任务发现的状态变化(上游通知丢了的那条腿)。但重试有上限(见上)—— 超过上限就不会再推。**请仍给长期停在 `"0"` 的订单做主动查询兜底**,别把回调当作唯一渠道。 > ⚠️ **退款的最终结果(`"4"` → `"2"` / `"1"`)走的是同一条回调**,`content.status` 里就是新状态,`refund_time` 也在里面。所以上面的兜底对退款**同样适用,而且更要紧**:退款停在 `"4"` 时你等的是钱有没有回来,回调不响就只能自己查。**给长期停在 `"4"` 的订单也做主动查询。** > ⚠️ **回调不带签名。** 不能仅凭收到回调就认定收款成功。请以 **10.2 订单查询** 的结果为准,把回调当作「该查了」的触发信号。 --- ### 10.5 状态说明 **`trade_status` 是数字字符串**(JSON 里带引号,不是数字): | 值 | 含义 | 是否终态 | |---|---|---| | `"0"` | 等待支付 | 否 | | `"1"` | **支付成功,钱已到账** | 是 | | `"2"` | 已退款 | 是 | | `"4"` | 退款中 | 否 | | `"8"` | 交易关闭 | 是 | 判断「是否收到过钱」用 `"1"` / `"2"`;判断「是否还要等」用 `"0"` / `"4"`。 判断「退款是否已经到账」只看 `"2"` —— 停在 `"4"` 表示平台还在向上游确认,**不代表已退**(见 10.3)。 **订单 `notify_status`**(平台向你推送回调的结果): | 值 | 含义 | |---|---| | `PENDING` | 尚未发起过推送(订单刚生成 / 状态刚变化,推送还没回写结果) | | `SUCCESS` | 你方已成功接收 | | `FAILED` | 推送失败(你方返回非 2xx、超时异常,或你还没配置回调地址)。平台会自动重试,累计最多 3 次(含首推)后停止;补配地址后未达上限的订单仍会被重推 | --- ## 11. 门店与订单通道的联调检查清单 - [ ] 认证与签名复用 3.3 的实现,先跑通 3.4 的样例 - [ ] `price` 用**元**、写成最简小数(不要 `1.50`、`0.010`) - [ ] 下单**不重发** —— 超时先查 10.2,确认没有再决定 - [ ] 拿到了 `pay_url` 并能渲染成二维码 - [ ] 按 `trade_status` 的**数字字符串**分支处理(`"1"` 才是钱到账) - [ ] 回调接口能处理表单编码,并把 `content` **再解析一次** JSON - [ ] 回调按 `out_biz_no` 做幂等 - [ ] 对长期停在 `"0"` 的订单有主动查询兜底 - [ ] 退款只对 `trade_status = "1"` 的订单发起,且发起前用 10.2 确认过没有退过 - [ ] 退款返回 `退款结果未知…` 时**不重发**,改用 10.2 查到终态 - [ ] 退款结果按 `"2"`(已到账)判定,不把 `"4"` 当已退款