1. 目标
通过本教程的学习,你应该可以按"商户实际开发流程"完成支付分停车服务的端到端接入:
入场流程:调用创建停车入场上报入场信息,并基于停车入场状态变更通知实时维护商户内部车牌状态。
离场场景:车辆离场时,根据状态变更通知或主动调用查询车牌服务开通信息判断车牌状态,并按状态分发后续动作:
扣款流程:调用扣费受理发起代扣,并完成支付结果通知 / 查询订单兜底。
错误码处理:扣费、退款及引导环节统一按错误码做幂等与告警处理。
2. 前置准备
2.1 商户配置
调用微信支付分停车接口前,需要完成以下配置:
以下代码演示了如何在停车业务代码里集中维护这些配置:

1package com.java.parking;
2
3
4
5
6
7public class MerchantParkingConfig {
8
9 public static final String MCH_ID = "1900000001";
10 public static final String APP_ID = "wxaa0d15f0e3b20278";
11 public static final String NOTIFY_URL = "https://your-domain.com/wxpay/notify";
12
13 public static final String MCH_CERTIFICATE_SERIAL_NO = "1DDE55AD98Exxxxxxxxxx";
14 public static final String MCH_PRIVATE_KEY_FILE_PATH = "/path/to/apiclient_key.pem";
15 public static final String WECHAT_PAY_PUBLIC_KEY_ID = "PUB_KEY_ID_xxxxxxxxxxxxx";
16 public static final String WECHAT_PAY_PUBLIC_KEY_FILE_PATH = "/path/to/wxp_pub.pem";
17 public static final String API_V3_KEY = "your_api_v3_key";
18}以上代码用到了微信支付工具库 WXPayUtility,详见工具库说明。
3. 入场流程
本章关注点:车辆进场时,只做两件事——调入场接口、消费状态变更通知,把车牌当前状态实时同步进商户系统。
任何"用户引导(开通 / 还款)"都属于"离场场景",统一在第 4 章讨论。
3.1 创建停车入场
车辆进入停车场后,商户调用创建停车入场接口将入场信息上报给微信支付。微信支付会返回 parking_id 和车牌当前的服务状态(state),同时异步发送停车入场状态变更通知。
入场流程时序图:

入场状态流转:

入场阶段不要做用户引导,原因:
入场阶段商户仅需做两件事:① 落库 parking_id、state、block_reason;② 等待 3.2 通知更新最新状态。
代码示例:

1package com.java.merchant.parking;
2
3import com.java.demo.CreateParking;
4import static com.java.demo.CreateParking.*;
5import com.java.utils.WXPayUtility;
6
7public class CreateParkingDemo {
8
9 private static String mchid = "19xxxxxxxx";
10 private static String certificateSerialNo = "1DDE55AD98Exxxxxxxxxx";
11 private static String privateKeyFilePath = "/path/to/apiclient_key.pem";
12 private static String wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
13 private static String wechatPayPublicKeyFilePath = "/path/to/wxp_pub.pem";
14
15 public static void main(String[] args) {
16 CreateParking client = new CreateParking(
17 mchid,
18 certificateSerialNo,
19 privateKeyFilePath,
20 wechatPayPublicKeyId,
21 wechatPayPublicKeyFilePath
22 );
23
24 CreateParkingRequest request = new CreateParkingRequest();
25 request.outParkingNo = "PKG" + System.currentTimeMillis();
26 request.plateNumber = "粤B888888";
27 request.plateColor = PlateColor.BLUE;
28 request.startTime = "2017-08-26T10:43:39+08:00";
29 request.parkingName = "欢乐海岸停车场";
30 request.freeDuration = 3600L;
31 request.notifyUrl = "https://www.weixin.qq.com/wxpay/pay.php";
32
33 Parking response;
34 try {
35 response = client.run(request);
36
37
38
39
40 System.out.println(response);
41 } catch (WXPayUtility.ApiException e) {
42 if ("PARAM_ERROR".equals(e.getErrorCode())) {
43
44 } else if ("INVALID_REQUEST".equals(e.getErrorCode())) {
45
46 } else if ("SIGN_ERROR".equals(e.getErrorCode())) {
47
48 } else if ("SYSTEM_ERROR".equals(e.getErrorCode())) {
49
50 } else if ("NO_AUTH".equals(e.getErrorCode())) {
51
52 } else if ("RATELIMIT_EXCEEDED".equals(e.getErrorCode())) {
53
54 } else {
55
56 }
57 }
58 }
59}3.2 停车入场状态变更通知
创建停车入场后,微信支付会异步发送停车入场状态变更通知(event_type: VEHICLE.ENTRANCE_STATE_CHANGE)回调到 3.1 接口里 notify_url 配置的地址。
重要: 无论创建入场时返回的状态是 NORMAL 还是 BLOCKED,都会收到状态变更通知。状态可能在入场期间随时变化(例如用户主动暂停服务、欠费、或重新开通服务),商户应始终以最新通知的 state_update_time 为准。
通知中的 parking_state 取值:
|
NORMAL | 车牌服务正常,可以代扣 | 更新内部状态为可用,离场时直接走 5.1 扣费 |
BLOCKED | 车牌服务不可用 | 更新内部状态 + `blocked_state_description`,离场时走第 4 章引导分支 |
blocked_state_description 取值与离场动作映射:
|
PAUSE | 已暂停车主服务 | 提示用户恢复服务(一般通过开通入口同流程) |
OVERDUE | 已授权签约但欠费 | 引导用户做微信垫资还款(4.3) |
REMOVE | 用户移除车牌 | 引导用户重新开通服务(4.2) |
幂等与应答时序:
商户必须在 5 秒内完成验签和应答;超时未应答 / 应答失败时,微信会按照(15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h,最多 15 次)的频次重复推送。
通知具有重试机制,必须用 (sp_mchid, parking_id, state_update_time) 做幂等去重。
同一 parking_id 可能收到乱序通知(旧通知到达晚于新通知),落库时必须比较 state_update_time,只接受比当前记录更新的状态,否则丢弃。
验签 / 解密通过 → HTTP 200/204,body 为空;失败 → HTTP 4XX/5XX,可附 {"code":"FAIL","message":"失败"}。
代码示例: 使用工具库 WXPayUtility.parseNotification 一步完成验签 + 解密,业务字段定义为内联结构体。

1package com.java.merchant.parking;
2
3import java.security.PublicKey;
4
5import com.google.gson.annotations.SerializedName;
6import com.java.utils.WXPayUtility;
7import com.java.utils.WXPayUtility.Notification;
8
9import okhttp3.Headers;
10
11public class ParkingStateChangeNotifyDemo {
12
13
14 public static class CallbackData {
15 @SerializedName("sp_mchid")
16 public String spMchid;
17
18 @SerializedName("parking_id")
19 public String parkingId;
20
21 @SerializedName("out_parking_no")
22 public String outParkingNo;
23
24 @SerializedName("plate_number")
25 public String plateNumber;
26
27
28 @SerializedName("plate_color")
29 public String plateColor;
30
31 @SerializedName("start_time")
32 public String startTime;
33
34 @SerializedName("parking_name")
35 public String parkingName;
36
37 @SerializedName("free_duration")
38 public Long freeDuration;
39
40
41 @SerializedName("parking_state")
42 public String parkingState;
43
44
45 @SerializedName("blocked_state_description")
46 public String blockedStateDescription;
47
48 @SerializedName("state_update_time")
49 public String stateUpdateTime;
50 }
51
52 public static class CallbackResponse {
53 @SerializedName("http_code")
54 public int http_code;
55
56 @SerializedName("code")
57 public String code;
58
59 @SerializedName("message")
60 public String message;
61 }
62
63 public ParkingStateChangeNotifyDemo() {
64
65 this.wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
66
67 String wechatPayPublicKeyPath = "/path/to/wxp_pub.pem";
68 this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyPath);
69
70 this.apiv3AesKey = "your_api_v3_key";
71 }
72
73 public CallbackResponse callback(Headers headers, String reqBody) {
74 try {
75
76 Notification notification = WXPayUtility.parseNotification(apiv3AesKey, wechatPayPublicKeyId,
77 wechatPayPublicKey, headers, reqBody);
78 String data = notification.getPlaintext();
79 CallbackData callbackData = WXPayUtility.fromJson(data, CallbackData.class);
80
81
82
83 System.out.println("收到停车入场状态变更通知:"
84 + " sp_mchid=" + callbackData.spMchid
85 + ", parking_id=" + callbackData.parkingId
86 + ", parking_state=" + callbackData.parkingState
87 + ", blocked_state_description=" + callbackData.blockedStateDescription
88 + ", state_update_time=" + callbackData.stateUpdateTime);
89
90
91
92 if ("BLOCKED".equals(callbackData.parkingState)) {
93
94 }
95
96
97 CallbackResponse response = new CallbackResponse();
98 response.http_code = 200;
99 return response;
100 } catch (Exception e) {
101 e.printStackTrace();
102
103
104 CallbackResponse response = new CallbackResponse();
105 response.http_code = 500;
106 response.code = "FAIL";
107 response.message = "失败";
108 return response;
109 }
110 }
111
112 private String wechatPayPublicKeyId;
113 private PublicKey wechatPayPublicKey;
114 private String apiv3AesKey;
115}
4. 离场场景
本章关注点:车辆出闸时(或闸口前判断阶段),先确定车牌当前状态,再据此分发到三条路径之一:
① 引导开通(4.2)
② 引导垫资还款(4.3)
③ 直接扣款(4.4 → 第 5 章)
4.1 状态来源:通知 vs 主动查询
商户系统应以本地维护的车牌状态为主(来自第 3.2 节通知),但在以下场景下需要主动调用查询车牌服务开通信息做兜底:
|
本地有最新通知(5 分钟内) | 直接使用本地状态,无需查询 |
本地状态较旧 / 缺失 | 主动调用查询接口刷新 |
商户系统重启后 | 关键车牌(即将出闸的)主动查询 |
怀疑有通知漏推 | 主动查询,并校对 `state_update_time` |
离场场景决策流程:

主动查询代码示例:

1package com.java.merchant.parking;
2
3import com.java.demo.QueryPlateService;
4import static com.java.demo.QueryPlateService.*;
5import com.java.utils.WXPayUtility;
6
7public class QueryPlateServiceDemo {
8
9 private static String mchid = "19xxxxxxxx";
10 private static String certificateSerialNo = "1DDE55AD98Exxxxxxxxxx";
11 private static String privateKeyFilePath = "/path/to/apiclient_key.pem";
12 private static String wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
13 private static String wechatPayPublicKeyFilePath = "/path/to/wxp_pub.pem";
14
15 public static void main(String[] args) {
16 QueryPlateService client = new QueryPlateService(
17 mchid,
18 certificateSerialNo,
19 privateKeyFilePath,
20 wechatPayPublicKeyId,
21 wechatPayPublicKeyFilePath
22 );
23
24 QueryPlateServiceRequest request = new QueryPlateServiceRequest();
25 request.appid = "wxcbda96de0b165486";
26 request.plateNumber = "粤B888888";
27 request.plateColor = PlateColor.BLUE;
28 request.openid = "oUpF8uMuAJOM2pxb1Q";
29
30 PlateService response;
31 try {
32 response = client.run(request);
33
34 System.out.println(response);
35 } catch (WXPayUtility.ApiException e) {
36 if ("PARAM_ERROR".equals(e.getErrorCode())) {
37
38 } else if ("INVALID_REQUEST".equals(e.getErrorCode())) {
39
40 } else if ("SIGN_ERROR".equals(e.getErrorCode())) {
41
42 } else if ("SYSTEM_ERROR".equals(e.getErrorCode())) {
43
44 } else if ("NO_AUTH".equals(e.getErrorCode())) {
45
46 } else if ("RATELIMIT_EXCEEDED".equals(e.getErrorCode())) {
47
48 } else {
49
50 }
51 }
52 }
53}4.2 分支 A:服务不可用 / 未开通 → 引导开通
触发条件:本地或查询结果为 BLOCKED 且 block_reason ∈ { OUT_SERVICE, REMOVE, PAUSE },或 service_state ∈ { OUT_SERVICE, PAUSE }。
目标:在闸口前端拉起支付分停车开通页面,引导用户授权 / 恢复 / 重新签约,开通成功后再回到 4.1 重新判定状态。
调用注意事项(务必确认)
触达条件:必须在用户身处微信内(小程序 / H5)才能拉起;APP 内调用要求用户已安装微信。闸口 LED 屏 / 道闸广播提示用户扫闸口二维码进入 H5 或小程序是最可靠的载体。
参数透传完整性:mchid / plate_number / plate_color 缺一不可,否则开通页面会显示"参数错误"。
车牌大小写与冠字:plate_number 必须包含汉字省份冠字(如"粤B12345"),且与入场时一致;不一致会导致开通后状态变更通知无法关联到当前 parking_id。
二次回查:调用 SDK 的 success 回调不能作为开通成功的最终凭据,必须以微信支付的状态变更通知或二次查询接口确认 state = NORMAL 后再放行。
超时兜底:若用户拉起后超过约定时长(建议 90s)仍未回到 NORMAL,前端应提示"请走线下支付通道",转入 5.2。
幂等:同一 parking_id 重复拉起开通页面对账户无副作用,但商户应避免无限循环触发,建议同一离场流程内最多拉起 1~2 次。
APP 端:通过微信 OpenSDK 拉起

1
2WXOpenBusinessView.Req req = new WXOpenBusinessView.Req();
3req.businessType = "wxpayScoreParking";
4
5HashMap<String, String> extraInfo = new HashMap<>();
6extraInfo.put("mchid", MerchantParkingConfig.MCH_ID);
7extraInfo.put("plate_number", "粤B12345");
8extraInfo.put("plate_color", "BLUE");
9req.extInfo = new Gson().toJson(extraInfo);
10
11api.sendReq(req);小程序端:通过 wx.openBusinessView

1wx.openBusinessView({
2 businessType: 'wxpayScoreParking',
3 extraData: {
4 mchid: '1900000001',
5 plate_number: '粤B12345',
6 plate_color: 'BLUE'
7 },
8 success(res) {
9 console.log('拉起开通页面成功', res);
10 },
11 fail(err) {
12 console.error('拉起开通页面失败', err);
13 }
14});H5 端:通过 JSAPI

1WeixinJSBridge.invoke('openBusinessView', {
2 businessType: 'wxpayScoreParking',
3 extraData: {
4 mchid: '1900000001',
5 plate_number: '粤B12345',
6 plate_color: 'BLUE'
7 }
8}, function(res) {
9 if (res.err_msg === 'openBusinessView:ok') {
10 console.log('用户已离开开通页面,回查车牌状态');
11 }
12});4.3 分支 B:欠费(OVERDUE)→ 引导微信垫资还款
触发条件:本地或查询结果为 BLOCKED 且 block_reason = OVERDUE(或通知中 blocked_state_description = OVERDUE)。
目标:拉起微信垫资还款页面,由微信先行垫付历史欠款给前序商户,恢复车牌可用状态后再继续本次扣费。垫资是用户与微信支付之间的资金动作,与本次停车扣费分账独立。
业务背景
用户在前序商户已发生扣款失败 / 欠款,导致车牌进入 OVERDUE。
直接发起代扣会被微信侧拒绝,错误码通常是 NOT_ENOUGH(用户欠款过多)。
用户在垫资页面同意微信垫付后,车牌状态变为 NORMAL,本次扣费可继续。
调用注意事项(务必确认)
入口仅在微信内:垫资还款入口只支持小程序 + H5(APP OpenSDK 暂不支持)。
businessType 与开通分离:开通用 wxpayScoreParking,还款用 wxpayScoreParkingRepay。
参数完整性:除 mchid / plate_number / plate_color 外,建议同时透传 parking_id。
垫资完成回查:还款成功后微信支付会推送一条 parking_state = NORMAL 的状态变更通知;商户在拉起后不要立即重发代扣。
失败兜底:用户取消 / 关闭页面后状态仍为 OVERDUE → 转入 5.2 线下支付。
避免反复拉起:同一 parking_id 单次离场流程内最多拉起一次还款页面。
金额无关:垫资是历史欠款问题,不要将本次停车应付金额传入垫资 extraData。
小程序端:通过 wx.openBusinessView 拉起垫资还款

1wx.openBusinessView({
2 businessType: 'wxpayScoreParkingRepay',
3 extraData: {
4 mchid: '1900000001',
5 plate_number: '粤B12345',
6 plate_color: 'BLUE',
7 parking_id: 'WX202503010000000001'
8 },
9 success(res) {
10 console.log('已拉起垫资还款页面', res);
11 },
12 fail(err) {
13 console.error('拉起垫资还款页面失败', err);
14 }
15});H5 端:通过 WeixinJSBridge 拉起垫资还款

1WeixinJSBridge.invoke('openBusinessView', {
2 businessType: 'wxpayScoreParkingRepay',
3 extraData: {
4 mchid: '1900000001',
5 plate_number: '粤B12345',
6 plate_color: 'BLUE',
7 parking_id: 'WX202503010000000001'
8 }
9}, function(res) {
10 if (res.err_msg === 'openBusinessView:ok') {
11 queryStateAndDeductOrFallback();
12 } else {
13 fallbackToOffline();
14 }
15});商户后端配合的回查模板

1package com.java.merchant.parking;
2
3import com.java.demo.QueryPlateService;
4import static com.java.demo.QueryPlateService.*;
5import com.java.utils.WXPayUtility;
6
7
8
9
10public class OverdueRepayDemo {
11 private static String mchid = "19xxxxxxxx";
12 private static String certificateSerialNo = "1DDE55AD98Exxxxxxxxxx";
13 private static String privateKeyFilePath = "/path/to/apiclient_key.pem";
14 private static String wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
15 private static String wechatPayPublicKeyFilePath = "/path/to/wxp_pub.pem";
16
17 public static void main(String[] args) {
18 QueryPlateService client = new QueryPlateService(
19 mchid, certificateSerialNo, privateKeyFilePath,
20 wechatPayPublicKeyId, wechatPayPublicKeyFilePath);
21
22 QueryPlateServiceRequest queryReq = new QueryPlateServiceRequest();
23 queryReq.appid = "wxcbda96de0b165486";
24 queryReq.plateNumber = "粤B888888";
25 queryReq.plateColor = PlateColor.BLUE;
26 queryReq.openid = "oUpF8uMuAJOM2pxb1Q";
27
28 final int MAX_RETRY = 3;
29 final long INTERVAL_MS = 3000L;
30
31 for (int i = 0; i < MAX_RETRY; i++) {
32 try {
33 PlateService resp = client.run(queryReq);
34 if ("NORMAL".equals(resp.serviceState)) {
35 return;
36 }
37 } catch (WXPayUtility.ApiException e) {
38 e.printStackTrace();
39 }
40 try {
41 Thread.sleep(INTERVAL_MS);
42 } catch (InterruptedException ie) {
43 Thread.currentThread().interrupt();
44 break;
45 }
46 }
47
48
49 }
50}4.4 分支 C:服务正常 → 进入扣款流程
当本地或查询结果为 NORMAL(或经过 4.2 / 4.3 引导后回到 NORMAL)时,进入第 5 章扣款流程。
重要:从离场判断到扣款受理之间应有毫秒级延迟。建议在 4.x 决策结束后直接拼装扣费请求,不要中间穿插耗时操作。
5. 扣款流程
本章关注点:车辆离场时实际发起代扣。状态符合走 5.1,不符合走 5.2 线下兜底;扣费异常 / 多次扣款走 5.5 → 第 6 章退款。
5.1 扣费受理
车辆离场且车牌状态确认为 NORMAL 时,商户调用扣费受理接口发起代扣。
扣费流程时序图:

重要: 必须确认扣费受理接口的交易状态返回 ACCEPTED 才能放行车辆。
代码示例:

1package com.java.merchant.parking;
2
3import com.java.demo.CreateTransaction;
4import static com.java.demo.CreateTransaction.*;
5import com.java.utils.WXPayUtility;
6
7public class CreateTransactionDemo {
8
9 private static String mchid = "19xxxxxxxx";
10 private static String certificateSerialNo = "1DDE55AD98Exxxxxxxxxx";
11 private static String privateKeyFilePath = "/path/to/apiclient_key.pem";
12 private static String wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
13 private static String wechatPayPublicKeyFilePath = "/path/to/wxp_pub.pem";
14
15 public static void main(String[] args) {
16 CreateTransaction client = new CreateTransaction(
17 mchid,
18 certificateSerialNo,
19 privateKeyFilePath,
20 wechatPayPublicKeyId,
21 wechatPayPublicKeyFilePath
22 );
23
24 CreateTransactionRequest request = new CreateTransactionRequest();
25 request.appid = "wxcbda96de0b165486";
26 request.outTradeNo = "TXN" + System.currentTimeMillis();
27 request.description = "停车场扣费";
28 request.attach = "深圳分店";
29 request.tradeScene = "PARKING";
30 request.goodsTag = "WXG";
31 request.notifyUrl = "https://www.weixin.qq.com/wxpay/pay.php";
32
33 OrderAmount amount = new OrderAmount();
34 amount.total = 888L;
35 amount.currency = "CNY";
36 request.amount = amount;
37
38 ParkingTradeScene parking = new ParkingTradeScene();
39 parking.parkingId = "5K8264ILTKCH16CQ250";
40 parking.plateNumber = "粤B888888";
41 parking.plateColor = PlateColor.BLUE;
42 parking.startTime = "2017-08-26T10:43:39+08:00";
43 parking.endTime = "2017-08-26T11:43:39+08:00";
44 parking.parkingName = "欢乐海岸停车场";
45 parking.chargingDuration = 3600L;
46 parking.deviceId = "DEVICE001";
47 request.parkingInfo = parking;
48
49 Transaction response;
50 try {
51 response = client.run(request);
52 if (!"ACCEPTED".equals(response.tradeState)) {
53 return;
54 }
55 System.out.println(response);
56 } catch (WXPayUtility.ApiException e) {
57 if ("PARAM_ERROR".equals(e.getErrorCode())) {
58
59 } else if ("INVALID_REQUEST".equals(e.getErrorCode())) {
60
61 } else if ("ALREADY_EXISTS".equals(e.getErrorCode())) {
62
63 } else if ("NOT_ENOUGH".equals(e.getErrorCode())) {
64
65 } else if ("SIGN_ERROR".equals(e.getErrorCode())) {
66
67 } else if ("SYSTEM_ERROR".equals(e.getErrorCode())) {
68
69 } else if ("NO_AUTH".equals(e.getErrorCode())) {
70
71 } else if ("RATELIMIT_EXCEEDED".equals(e.getErrorCode())) {
72
73 } else {
74
75 }
76 }
77 }
78}5.2 状态不符合的兜底:线下支付
触发条件:以下任一情况发生时,不要调用扣费受理,而是走线下支付(人工现金 / 商户自有微信收款 / 支付宝 / 银行卡 POS 等):
|
`service_state` = `OUT_SERVICE` 或 `block_reason` = `OUT_SERVICE` | 4.1 / 4.2 | 用户拒绝开通 / 拉起失败 |
`block_reason` = `EVALUATION_FAILED` | 4.1 | 综合评估未通过,用户无法通过开通页面恢复,必须线下 |
`block_reason` = `OVERDUE`,但用户取消垫资 / 4.3 轮询超时未恢复 | 4.3 | 用户拒绝还款 |
扣费受理返回 `NOT_ENOUGH` / `INVALID_REQUEST`(业务规则不符) | 5.1 | 走到这里说明 4.x 漏判;按线下处理 |
查询车牌服务开通信息接口连续异常(如多次 `SYSTEM_ERROR`) | 4.1 | 不阻塞放行,转线下并告警 |
用户在闸口主动选择"线下支付" | 商户前端 | 商户应保留兜底入口 |
商户侧记账字段建议
为保证日终对账与事后追溯,线下支付场景下商户内部订单表至少应记录以下字段:
|
`out_trade_no_internal` | string | 商户内部订单号 |
`parking_id` | string | 关联到入场记录 |
`plate_number` / `plate_color` | string | 车牌主键 |
`pay_channel` | enum | `OFFLINE_CASH` / `OFFLINE_WX` / `OFFLINE_ALIPAY` / `OFFLINE_POS` / `OFFLINE_OTHER` |
`offline_reason_code` | enum | 触发线下的分支 |
`wx_state_snapshot` | json | 决策时刻的状态快照 |
`wx_query_logkey` | string | 最后一次查询接口的 logkey |
`amount_fen` | int | 线下实收金额(分) |
`paid_at` | datetime | 线下完款时间 |
`operator` | string | 收款员 / 闸口设备 ID |
`reconcile_flag` | enum | 对账状态 |
示例代码(伪代码 + TODO)

1package com.java.merchant.parking;
2
3import java.util.HashMap;
4
5
6
7
8public class OfflineFallbackDemo {
9
10 public enum OfflineReason {
11 EVALUATION_FAILED,
12 OUT_SERVICE_REJECTED,
13 OVERDUE_REJECTED,
14 DEDUCT_NOT_ENOUGH,
15 DEDUCT_INVALID,
16 QUERY_API_DOWN,
17 USER_CHOICE
18 }
19
20 public enum PayChannel {
21 OFFLINE_CASH, OFFLINE_WX, OFFLINE_ALIPAY, OFFLINE_POS, OFFLINE_OTHER
22 }
23
24 public static class OfflinePayResult {
25 public PayChannel channel = PayChannel.OFFLINE_CASH;
26 public String paidAt;
27 public String operator;
28 }
29
30 public void handle(String parkingId,
31 String plateNumber, String plateColor,
32 int amountFen,
33 OfflineReason reason,
34 HashMap<String, Object> wxStateSnapshot) {
35
36 String outTradeNoInternal = "OFF" + System.currentTimeMillis();
37
38 HashMap<String, Object> record = new HashMap<>();
39 record.put("out_trade_no_internal", outTradeNoInternal);
40 record.put("parking_id", parkingId);
41 record.put("plate_number", plateNumber);
42 record.put("plate_color", plateColor);
43 record.put("amount_fen", amountFen);
44 record.put("offline_reason_code", reason.name());
45 record.put("wx_state_snapshot", wxStateSnapshot);
46 record.put("reconcile_flag", "PENDING");
47 saveOfflineOrder(record);
48
49 OfflinePayResult result = invokeOfflineCashier(outTradeNoInternal, amountFen);
50
51 record.put("pay_channel", result.channel.name());
52 record.put("paid_at", result.paidAt);
53 record.put("operator", result.operator);
54 record.put("reconcile_flag", "MATCHED");
55 updateOfflineOrder(record);
56
57 if (reason != OfflineReason.USER_CHOICE) {
58 reportAlert(reason, record);
59 }
60
61 openGate(parkingId);
62 }
63
64 private void saveOfflineOrder(HashMap<String, Object> record) { }
65 private void updateOfflineOrder(HashMap<String, Object> record) { }
66 private OfflinePayResult invokeOfflineCashier(String tradeNo, int amount) {
67 return new OfflinePayResult();
68 }
69 private void reportAlert(OfflineReason reason, HashMap<String, Object> record) { }
70 private void openGate(String parkingId) { }
71}运营建议:所有线下兜底订单建议单独建表或在主订单表用 pay_channel 区分。
5.3 订单支付结果通知
扣费受理后,微信支付异步完成扣款并通过回调通知商户支付结果(event_type: TRANSACTION.SUCCESS)。商户收到通知后应更新订单状态,并向微信支付返回成功应答。
trade_state 取值:
|
SUCCESS | 支付成功(终态) | 更新订单为 PAID;若 `bank_type`=BPA 且 `user_repaid`=Y,记录"用户已完成补扣(垫资还款回执)" |
PAY_FAIL | 支付失败(终态) | 更新订单为 FAILED;视情况转 5.2 线下兜底 |
REFUND | 订单已转入退款(终态) | 更新订单为 REFUNDING |
代码示例: 同样使用 WXPayUtility.parseNotification 一步完成验签 + 解密,业务字段以内联结构体定义(对应官方 JSON)。

1package com.java.merchant.parking;
2
3import java.security.PublicKey;
4import java.util.List;
5
6import com.google.gson.annotations.SerializedName;
7import com.java.utils.WXPayUtility;
8import com.java.utils.WXPayUtility.Notification;
9
10import okhttp3.Headers;
11
12public class PayResultNotifyDemo {
13
14
15 public static class CallbackData {
16 @SerializedName("appid")
17 public String appid;
18
19 @SerializedName("sp_mchid")
20 public String spMchid;
21
22 @SerializedName("out_trade_no")
23 public String outTradeNo;
24
25
26 @SerializedName("trade_state")
27 public String tradeState;
28
29 @SerializedName("trade_state_description")
30 public String tradeStateDescription;
31
32
33 @SerializedName("bank_type")
34 public String bankType;
35
36 @SerializedName("attach")
37 public String attach;
38
39 @SerializedName("success_time")
40 public String successTime;
41
42 @SerializedName("create_time")
43 public String createTime;
44
45
46 @SerializedName("user_repaid")
47 public String userRepaid;
48
49 @SerializedName("description")
50 public String description;
51
52
53 @SerializedName("trade_scene")
54 public String tradeScene;
55
56 @SerializedName("payer")
57 public Payer payer;
58
59 @SerializedName("amount")
60 public Amount amount;
61
62 @SerializedName("parking_info")
63 public ParkingInfo parkingInfo;
64
65 @SerializedName("promotion_detail")
66 public List<PromotionDetail> promotionDetail;
67 }
68
69 public static class Payer {
70 @SerializedName("openid")
71 public String openid;
72 }
73
74 public static class Amount {
75 @SerializedName("total")
76 public Long total;
77
78 @SerializedName("discount_total")
79 public Long discountTotal;
80
81 @SerializedName("payer_total")
82 public Long payerTotal;
83
84 @SerializedName("currency")
85 public String currency;
86 }
87
88 public static class ParkingInfo {
89 @SerializedName("parking_id")
90 public String parkingId;
91
92 @SerializedName("plate_number")
93 public String plateNumber;
94
95 @SerializedName("plate_color")
96 public String plateColor;
97
98 @SerializedName("start_time")
99 public String startTime;
100
101 @SerializedName("end_time")
102 public String endTime;
103
104 @SerializedName("parking_name")
105 public String parkingName;
106
107 @SerializedName("charging_duration")
108 public Long chargingDuration;
109
110 @SerializedName("device_id")
111 public String deviceId;
112 }
113
114 public static class PromotionDetail {
115 @SerializedName("promotion_id")
116 public String promotionId;
117
118 @SerializedName("name")
119 public String name;
120
121 @SerializedName("scope")
122 public String scope;
123
124 @SerializedName("type")
125 public String type;
126
127 @SerializedName("amount")
128 public Long amount;
129
130 @SerializedName("activity_id")
131 public String activityId;
132
133 @SerializedName("wechatpay_contribute")
134 public Long wechatpayContribute;
135
136 @SerializedName("merchant_contribute")
137 public Long merchantContribute;
138
139 @SerializedName("other_contribute")
140 public Long otherContribute;
141 }
142
143 public static class CallbackResponse {
144 @SerializedName("http_code")
145 public int http_code;
146
147 @SerializedName("code")
148 public String code;
149
150 @SerializedName("message")
151 public String message;
152 }
153
154 public PayResultNotifyDemo() {
155
156 this.wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
157
158 String wechatPayPublicKeyPath = "/path/to/wxp_pub.pem";
159 this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyPath);
160
161 this.apiv3AesKey = "your_api_v3_key";
162 }
163
164 public CallbackResponse callback(Headers headers, String reqBody) {
165 try {
166
167 Notification notification = WXPayUtility.parseNotification(apiv3AesKey, wechatPayPublicKeyId,
168 wechatPayPublicKey, headers, reqBody);
169 String data = notification.getPlaintext();
170 CallbackData callbackData = WXPayUtility.fromJson(data, CallbackData.class);
171
172 System.out.println("收到支付结果通知:"
173 + " sp_mchid=" + callbackData.spMchid
174 + ", out_trade_no=" + callbackData.outTradeNo
175 + ", trade_state=" + callbackData.tradeState
176 + ", trade_state_description=" + callbackData.tradeStateDescription);
177
178 switch (callbackData.tradeState != null ? callbackData.tradeState : "") {
179 case "SUCCESS":
180
181
182 break;
183 case "PAY_FAIL":
184
185 break;
186 case "REFUND":
187
188 break;
189 default:
190
191 break;
192 }
193
194
195 CallbackResponse response = new CallbackResponse();
196 response.http_code = 200;
197 return response;
198 } catch (Exception e) {
199 e.printStackTrace();
200
201
202 CallbackResponse response = new CallbackResponse();
203 response.http_code = 500;
204 response.code = "FAIL";
205 response.message = "失败";
206 return response;
207 }
208 }
209
210 private String wechatPayPublicKeyId;
211 private PublicKey wechatPayPublicKey;
212 private String apiv3AesKey;
213}5.4 查询订单(兜底 / 对账)
商户可通过查询订单接口主动查询扣费订单的支付状态。适用于:
未收到支付结果通知时主动查询
收到非终态时兜底查询最终结果
对账时核实订单状态
订单状态流转:

代码示例:

1package com.java.merchant.parking;
2
3import com.java.demo.QueryTransaction;
4import static com.java.demo.QueryTransaction.*;
5import com.java.utils.WXPayUtility;
6
7public class QueryTransactionDemo {
8 private static String mchid = "19xxxxxxxx";
9 private static String certificateSerialNo = "1DDE55AD98Exxxxxxxxxx";
10 private static String privateKeyFilePath = "/path/to/apiclient_key.pem";
11 private static String wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
12 private static String wechatPayPublicKeyFilePath = "/path/to/wxp_pub.pem";
13
14 public static void main(String[] args) {
15 QueryTransaction client = new QueryTransaction(
16 mchid,
17 certificateSerialNo,
18 privateKeyFilePath,
19 wechatPayPublicKeyId,
20 wechatPayPublicKeyFilePath
21 );
22
23 QueryTransactionRequest request = new QueryTransactionRequest();
24 request.outTradeNo = "20150806125346";
25
26 final int MAX_RETRY = 3;
27 final long INTERVAL_MS = 5000L;
28
29 Transaction response;
30 try {
31 response = client.run(request);
32
33 for (int i = 0; i < MAX_RETRY && "ACCEPTED".equals(response.tradeState); i++) {
34 try { Thread.sleep(INTERVAL_MS); }
35 catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; }
36 response = client.run(request);
37 }
38
39 switch (response.tradeState != null ? response.tradeState : "") {
40 case "SUCCESS":
41
42 break;
43 case "PAY_FAIL":
44
45 break;
46 case "REFUND":
47
48 break;
49 case "ACCEPTED":
50
51 break;
52 default:
53
54 break;
55 }
56 } catch (WXPayUtility.ApiException e) {
57 if ("PARAM_ERROR".equals(e.getErrorCode())) {
58
59 } else if ("RESOURCE_NOT_EXISTS".equals(e.getErrorCode())) {
60
61 } else if ("SIGN_ERROR".equals(e.getErrorCode())) {
62
63 } else if ("SYSTEM_ERROR".equals(e.getErrorCode())) {
64
65 } else {
66
67 }
68 }
69 }
70}5.5 多次扣款的处理 → 见第 6 章退款
实际运营中可能出现以下"重复扣款"场景:
|
同一离场被扣两次 | 商户重试扣费受理时 `out_trade_no` 没复用 | 查商户日志,对比同一 `parking_id` 下的成功 `out_trade_no` 是否 > 1 |
不同 `parking_id` 重复扣费 | 入场记录被重复创建 | 查 3.1 入场重复触发原因 |
已收线下又被代扣 | 4.x 决策与 5.1 之间状态发生变化 | 查 5.2 是否漏标 |
处理方法:识别重复后,对多余的成功订单调用退款申请做全额退款。
去重建议:商户内部建议以 parking_id 作为扣费幂等主键,单次离场流程内复用同一 out_trade_no 重试扣费受理。
6. 退款(如需要)
6.1 申请退款
支付成功后,如需要退款(如多次扣款、计费错误、用户投诉等),可通过退款申请接口发起退款。
退款单状态流转:

注意: 退款使用通用退款接口。退款文档地址:https://pay.weixin.qq.com/doc/v3/merchant/4012557131 。
代码示例:

1package com.java.merchant.parking;
2
3import com.java.demo.Create;
4import static com.java.demo.Create.*;
5import com.java.utils.WXPayUtility;
6
7public class CreateDemo {
8
9 private static String mchid = "19xxxxxxxx";
10 private static String certificateSerialNo = "1DDE55AD98Exxxxxxxxxx";
11 private static String privateKeyFilePath = "/path/to/apiclient_key.pem";
12 private static String wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
13 private static String wechatPayPublicKeyFilePath = "/path/to/wxp_pub.pem";
14
15 public static void main(String[] args) {
16 Create client = new Create(
17 mchid,
18 certificateSerialNo,
19 privateKeyFilePath,
20 wechatPayPublicKeyId,
21 wechatPayPublicKeyFilePath
22 );
23
24 CreateRequest request = new CreateRequest();
25 request.transactionId = "4200001234202504010123456789";
26 request.outRefundNo = "REFUND" + System.currentTimeMillis();
27 request.reason = "多次扣款,全额退款";
28
29 AmountReq amount = new AmountReq();
30 amount.refund = 888L;
31 amount.total = 888L;
32 amount.currency = "CNY";
33 request.amount = amount;
34
35 request.notifyUrl = "https://www.weixin.qq.com/wxpay/refund_notify.php";
36
37 Refund response;
38 try {
39 response = client.run(request);
40 try {
41 switch (response.status) {
42 case SUCCESS:
43
44 break;
45 case PROCESSING:
46
47 break;
48 case ABNORMAL:
49
50 break;
51 case CLOSED:
52
53 break;
54 default:
55
56 break;
57 }
58 } catch (NullPointerException e) {
59
60 }
61 System.out.println(response);
62 } catch (WXPayUtility.ApiException e) {
63 if ("SYSTEM_ERROR".equals(e.getErrorCode())) {
64
65 } else if ("FREQUENCY_LIMITED".equals(e.getErrorCode())) {
66
67 } else if ("USER_ACCOUNT_ABNORMAL".equals(e.getErrorCode())) {
68
69 } else if ("NOT_ENOUGH".equals(e.getErrorCode())) {
70
71 } else if ("NO_AUTH".equals(e.getErrorCode())) {
72
73 } else if ("SIGN_ERROR".equals(e.getErrorCode())) {
74
75 } else if ("PARAM_ERROR".equals(e.getErrorCode())) {
76
77 } else if ("INVALID_REQUEST".equals(e.getErrorCode())) {
78
79 } else {
80
81 }
82 }
83 }
84}