业务示例代码

更新时间:2025.12.11
||

1. 目标

通过本教程的学习,你应该可以按"商户实际开发流程"完成支付分停车服务的端到端接入:

  1. 入场流程:调用创建停车入场上报入场信息,并基于停车入场状态变更通知实时维护商户内部车牌状态

  2. 离场场景:车辆离场时,根据状态变更通知或主动调用查询车牌服务开通信息判断车牌状态,并按状态分发后续动作:

    • 服务不可用 / 未开通 → 引导用户开通支付分停车服务(APP / 小程序 / H5)

    • 欠费(OVERDUE)→ 引导用户进行微信垫资还款(小程序 / H5)

    • 状态正常 → 进入扣款流程

  3. 扣款流程:调用扣费受理发起代扣,并完成支付结果通知 / 查询订单兜底。

    • 状态不符合提供服务条件 → 走线下支付兜底

    • 出现多次扣款(重复扣款 / 计费错误)→ 通过退款申请处理

  4. 错误码处理:扣费、退款及引导环节统一按错误码做幂等与告警处理。

2. 前置准备

2.1 商户配置

调用微信支付分停车接口前,需要完成以下配置:

配置项

获取方式

  商户号(mchid)

  微信支付商户平台 — 账户中心 → 商户信息 → 微信支付商户号

  商户API私钥

  参考商户开发参数 

  商户证书序列号

  参考获取证书序列号 

  微信支付公钥 / 公钥ID

  参考获取微信支付公钥ID 

  AppID

  商户在微信开放平台 / 公众平台申请的应用ID

以下代码演示了如何在停车业务代码里集中维护这些配置:

1package com.java.parking;
2
3/**
4 * 停车业务的全局参数。
5 * 所有接口客户端都从这里读取商户号 / 证书 / 公钥等配置。
6 */
7public class MerchantParkingConfig {
8  // TODO: 请按实际值替换。参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
9  public static final String MCH_ID = "1900000001";                             // 商户号
10  public static final String APP_ID = "wxaa0d15f0e3b20278";                     // AppID
11  public static final String NOTIFY_URL = "https://your-domain.com/wxpay/notify"; // 通知回调地址
12
13  public static final String MCH_CERTIFICATE_SERIAL_NO = "1DDE55AD98Exxxxxxxxxx";       // 商户 API 证书序列号
14  public static final String MCH_PRIVATE_KEY_FILE_PATH = "/path/to/apiclient_key.pem";  // 商户 API 证书私钥文件路径
15  public static final String WECHAT_PAY_PUBLIC_KEY_ID = "PUB_KEY_ID_xxxxxxxxxxxxx";     // 微信支付公钥 ID
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";                            // APIv3 密钥(用于解密通知)
18}

以上代码用到了微信支付工具库 WXPayUtility,详见工具库说明


3. 入场流程

本章关注点:车辆进场时,只做两件事——调入场接口、消费状态变更通知,把车牌当前状态实时同步进商户系统。

任何"用户引导(开通 / 还款)"都属于"离场场景",统一在第 4 章讨论。

3.1 创建停车入场

车辆进入停车场后,商户调用创建停车入场接口将入场信息上报给微信支付。微信支付会返回 parking_id 和车牌当前的服务状态(state),同时异步发送停车入场状态变更通知。

入场流程时序图:

入场状态流转:

 

入场阶段不要做用户引导,原因:

  • 入场时车主已离开车辆,没有可触达的前端容器;

  • 即便此时拉起页面也没有载体(车主未在 APP / 小程序 / H5 上);

  • 引导动作统一延后到离场时,借助闸口的 H5 / 小程序扫码触达用户。

入场阶段商户仅需做两件事:① 落库 parking_idstateblock_reason;② 等待 3.2 通知更新最新状态。

代码示例:

1package com.java.merchant.parking;
2
3import com.java.demo.CreateParking; // 使用普通商户创建停车入场接口:https://pay.weixin.qq.com/doc/v3/merchant/4012533937
4import static com.java.demo.CreateParking.*;
5import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
6
7public class CreateParkingDemo {
8  // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
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      // TODO: 入场阶段:仅做"落库 + 等通知",不做用户引导
37      //   持久化 id / state / block_reason / 状态时间戳到商户车牌状态表
38      //   维度:(plate_number, plate_color)
39      //   字段:parking_id, state, block_reason, last_update_time
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 取值:

parking_state

含义

商户操作

  NORMAL

  车牌服务正常,可以代扣

  更新内部状态为可用,离场时直接走 5.1 扣费

  BLOCKED

  车牌服务不可用

  更新内部状态 + `blocked_state_description`,离场时走第 4 章引导分支

blocked_state_description 取值与离场动作映射:

含义

离场时动作(详见第 4 章)

  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        /** 车牌颜色,枚举值:BLUE / GREEN / YELLOW / BLACK / WHITE / LIMEGREEN */
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        /** NORMAL / BLOCKED */
41        @SerializedName("parking_state")
42        public String parkingState;
43
44        /** parking_state = BLOCKED 时下发,枚举值:PAUSE / OVERDUE / REMOVE */
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        // 微信支付公钥 ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
65        this.wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
66        // 微信支付公钥文件路径,本地文件路径
67        String wechatPayPublicKeyPath = "/path/to/wxp_pub.pem";
68        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyPath);
69        // APIv3 密钥(用于解密通知体),获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053257
70        this.apiv3AesKey = "your_api_v3_key";
71    }
72
73    public CallbackResponse callback(Headers headers, String reqBody) {
74        try {
75            // 一步完成验签 + AEAD_AES_256_GCM 解密
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            // TODO: 写库前用 (sp_mchid, parking_id, state_update_time) 做幂等
82            // TODO: 仅当 state_update_time 比当前记录更新时才覆盖
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            // 真正的"用户引导"动作发生在车辆离场时(参见第 4 章)
92            if ("BLOCKED".equals(callbackData.parkingState)) {
93                // 落库 blocked_state_description 供离场时分流(PAUSE / OVERDUE / REMOVE)
94            }
95
96            // 成功,响应:200,无响应体
97            CallbackResponse response = new CallbackResponse();
98            response.http_code = 200;
99            return response;
100        } catch (Exception e) {
101            e.printStackTrace();
102            // 失败,响应:5xx 或 4xx
103            //   {"code":"FAIL","message":"失败"}
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; // 使用普通商户查询车牌服务开通信息接口:https://pay.weixin.qq.com/doc/v3/merchant/4012534043
4import static com.java.demo.QueryPlateService.*;
5import com.java.utils.WXPayUtility;
6
7public class QueryPlateServiceDemo {
8  // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
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      // TODO: 用查询结果刷新本地车牌状态表,分发到 4.2 / 4.3 / 5.x
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        // 错误:系统错误,多次失败时不要阻塞放行,转入 5.2 线下兜底并告警
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_SERVICEREMOVEPAUSE },或 service_state ∈ { OUT_SERVICEPAUSE }。

目标:在闸口前端拉起支付分停车开通页面,引导用户授权 / 恢复 / 重新签约,开通成功后再回到 4.1 重新判定状态。

调用注意事项(务必确认)

  1. 触达条件:必须在用户身处微信内(小程序 / H5)才能拉起;APP 内调用要求用户已安装微信。闸口 LED 屏 / 道闸广播提示用户扫闸口二维码进入 H5 或小程序是最可靠的载体。

  2. 参数透传完整性mchid / plate_number / plate_color 缺一不可,否则开通页面会显示"参数错误"。

  3. 车牌大小写与冠字plate_number 必须包含汉字省份冠字(如"粤B12345"),且与入场时一致;不一致会导致开通后状态变更通知无法关联到当前 parking_id

  4. 二次回查:调用 SDK 的 success 回调不能作为开通成功的最终凭据,必须以微信支付的状态变更通知二次查询接口确认 state = NORMAL 后再放行。

  5. 超时兜底:若用户拉起后超过约定时长(建议 90s)仍未回到 NORMAL,前端应提示"请走线下支付通道",转入 5.2。

  6. 幂等:同一 parking_id 重复拉起开通页面对账户无副作用,但商户应避免无限循环触发,建议同一离场流程内最多拉起 1~2 次。

APP 端:通过微信 OpenSDK 拉起

1// Android 端示例代码
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,本次扣费可继续。

调用注意事项(务必确认)

  1. 入口仅在微信内:垫资还款入口只支持小程序 + H5(APP OpenSDK 暂不支持)。

  2. businessType 与开通分离:开通用 wxpayScoreParking还款用 wxpayScoreParkingRepay

  3. 参数完整性:除 mchid / plate_number / plate_color 外,建议同时透传 parking_id

  4. 垫资完成回查:还款成功后微信支付会推送一条 parking_state = NORMAL 的状态变更通知;商户在拉起后不要立即重发代扣

  5. 失败兜底:用户取消 / 关闭页面后状态仍为 OVERDUE → 转入 5.2 线下支付。

  6. 避免反复拉起:同一 parking_id 单次离场流程内最多拉起一次还款页面。

  7. 金额无关:垫资是历史欠款问题,不要将本次停车应付金额传入垫资 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 * 离场流程:OVERDUE → 拉起垫资 → 后端轮询确认 service_state 是否回到 NORMAL。
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; // 使用普通商户扣费受理接口:https://pay.weixin.qq.com/doc/v3/merchant/4012534352
4import static com.java.demo.CreateTransaction.*;
5import com.java.utils.WXPayUtility;
6
7public class CreateTransactionDemo {
8  // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
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        // 错误:out_trade_no 冲突
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)   { /* TODO */ }
65  private void updateOfflineOrder(HashMap<String, Object> record) { /* TODO */ }
66  private OfflinePayResult invokeOfflineCashier(String tradeNo, int amount) {
67    return new OfflinePayResult();
68  }
69  private void reportAlert(OfflineReason reason, HashMap<String, Object> record) { /* TODO */ }
70  private void openGate(String parkingId) { /* TODO */ }
71}

运营建议:所有线下兜底订单建议单独建表或在主订单表用 pay_channel 区分。

5.3 订单支付结果通知

扣费受理后,微信支付异步完成扣款并通过回调通知商户支付结果(event_type: TRANSACTION.SUCCESS)。商户收到通知后应更新订单状态,并向微信支付返回成功应答。

trade_state 取值:

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        /** SUCCESS / PAY_FAIL / REFUND */
26        @SerializedName("trade_state")
27        public String tradeState;
28
29        @SerializedName("trade_state_description")
30        public String tradeStateDescription;
31
32        /** 付款银行类型;BPA 表示先享后付 / 垫资 */
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        /** 用户是否已完成补扣(垫资还款回执),bank_type=BPA 时下发,Y/N */
46        @SerializedName("user_repaid")
47        public String userRepaid;
48
49        @SerializedName("description")
50        public String description;
51
52        /** 交易场景:本场景为 PARKING */
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        // 微信支付公钥 ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
156        this.wechatPayPublicKeyId = "PUB_KEY_ID_xxxxxxxxxxxxx";
157        // 微信支付公钥文件路径,本地文件路径
158        String wechatPayPublicKeyPath = "/path/to/wxp_pub.pem";
159        this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyPath);
160        // APIv3 密钥(用于解密通知体),获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053257
161        this.apiv3AesKey = "your_api_v3_key";
162    }
163
164    public CallbackResponse callback(Headers headers, String reqBody) {
165        try {
166            // 一步完成验签 + AEAD_AES_256_GCM 解密
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                    // 终态:支付成功 → 更新订单状态为 PAID
181                    // 若 bank_type=BPA 且 user_repaid=Y,记录"用户已完成补扣(垫资还款回执)"
182                    break;
183                case "PAY_FAIL":
184                    // 终态:支付失败 → 更新订单状态为 FAILED;视情况转 5.2 线下兜底
185                    break;
186                case "REFUND":
187                    // 终态:订单已转入退款 → 更新订单状态为 REFUNDING
188                    break;
189                default:
190                    // 未知交易状态,告警并人工跟进
191                    break;
192            }
193
194            // 成功,响应:200,无响应体
195            CallbackResponse response = new CallbackResponse();
196            response.http_code = 200;
197            return response;
198        } catch (Exception e) {
199            e.printStackTrace();
200            // 失败,响应:5xx 或 4xx
201            //   {"code":"FAIL","message":"失败"}
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; // 使用普通商户查询订单接口:https://pay.weixin.qq.com/doc/v3/merchant/4012534308
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      // 非终态时按 5 秒间隔重试,最多 3 次
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          // 终态:支付成功 → 更新订单状态为 PAID
42          break;
43        case "PAY_FAIL":
44          // 终态:支付失败 → 更新订单状态为 FAILED
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; // 使用普通商户申请退款接口:https://pay.weixin.qq.com/doc/v3/merchant/4012557131
4import static com.java.demo.Create.*;
5import com.java.utils.WXPayUtility;
6
7public class CreateDemo {
8  // TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
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        // response.status 为 null
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}