Add WeChat Pay local skills
This commit is contained in:
109
.codex/skills/wechatpay-payscore/SKILL.md
Normal file
109
.codex/skills/wechatpay-payscore/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: wechatpay-payscore
|
||||
description: 微信支付分接入解决方案,覆盖创单/确认/完结/扣款/退款全链路,提供选型/示例代码/业务速查/质量评估/排障五大能力。Use when user mentions "微信支付分", "支付分", "信用分", "免押租借", "免押金", "先享后付", "先免模式", "先享模式", "需确认订单", "支付分服务订单", or asks to "接入微信支付分", "要支付分接口示例代码", "排查支付分问题".
|
||||
author: wechatpay
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# 微信支付分接入指引
|
||||
|
||||
## 全局交互规范
|
||||
|
||||
> ‼️ 以下规则适用于本技能所有能力、所有对话轮次,优先级高于各能力的局部规则。
|
||||
|
||||
1. **所有问题必须得到用户明确回答后才能继续。** 一次提出多个问题时,逐一检查是否都已获得明确答复,未答复的必须再次追问,**严禁自行假设、推断或使用默认值**。
|
||||
2. **接入模式前置确认**:任何能力使用前须先确认 **商户模式** 或 **服务商模式**,已明确则无需重复。两种模式的核心差异(参数命名、API 路径前缀、签名工具类)见各角色 `接入指南/签名与验签规则.md`。
|
||||
3. **分步确认协议**(简单知识问答除外):
|
||||
- **① 明确需求**:先理解问题给出初步判断,不要堆参数清单。
|
||||
- **② 征得同意**:主动提出下一步能做什么,等用户明确同意后才继续。
|
||||
- **③ 收集信息**:用户同意后再告知所需信息并逐项收集,收齐才执行。
|
||||
- **④ 执行前确认**:操作前简要说明即将做什么,确认同意后再执行;线上环境额外提示风险。
|
||||
|
||||
## 能力概览
|
||||
|
||||
1. **产品选型** — 了解微信支付分的功能定位、需确认订单子模式(先免 / 先享)、典型使用场景与接入前提
|
||||
2. **示例代码** — 创建 / 查询 / 取消 / 完结 / 修改金额 / 同步状态 / 退款 / 查退 / JSAPI / 小程序 / APP 拉起 / 三类回调 全套接口
|
||||
3. **业务知识速查** — 开发参数与业务规则(含 APIv2 密钥与服务 ID)、订单状态流转、`risk_fund` / 完结金额公式 / `post_payments` 等字段规范、签名验签、回调处理
|
||||
4. **接入质量评估** — 通用安全雷达 + 支付分专属雷达:金额合法性、订单状态防误调、回调与查单互补、APIv2 调起签名、风险金上限校验、(服务商)`sub_mchid` 路由防串单
|
||||
5. **问题排查** — 错误码 TOP 20 + 常见问题,覆盖 HTTP 错误 / 回调收不到 / 签名失败 / 退款异常 / 角色特有问题 / 业务规则 / 通用接入配置
|
||||
|
||||
> 路由说明:能力 1 用于产品概念性问题与接入前提确认,已明确接入支付分的可直接跳到能力 2/3。能力 2 与 3 可独立调用,但首次必须先完成"接入模式前置确认"。能力 5 命中错误码或常见问题后,必须在末尾推荐能力 4 做一次性自查。
|
||||
|
||||
## 能力1:产品选型
|
||||
|
||||
> 用户问「这个产品是什么 / 能做什么 / 适合什么场景 / 接入需要什么前提 / 先免还是先享」时 → 加载产品介绍文档作答。已明确接入支付分的可直接跳到能力 2/3。
|
||||
|
||||
- 产品介绍(产品概览 + 使用场景 + 接入前提):
|
||||
- 商户模式 → [📄 商户模式产品介绍](./references/1-商户/产品选型/产品介绍.md)
|
||||
- 服务商模式 → [📄 服务商模式产品介绍](./references/2-服务商/产品选型/产品介绍.md)
|
||||
|
||||
## 能力2:示例代码
|
||||
|
||||
> 用户要某个接口的示例代码时 → 确认接入模式和语言,加载对应模式的 `接口索引.md` 定位代码文件。
|
||||
>
|
||||
> ‼️ **只检索、不生成。** 严禁从零编写任何代码,必须从示例代码文件中检索获取。
|
||||
>
|
||||
> ‼️ **只展示、不写入。** 示例代码仅用于讲解 API 调用结构和签名流程,严禁直接写入用户项目(禁止调用 write_to_file、replace_in_file 等工具创建或修改项目文件),让用户自行复制适配。
|
||||
>
|
||||
> ‼️ **先交互、后输出。** 提供代码前必须先确认接入模式、开发语言和具体接口,每次只输出一个接口;提供完代码后主动推荐接入质量评估。
|
||||
>
|
||||
> ‼️ **用户语言非 Java/Go 时**(本 skill 仅维护 Java/Go 示例):**禁止**直接生成跨语言代码。流程:
|
||||
> 1. 用 `AskQuestion` 获明确同意(文案需明示「参考实现 / 非官方维护 / 须自行 review 与测试」),未同意只发官方 Java/Go 原文。
|
||||
> 2. 同意后以官方 Java 示例为基准翻译生成业务代码「参考实现」;再用纯文字问是否翻 Java 公库(SDK 工具类 + HTTP 客户端),未明确要不贴。每段代码前附下方免责块。
|
||||
>
|
||||
> > ⚠️ 以下代码为**跨语言参考实现**,由 AI 参考官方 Java 示例翻译生成,并非微信支付官方维护。
|
||||
> > - 请**逐行 review** 签名构造、HTTP 调用、字段命名、回调解密等关键逻辑。
|
||||
> > - 上线前必须在测试环境完整验证,建议先以官方 Java/Go 示例打通主链路作为对照。
|
||||
> > - 出现接入问题时以官方 Java/Go 示例为准。
|
||||
>
|
||||
> ‼️ **拉起端类型确认规则**:「拉起确认订单页」「拉起订单详情页」必须先确认拉起端类型(JSAPI 公众号 / 小程序 / APP Android / APP iOS / APP 鸿蒙),不同端的报文与签名规则不同;其余通用接口(创建 / 查询 / 取消 / 完结 / 修改金额 / 同步状态 / 退款 / 查退 / 三类回调)无需询问拉起端。
|
||||
|
||||
- 涉及提供示例代码时,按接入模式查阅对应接口索引:
|
||||
- 商户模式 → [📄 商户模式接口索引](./references/1-商户/示例代码/接口索引.md)
|
||||
- 服务商模式 → [📄 服务商模式接口索引](./references/2-服务商/示例代码/接口索引.md)
|
||||
|
||||
> **加载策略**:先确认接入模式,读对应的 `接口索引.md` 定位接口文件路径,再按需加载具体文件。不要一次性加载所有文件。
|
||||
|
||||
## 能力3:业务知识速查
|
||||
|
||||
> 用户问开发参数与业务规则(mchid / sub_mchid / appid / sub_appid / APIv3 密钥 / APIv2 密钥 / 商户 API 证书 / 微信支付公钥 / 服务 ID)、订单状态、`risk_fund` / 完结金额公式 / `post_payments` 字段、签名规则、回调机制等业务知识时 → 按接入模式加载对应文档。
|
||||
|
||||
- 开发参数与业务规则(参数清单 + 获取步骤 + 订单状态流转 + 关键字段传参规范 + 对账与上线验收):
|
||||
- 商户模式 → [📄 商户模式开发参数与业务规则](./references/1-商户/接入指南/开发参数与业务规则.md)
|
||||
- 服务商模式 → [📄 服务商模式开发参数与业务规则](./references/2-服务商/接入指南/开发参数与业务规则.md)
|
||||
- 签名与验签规则(请求签名 / 响应验签 / 回调验签 / 调起支付分小程序 APIv2 签名):
|
||||
- 商户模式 → [📄 商户模式签名与验签规则](./references/1-商户/接入指南/签名与验签规则.md)
|
||||
- 服务商模式 → [📄 服务商模式签名与验签规则](./references/2-服务商/接入指南/签名与验签规则.md)
|
||||
- 回调处理(回调解密 / 验签 / 幂等 / 并发控制):
|
||||
- 商户模式 → [📄 商户模式回调处理](./references/1-商户/接入指南/回调处理.md)
|
||||
- 服务商模式 → [📄 服务商模式回调处理](./references/2-服务商/接入指南/回调处理.md)
|
||||
|
||||
## 能力4:接入质量评估
|
||||
|
||||
> 用户准备上线或想检查代码隐患时 → 加载以下文档。
|
||||
>
|
||||
> ‼️ **只检查用户实际使用的功能模块。** 分账、APP 拉起、小程序拉起 等模块须先确认用户是否涉及,**未使用的不检查、不提及**。
|
||||
|
||||
- 接入质量检查(含质检人设 + 检查清单):
|
||||
- 商户模式 → [📄 商户模式接入质量检查](./references/1-商户/接入指南/接入质量检查.md)
|
||||
- 服务商模式 → [📄 服务商模式接入质量检查](./references/2-服务商/接入指南/接入质量检查.md)
|
||||
|
||||
## 能力5:问题排查
|
||||
|
||||
> ‼️ **唯一入口**:用户报告**任何**问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 对账差异 / 业务规则疑问等),**都先按接入模式加载下方排障手册**,严格按手册内「排障流程」执行,**禁止自行猜测原因或直接分析代码**。
|
||||
>
|
||||
> ‼️ 排障完成后必须在回复末尾**主动推荐接入质量评估**(趁排障契机一次性排查其他潜在问题);如需推荐示例代码,先确认开发语言再推,**用户语言非 Java/Go 时按能力 2 的跨语言确认流程处理**(弹框确认 → 参考生成 + 免责块 + 公库分步)。
|
||||
|
||||
- 排障手册(一、错误码 TOP 20 + 二、常见问题,覆盖 HTTP / 回调 / 签名 / 退款 / 业务规则 / 通用配置):
|
||||
- 商户模式 → [📄 商户模式排障手册](./references/1-商户/问题排查/排障手册.md)
|
||||
- 服务商模式 → [📄 服务商模式排障手册](./references/2-服务商/问题排查/排障手册.md)
|
||||
|
||||
---
|
||||
|
||||
> 以下信息与技能能力无关,仅供查阅。
|
||||
|
||||
## 💬 社区与反馈
|
||||
|
||||
在使用过程中遇到问题、有改进建议,或者想和其他开发者交流接入经验,欢迎扫码添加企业微信进群,与官方团队和社区开发者一起讨论:
|
||||
|
||||
微信支付 Skills 交流群二维码
|
||||
@@ -0,0 +1,62 @@
|
||||
# 商户模式产品介绍
|
||||
|
||||
> 来源:[商户产品介绍](https://pay.weixin.qq.com/doc/v3/merchant/4012587050.md) + [开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012587166.md) + [权限申请](https://pay.weixin.qq.com/doc/v3/merchant/4012587112.md)
|
||||
|
||||
## 一、产品概览
|
||||
|
||||
微信支付分是基于用户身份特质、支付行为、使用历史等综合计算的信用分值。微信支付通过支付分在用户和商家之间建立平台,提供"先使用服务、再付款"的能力,典型场景如免押金借用充电宝、免押金住酒店、先乘车后付款、电商先用后付等。
|
||||
|
||||
商户接入支付分可以:
|
||||
|
||||
- 降低用户使用门槛(免押金 / 免预付)
|
||||
- 提升下单转化率与好感度
|
||||
- 扩大潜在用户群体
|
||||
- 通过自动扣款简化收款流程
|
||||
|
||||
## 二、需确认订单模式(先免 / 先享)
|
||||
|
||||
需确认订单模式是指:用户从商户下单页发起的每一笔订单,都需要跳转到微信支付分小程序,由用户授权确认是否使用支付分服务。商户接入时由微信支付行业运营共同确定适用的子模式:
|
||||
|
||||
| 子模式 | 说明 | 评估通过 | 评估不通过 |
|
||||
| ---- | ---- | -------- | -------- |
|
||||
| **先免模式** | 用户先免风险金(押金/预付款/保证金)享受服务,服务结束按实际费用扣款 | 免风险金使用服务 | 需缴纳风险金,服务结束后退还剩余 |
|
||||
| **先享模式** | 用户先享受服务后支付(按预估金额评估) | 先享后付 | 无法使用服务 |
|
||||
|
||||
> ‼️ 创单接口 `risk_fund.name` 取值受模式约束:先免只能传 `DEPOSIT`/`ADVANCE`/`CASH_DEPOSIT`,先享只能传 `ESTIMATE_ORDER_COST`。两种模式的 `total_amount`(实际收款)上限规则不同,详见 [开发参数与业务规则](../接入指南/开发参数与业务规则.md) 的"创单 risk_fund 字段"与"完结金额公式"章节。
|
||||
|
||||
## 三、典型使用场景
|
||||
|
||||
| 场景 | 适用业务 | 准入要点 |
|
||||
| ---- | -------- | -------- |
|
||||
| **免押租借** | 共享充电宝、共享雨伞、电动车租赁等 | 营业执照含"手机充电服务/电子设备租赁/手机充电设备服务/电子产品租赁服务"等类目 |
|
||||
| **先享后付** | 电商、网约车、寄快递、电动车充电、酒店、智慧加油等 | 业务场景与营业执照类目一致 |
|
||||
| **智慧零售** | 无人售货机柜(开柜购物) | 营业执照含"自动售货机/食品/食用农产品/预包装食品"等类目 |
|
||||
| **充电桩** | 二轮电动车 / 汽车充电桩 | 营业执照含"充电系统及设备/新能源发电及储能系统及设备/电能计量系统及设备"等类目 |
|
||||
|
||||
> ‼️ 商户必须按申请时的业务场景合规使用支付分功能,**不可跨场景使用**(例:申请了"共享充电宝"的服务 ID,不可用于其他业务)。
|
||||
|
||||
## 四、接入前提
|
||||
|
||||
### 4.1 资质与权限
|
||||
|
||||
1. **企业资质**:商户营业执照经营类目需包含上述业务对应类目
|
||||
2. **客服电话认证**:客服电话必须经过微信支付认证并显示在账单详情页
|
||||
3. **服务质量达标**:同一主体下所有商户服务质量需达标,主体无违规行为
|
||||
4. **权限申请**:发送邮件至 `weixinpay_scoreBD@tencent.com`,邮件标题/正文模板见 [权限申请文档](https://pay.weixin.qq.com/doc/v3/merchant/4012587112.md)
|
||||
5. **签署协议**:审核通过后登录商户平台 → 产品中心 → 支付拓展工具 → 微信支付分 → 申请开通 + 签署协议
|
||||
6. **获取服务 ID**:申请流程结束后,微信支付行业运营在对接群提供 `service_id`,所有支付分 API 都需要传该参数
|
||||
|
||||
> ‼️ 开发参数清单(含支付分专属的 **APIv2 密钥**、`service_id`、APIv3 密钥、商户 API 证书、微信支付公钥)、获取步骤与踩坑提示统一收拢到 [开发参数与业务规则](../接入指南/开发参数与业务规则.md),本页不再重复列出。
|
||||
|
||||
### 4.2 测试白名单
|
||||
|
||||
支付分服务上线前,**仅商户平台白名单中的微信号用户可使用**。开发联调时必须先到 商户平台 → 产品中心 → 微信支付分 → 测试号配置 添加测试微信号。上线前不会对真实用户开放。
|
||||
|
||||
### 4.3 接入产物
|
||||
|
||||
成功接入后将具备以下能力:
|
||||
|
||||
1. 创建支付分订单(`POST /v3/payscore/serviceorder`)→ 拉起 JSAPI/APP/小程序确认页 → 接收"用户确认订单回调"
|
||||
2. 服务结束后调用"完结订单"接口,由微信支付分自动轮询扣款 → 接收"支付成功回调"
|
||||
3. 异常处理:取消订单 / 修改金额 / 同步状态 / 申请退款 / 查询退款 / 退款回调
|
||||
4. 引导用户:调起订单详情页(JSAPI/APP/小程序)→ 用户主动支付待支付订单
|
||||
141
.codex/skills/wechatpay-payscore/references/1-商户/接入指南/回调处理.md
Normal file
141
.codex/skills/wechatpay-payscore/references/1-商户/接入指南/回调处理.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 商户模式回调处理
|
||||
|
||||
> 本文档为微信支付**通用回调处理规范**,适用于**商户**、**品牌直连**、**服务商**三种接入模式。三方在**回调报文解密、IP 白名单、应答要求、幂等、收不到回调排查**上完全一致;仅在 **`notify_url` 配置方式**和**回调归属维度**上有差异,差异点已在文中以"模式分支"标注。
|
||||
>
|
||||
> 各业务(如商品券、营销立减金、基础支付等)的**事件类型清单、解密后业务字段、二次确认接口路径**等业务专属内容,由各业务自身的接口文档提供,不在本通用文档范围内。
|
||||
|
||||
## 一、回调处理
|
||||
|
||||
### 前提条件
|
||||
|
||||
1. **必须设置 APIv3 密钥**(32 字节),未设置不会收到任何回调
|
||||
2. **必须配置 `notify_url`**,按接入模式分支处理:
|
||||
- **商户模式**:在下单/业务请求体里直接传入 `notify_url` 字段(如 JSAPI 下单),或在商户平台「产品中心 → 开发配置」中预设
|
||||
- **品牌直连**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /brand/marketing/{业务}/notify-config`),品牌维度
|
||||
- **服务商模式**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /v3/marketing/{业务}/notify-config`),服务商维度,所有子商户/品牌共用同一地址
|
||||
3. 回调地址要求:HTTPS + 域名已 ICP 备案 + 公网可访问
|
||||
4. 不能使用内网地址(127.0.0.1 / 192.168.x.x / localhost)
|
||||
|
||||
### 回调 IP 白名单
|
||||
|
||||
商户侧对微信支付回调 IP 有防火墙策略限制的,需要对以下 IP 段开通白名单:
|
||||
|
||||
|
||||
| 出口 | 网段/IP |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| 上海电信出口网段 | 101.226.103.0/25 |
|
||||
| 上海联通出口网段 | 140.207.54.0/25 |
|
||||
| 上海CAP出口网段 | 121.51.58.128/25 |
|
||||
| 深圳电信出口网段 | 183.3.234.0/25 |
|
||||
| 深圳联通出口网段 | 58.251.80.0/25 |
|
||||
| 深圳CAP出口网段 | 121.51.30.128/25 |
|
||||
| 香港出口网段 | 203.205.219.128/25 |
|
||||
| 广州腾讯云出口IP | 81.71.199.64, 81.71.198.25, 81.71.199.59 |
|
||||
| 退款结果通知、分账动账通知IP | 175.24.214.208, 175.24.211.24, 175.24.213.135, 109.244.180.23, 114.132.203.119, 43.139.43.69 |
|
||||
|
||||
|
||||
同时关闭 WAF/CC 防护对回调 URL 的拦截,避免误将微信支付回调请求判定为恶意请求。
|
||||
|
||||
### 回调报文与解密
|
||||
|
||||
回调通知整体结构(三种接入模式完全一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "通知唯一ID",
|
||||
"create_time": "2025-08-02T00:00:00+08:00",
|
||||
"event_type": "事件类型,由具体业务定义",
|
||||
"resource_type": "encrypt-resource",
|
||||
"resource": { /* 加密资源对象,结构见下 */ }
|
||||
}
|
||||
```
|
||||
|
||||
`resource` 字段为加密资源对象,三种接入模式完全一致(参考官方文档:[商户](https://pay.weixin.qq.com/doc/v3/merchant/4012071382) / [品牌](https://pay.weixin.qq.com/doc/brand/4015407591) / 服务商):
|
||||
|
||||
```json
|
||||
{
|
||||
"original_type": "transaction",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "...",
|
||||
"nonce": "...",
|
||||
"associated_data": ""
|
||||
}
|
||||
```
|
||||
|
||||
- 算法:`AEAD_AES_256_GCM`,密钥:**APIv3 密钥(32 字节)—— 商户、品牌、服务商三方完全相同**
|
||||
- 从 `resource` 取 `nonce`、`ciphertext`、`associated_data` 进行解密
|
||||
- ‼️ 加密报文中的 `nonce` 与请求签名串中的随机串**没有任何关系**,是两个独立的值
|
||||
|
||||
### 回调处理要求
|
||||
|
||||
1. **必须返回 HTTP 2XX**(200 或 204),否则微信支付会重试
|
||||
2. **必须在 5 秒内应答**
|
||||
3. **必须做幂等处理**(按业务唯一标识 + `event_type` 去重)
|
||||
4. **必须验签**,防止伪造通知。验签密钥支持两种,**与接入模式无关**,取决于商户/服务商在平台的密钥配置:
|
||||
- **微信支付公钥**(推荐,2024 年后新增,公钥 ID 形如 `PUB_KEY_ID_xxxxxx`)
|
||||
- **微信支付平台证书**(旧方式,需定期下载更新,仍可继续使用)
|
||||
- 任意一种接入模式(商户 / 品牌 / 服务商)均可自由选择上述任一种验签方式
|
||||
5. 签名探测流量以 `WECHATPAY/SIGNTEST/` 开头,需正确处理
|
||||
6. 即使业务处理异常,也建议返回 200,通过告警系统人工介入
|
||||
|
||||
### 回调收不到的常见排查场景
|
||||
|
||||
#### 一、前置配置缺失
|
||||
|
||||
1. **未设置 APIv3 密钥** — 微信支付不会发送回调通知
|
||||
2. **未配置 `notify_url`** — 接口或商户平台未配置回调通知接收地址,微信支付不会发送回调通知
|
||||
|
||||
#### 二、回调地址配置类问题
|
||||
|
||||
1. **地址格式错误**
|
||||
- `notify_url` 未以 `https://` 或 `http://` 开头
|
||||
- URL 中只有域名,缺少具体路径(如 `http://www.weixin.qq.com`)
|
||||
- URL 携带了参数
|
||||
- 使用了内网地址(`127.0.0.1`、`192.168.x.x`、`localhost`)
|
||||
2. **域名未备案或解析异常**
|
||||
- 域名未完成工信部 ICP 备案(国内服务器必须)
|
||||
- DNS 解析失效(解析记录过期、未配置正确的 A/AAAA 记录)
|
||||
|
||||
#### 三、网络与服务器连通性问题
|
||||
|
||||
1. **防火墙/安全组拦截** — 未对上方「回调 IP 白名单」中的 IP 段开通入站规则
|
||||
2. **WAF/CC 防护误拦** — 安全策略将微信支付回调请求误判为恶意请求
|
||||
3. **网络链路故障** — 丢包或延迟过高(超过 3 秒)导致请求超时
|
||||
4. **CDN/反向代理配置异常** — Nginx、Cloudflare 等未将回调请求正确转发至后端服务
|
||||
|
||||
#### 四、回调处理逻辑问题
|
||||
|
||||
1. **登录态校验** — `notify_url` 的代码逻辑不能做登录态校验
|
||||
2. **未在 5 秒内应答** — 微信支付会认为通知失败并重复发送
|
||||
3. **未做幂等** — 同一通知可能多次发送,必须按业务唯一标识 + `event_type` 去重
|
||||
|
||||
### 各模式回调归属说明
|
||||
|
||||
| 模式 | 归属维度 | 区分多主体的关键字段 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| **商户** | 商户维度 | 无(回调本就属于该商户) | 一个商户一个回调地址 |
|
||||
| **品牌直连** | 品牌维度 | `brand_id` | 一个品牌一个回调地址 |
|
||||
| **服务商** | 服务商维度 | `sub_mchid` / `brand_id` | 所有子商户/品牌共用同一回调地址,**必须按字段路由到正确的子主体**,否则会出现"A 商户的订单被 B 商户业务处理"的串单事故 |
|
||||
|
||||
## 二、错误处理策略
|
||||
|
||||
|
||||
| 错误类型 | 处理策略 |
|
||||
| ---------------- | ---------------------------- |
|
||||
| 500 SYSTEM_ERROR | 使用相同请求号重试(指数退避) |
|
||||
| 400 参数错误 | 修正参数后重试 |
|
||||
| 401 签名错误 | 检查验签密钥(公钥 / 平台证书)是否与平台配置一致;服务商还需检查请求头 `Wechatpay-Serial` 是否携带正确的证书/公钥序列号 |
|
||||
| 回调超时 | 返回 200,异步补偿处理 |
|
||||
| 解密失败 | 检查 APIv3 密钥是否正确(32 字节、与商户/服务商平台配置一致)|
|
||||
|
||||
|
||||
## 三、幂等设计
|
||||
|
||||
- 所有写操作必须使用业务侧生成的唯一请求号(如 `out_trade_no`、`out_request_no`、各业务自定义的请求号)
|
||||
- 相同请求号重复请求不会创建重复资源
|
||||
- 建议格式:`{业务前缀}_{日期}_{序号}`,例如 `pay_20250801_000001`
|
||||
|
||||
## 四、请求域名
|
||||
|
||||
- 主域名: `https://api.mch.weixin.qq.com`
|
||||
- 备域名: `https://api2.mch.weixin.qq.com`
|
||||
@@ -0,0 +1,212 @@
|
||||
# 商户模式开发参数与业务规则
|
||||
|
||||
> 来源:[商户开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012587166.md) + [权限申请](https://pay.weixin.qq.com/doc/v3/merchant/4012587112.md) + 各 API 文档
|
||||
|
||||
本文档覆盖本产品**接入前**需要准备的全部参数与产品特有的字段传参规范。业务全链路流程随各 API 示例代码注释展示,错误处置见 [📄 排障手册.md](../问题排查/排障手册.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一、参数清单
|
||||
|
||||
| 参数 | 类型 | 用途 | 必备性 |
|
||||
| ---- | ---- | ---- | ------ |
|
||||
| `mchid` | string | 商户号,所有 API 必传 | 必备 |
|
||||
| `appid` | string | 公众号 / 小程序 / 移动应用 ID(必须已与 mchid 绑定) | 必备 |
|
||||
| `service_id` | string | 支付分服务 ID,创单 API 必传 | 必备 |
|
||||
| **APIv2 密钥** | string(32) | ‼️ **支付分专属强依赖**:调起支付分小程序的前端 sign 用此密钥 | 必备 |
|
||||
| **APIv3 密钥** | string(32) | 解密回调通知密文(AEAD_AES_256_GCM) | 必备 |
|
||||
| 商户 API 证书 | PEM 文件 | APIv3 接口请求签名(私钥) | 必备 |
|
||||
| 商户 API 证书序列号 | string | Authorization 头 `serial_no` 字段 | 必备 |
|
||||
| 微信支付公钥 + 公钥 ID | PEM + string | APIv3 响应/回调验签(推荐,2024 年后新增) | 二选一 |
|
||||
| 微信支付平台证书 + 证书序列号 | PEM + string | APIv3 响应/回调验签(旧式,需要定期下载更新) | 二选一 |
|
||||
| 通知回调地址 `notify_url` | URL | 接收三类回调,HTTPS + 已备案 + 公网可达 | 必备 |
|
||||
|
||||
> ‼️ APIv3 密钥和 APIv2 密钥**是两个独立的密钥**,必须分别设置。支付分调起小程序需要 APIv2 密钥,仅设置 APIv3 密钥会导致 4185 / "商户签名校验失败"等错误。
|
||||
|
||||
## 二、获取步骤
|
||||
|
||||
### 2.1 mchid(商户号)
|
||||
|
||||
1. 登录 [微信支付商户平台](https://pay.weixin.qq.com/)
|
||||
2. 进入「账户中心 → 商户信息」即可看到 `mchid`(10 位数字,如 `1900007291`),也可在右上角点击商户简称下拉查看
|
||||
3. ⚠️ 右上角直接显示的是「商户简称」(如"深圳腾大"这样的中文昵称),**不是** `mchid`,请勿混用
|
||||
|
||||
### 2.2 appid
|
||||
|
||||
| 应用类型 | 申请入口 |
|
||||
| -------- | -------- |
|
||||
| 公众号 / 小程序 | [微信公众平台](https://mp.weixin.qq.com/) |
|
||||
| 移动应用 | [微信开放平台](https://open.weixin.qq.com/) |
|
||||
|
||||
申请到 appid 后必须在商户平台「产品中心 → AppID 账号管理」与 mchid 绑定,详见 [管理商户号绑定的 AppID 账号](https://pay.weixin.qq.com/doc/v3/merchant/4013287010)。
|
||||
|
||||
> ‼️ **创单 / 完结 / 取消必须使用同一个 appid**,否则触发 `4108`。多 appid 场景需逐个绑定到 service_id。
|
||||
|
||||
### 2.3 service_id
|
||||
|
||||
支付分权限审核通过后,由微信支付行业运营在对接群里向商户提供。商户后续新增 mchid 或 appid 使用支付分时,需联系运营在 service_id 上做增量绑定。
|
||||
|
||||
> 服务 ID 还会附带"风险金额上限"参数,运营会一并告知,是创单 `risk_fund.amount` 的硬上限。
|
||||
|
||||
### 2.4 APIv2 密钥(32 字节)
|
||||
|
||||
登录商户平台 → 账户中心 → API 安全 → 设置 APIv2 密钥。**支付分调起小程序前端签名强依赖此密钥,必须设置**。
|
||||
|
||||
### 2.5 APIv3 密钥(32 字节)
|
||||
|
||||
1. 登录商户平台 → 账户中心 → API 安全 → 设置 APIv3 密钥
|
||||
2. 详细步骤:[APIv3 密钥设置方法](https://pay.weixin.qq.com/doc/v3/merchant/4012072195)
|
||||
|
||||
### 2.6 商户 API 证书
|
||||
|
||||
1. 登录商户平台 → 账户中心 → API 安全 → API 证书 → 申请并下载
|
||||
2. 详细步骤:[商户 API 证书获取方法](https://pay.weixin.qq.com/doc/v3/merchant/4012072428)
|
||||
3. 下载后得到 `apiclient_cert.pem`(公钥证书)+ `apiclient_key.pem`(私钥)+ 证书序列号
|
||||
|
||||
### 2.7 微信支付公钥(推荐)
|
||||
|
||||
1. 登录商户平台 → 账户中心 → API 安全 → 微信支付公钥
|
||||
2. 下载后得到公钥文件(PEM)+ 公钥 ID(形如 `PUB_KEY_ID_xxxxxxxx`)
|
||||
3. 详细步骤:[如何获取微信支付公钥](https://pay.weixin.qq.com/doc/v3/merchant/4013038816.md)
|
||||
|
||||
### 2.8 微信支付平台证书(旧式,可选)
|
||||
|
||||
1. 调用 [获取平台证书 API](https://pay.weixin.qq.com/doc/v3/merchant/4012551764.md) 自动下载
|
||||
2. 平台证书会到期,需要定期更新
|
||||
|
||||
> **新接入推荐使用微信支付公钥**,永久有效不需要轮换。两种方式不能混用,与 APIv3 接入策略保持一致即可。
|
||||
|
||||
### 2.9 notify_url 配置
|
||||
|
||||
支付分有两种 notify_url 配置方式:
|
||||
|
||||
1. **创单接口直接传入 `notify_url` 字段**(推荐)→ 该订单的"确认订单回调"和"支付成功回调"都发往该 URL
|
||||
2. **商户平台预设回调地址**:产品中心 → 开发配置 → 服务通知 → 设置默认地址(创单不传 notify_url 时使用)
|
||||
|
||||
`notify_url` 必须满足:
|
||||
|
||||
- HTTPS(不允许 HTTP)
|
||||
- 域名已 ICP 备案
|
||||
- 公网可达,**不能用 127.0.0.1 / 192.168.x.x / localhost**
|
||||
- 不能携带任何 query 参数
|
||||
|
||||
### 2.10 测试白名单
|
||||
|
||||
服务上线前,**仅商户平台白名单中的微信号用户可使用**。开发联调时必须先到 商户平台 → 产品中心 → 微信支付分 → 测试号配置 添加测试微信号。
|
||||
|
||||
## 三、绑定关系自查清单
|
||||
|
||||
接入前请逐项确认(任一项不满足都会触发 NO_AUTH 报错):
|
||||
|
||||
- [ ] mchid 已开通支付分产品权限(商户平台显示"已开通")
|
||||
- [ ] appid 已与 mchid 完成绑定
|
||||
- [ ] service_id 已下发,且与"申请权限时填写的 mchid + appid 组合"完全一致
|
||||
- [ ] 后续新增的 mchid 或 appid 已联系运营做 service_id 增量绑定
|
||||
- [ ] APIv2 密钥已设置(前端调起强依赖)
|
||||
- [ ] APIv3 密钥已设置
|
||||
- [ ] 商户 API 证书已下载并保存证书序列号
|
||||
- [ ] 微信支付公钥(或平台证书)已下载
|
||||
- [ ] notify_url 已配置且公网可达 + 已备案
|
||||
- [ ] 测试微信号已加入白名单(上线前必备)
|
||||
|
||||
---
|
||||
|
||||
## 四、订单状态流转
|
||||
|
||||
| state | state_description | collection.state | 含义 | 可执行操作 |
|
||||
| ----- | ----------------- | ---------------- | ---- | ---------- |
|
||||
| CREATED | - | - | 已创建,等待用户确认 | 取消、查询;30 天未确认自动失效 |
|
||||
| DOING | USER_CONFIRM | - | 用户已确认,服务进行中 | 完结、取消、查询、修改金额 |
|
||||
| DOING | MCH_COMPLETE | USER_PAYING | 商户已完结,用户待支付 | 修改金额、取消、同步、查询 |
|
||||
| DONE | - | - | 订单完成(终态) | 退款、查询 |
|
||||
| REVOKED | - | - | 商户取消订单(终态) | 查询 |
|
||||
| EXPIRED | - | - | 订单失效(终态,CREATED 30 天未变动) | 查询 |
|
||||
|
||||
> ‼️ 状态判断必须 `state` + `state_description` + `collection.state` **三者结合**,不能只看 `state`。
|
||||
|
||||
## 五、关键字段传参规范
|
||||
|
||||
### 5.1 创单 `risk_fund` 字段(受订单子模式约束)
|
||||
|
||||
| 子模式 | `risk_fund.name` 取值 | `risk_fund.amount` 上限 | 说明 |
|
||||
| ------ | --------------------- | ----------------------- | ---- |
|
||||
| **先免** | `DEPOSIT` / `ADVANCE` / `CASH_DEPOSIT` | ≤ service_id 风险金额上限 | 用户先免风险金享受服务 |
|
||||
| **先享** | `ESTIMATE_ORDER_COST` | ≤ service_id 风险金额上限 | 用户先享受服务后支付 |
|
||||
|
||||
> 子模式由微信支付行业运营在权限审批时与商户共同确定,并落到 `service_id` 上。同一 `service_id` 内的所有订单遵循统一子模式。
|
||||
|
||||
### 5.2 完结金额公式(必校验)
|
||||
|
||||
完结接口 `total_amount` 必须严格满足:
|
||||
|
||||
```
|
||||
total_amount = Σpost_payments.amount - Σpost_discounts.amount
|
||||
```
|
||||
|
||||
不成立返回 `PARAM_ERROR 最终总金额计算非法`。
|
||||
|
||||
**金额硬约束(按子模式)**:
|
||||
|
||||
- 先免:`total_amount ≤ 创单的 risk_fund.amount`
|
||||
- 先享:`total_amount ≤ service_id 风险金额上限`
|
||||
|
||||
### 5.3 状态前提(完结 / 取消 / 修改)
|
||||
|
||||
| 操作 | 允许的状态 | 错误时报错 |
|
||||
| ---- | ---------- | ---------- |
|
||||
| 完结 | `state=DOING` 且 `state_description ∈ {USER_CONFIRM, MCH_COMPLETE}` | `INVALID_REQUEST 当前订单状态不合法` |
|
||||
| 修改金额 | `state=DOING` 且 `state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,仅可下调) | `INVALID_REQUEST 当前订单状态不合法` |
|
||||
| 取消 | `state=CREATED`,或 `state=DOING` 且 `state_description=USER_CONFIRM`(即用户已确认、商户尚未完结) | `271316592 当前订单状态不满足撤销条件` |
|
||||
|
||||
> ‼️ 取消的边界要点:商户一旦调过「完结订单」(订单进入 `DOING + MCH_COMPLETE`、`collection.state=USER_PAYING`,扣款已在进行)就**不能再取消**,应改走「修改金额」或等待支付结果回调;`DONE` / `REVOKED` / `EXPIRED` 终态同样不可取消。
|
||||
|
||||
### 5.4 退款必须用 `transaction_id`
|
||||
|
||||
支付分退款请求体必须使用**微信支付交易单号 `transaction_id`**,**不能用 `out_order_no`**,否则报"订单不存在"。`transaction_id` 来自支付成功回调或查询订单接口的响应。
|
||||
|
||||
### 5.5 `post_payments`(后付费项目)行业字段
|
||||
|
||||
每个行业有差异化字段要求,必须严格遵循否则订单详情页无法正常展示,影响验收上线。**支付分订单详情页是验收上线的关键依据**。
|
||||
|
||||
| 行业 | 文档 |
|
||||
| ---- | ---- |
|
||||
| 二轮电动车充电桩 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587259.md) |
|
||||
| 充电宝 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587281.md) |
|
||||
| 共享单车 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587294.md) |
|
||||
| 快递行业 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587304.md) |
|
||||
| 智慧零售(无人设备) | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587317.md) |
|
||||
| 汽车充电桩 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587347.md) |
|
||||
| 汽车租赁 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587354.md) |
|
||||
| 酒店行业 | [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587370.md) |
|
||||
|
||||
### 5.6 `device.start_device_id`(无人自助设备必传)
|
||||
|
||||
售货机、充电宝、共享单车、共享雨伞、充电桩等**无人自助设备场景**,创单时必须传 `device.start_device_id`,否则订单详情页无法展示设备信息,影响验收。
|
||||
|
||||
### 5.7 `need_user_confirm`
|
||||
|
||||
需确认订单模式必须传 `true` 或不传(默认 true)。免确认是高级权限,需向运营单独申请。无免确认权限时若传 `false` 会报权限错误。
|
||||
|
||||
### 5.8 `notify_url` 字段
|
||||
|
||||
写法已在 §2.9 说明。补充:同一笔订单的"用户确认 / 支付成功 / 退款结果"三类回调都发往该地址,业务侧需按 `event_type` 路由。
|
||||
|
||||
## 六、对账
|
||||
|
||||
- 次日 10:00 后通过商户平台手动下载,或调用 [申请交易账单](https://pay.weixin.qq.com/doc/v3/merchant/4013071227.md) + [下载账单](https://pay.weixin.qq.com/doc/v3/merchant/4013071238.md)
|
||||
- 账单的「交易类型」与「商户订单号」按支付方式区分:
|
||||
- **主动支付**:交易类型为 `JSAPI`,「商户订单号」即商户创单上送的 `out_order_no`
|
||||
- **自动扣款**:交易类型为 `AUTH`,「商户订单号」由**微信侧生成**(不是 `out_order_no`),原 `out_order_no` 在「商户数据包」字段,格式:`wxzff|微信服务单号|商户服务单号`
|
||||
- 对账落库时按「交易类型」分支取键:`JSAPI` 直接用「商户订单号」;`AUTH` 必须解析「商户数据包」拿到原 `out_order_no` 再关联本地业务单
|
||||
|
||||
## 七、上线验收
|
||||
|
||||
商户用测试微信号完成开发测试后,按以下流程提交资料:
|
||||
|
||||
| 步骤 | 资料 |
|
||||
| ---- | ---- |
|
||||
| 1. 提交资料 | ① 第一人称视角录屏(首页 → 介绍 → 确认 → 授权 → 扣款 → 详情 → 通知)<br/>② 第三方视角拍摄(线下场景全貌 + 品牌 + 硬件文字)<br/>③ 进行中 / 已完成 / 已取消 三个状态的订单详情截图 |
|
||||
| 2. 提供上线信息 | 服务 ID、服务名称、场景、商户号、上线计划、放量节奏、应急机制 |
|
||||
| 3. 上线 | 微信支付侧完成服务配置,正式开放给所有用户 |
|
||||
|
||||
UI 必须满足 [支付分合作品牌线上应用规范](https://pay.weixin.qq.com/doc/v3/merchant/4012587220.md)。
|
||||
@@ -0,0 +1,84 @@
|
||||
# 商户模式接入质量检查
|
||||
|
||||
## 角色设定:金融支付系统技术专家
|
||||
|
||||
> ‼️ **本节角色、铁律和问题雷达是质检的全部驱动力,必须内化后再审代码。**
|
||||
|
||||
你是金融支付系统技术专家,全栈工程师出身,亲手写过从前端收银台到后端交易引擎的全链路代码。你主导过千万级用户规模的国民级支付系统架构设计,从零搭建过高并发交易平台。你熟悉主流支付平台的接入规范与安全体系,对 API 签名验签机制、异步回调通知处理、资金流对账有丰富的实战经验。你对代码质量有极强的直觉,尤其对资金链路上的异常处理缺失高度警觉。
|
||||
|
||||
你对支付系统的要求极高:接口交互必须有完善的异常处理和兜底方案,资金操作必须可追溯、可对账,所有外部输入必须经过校验才能进入业务逻辑。
|
||||
|
||||
## 铁律
|
||||
|
||||
**铁律一:高可用(99.9999%)**
|
||||
系统可用性要求 99.9999%(六个 9),即每一百万次请求中最多允许一次失败。支付链路上不允许存在单点故障,每一个外部调用都必须有超时、重试和降级方案。
|
||||
检查直觉:调用微信支付 API 超时了,代码会自动重试还是直接报错?重试的时候会不会导致重复下单?微信的支付回调一直没来,系统有没有定时去主动查询订单状态?用户快速点了两次支付按钮,会不会创建两笔订单?
|
||||
|
||||
**铁律二:资金安全(一分钱都不能错)**
|
||||
金额计算必须使用整数(单位:分),杜绝浮点精度丢失。每一笔资金变动(支付、退款、分账)都必须有据可查,系统必须在次日通过账单对账主动发现差异。
|
||||
检查直觉:金额字段的类型是 int/long 还是 double/float?用户申请退款时,代码有没有累加历史退款金额并校验是否超过订单总额?系统有没有每天自动拉取微信账单和本地订单做比对?
|
||||
|
||||
**铁律三:零信任(不信任任何未经验证的外部数据)**
|
||||
微信的回调通知、前端传入的参数、缓存中的数据,在进入业务逻辑前必须经过验证,未验证的输入一律视为不可信。
|
||||
检查直觉:收到支付回调后,代码是先验签还是直接解析 body 处理业务?下单接口的金额是从后端数据库查的还是直接用前端传过来的值?回调通知中的支付金额有没有和本地订单金额做比对?私钥是通过环境变量加载的还是硬编码在代码里?
|
||||
|
||||
## 检查方法
|
||||
|
||||
1. **扫代码** — 快速扫描代码,按问题雷达定位高风险区域
|
||||
2. **追链路** — 沿资金流完整走一遍:创单 → 拉起确认 → 用户确认 → 提供服务 → 完结订单 → 自动扣款 → 退款,任何断点都是事故点
|
||||
3. **做预演** — 对每个关键节点问"如果这里故障了/超时了/被攻击了/来了两次,会怎样?"
|
||||
|
||||
**输出要求**:发现问题必须给出修复方向,不能只说"有风险";必须基于代码事实,不基于猜测;结果按 🔴🟡🟠 分级,致命问题置顶。
|
||||
|
||||
## 问题雷达
|
||||
|
||||
> **来源**:通用安全雷达(固定 4 项)+ 产品专属雷达(**重点从「开发指引」与「常见问题」提炼**,其他文档作为补充)。
|
||||
>
|
||||
> 以下仅列举常见的高风险问题,**不要只检查列出的项**。检查时应反向运用铁律:逐条铁律审视代码,发现未列出的同类问题。
|
||||
|
||||
### 通用安全雷达(所有产品必查)
|
||||
|
||||
> 4 项**独立判定**,每项必须给出"通过 / 未实现 / 不涉及"三选一的明确结论,**禁止合并多项为一条**。具体检查方法见 [签名与验签规则](./签名与验签规则.md)。
|
||||
|
||||
|
||||
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
|
||||
| --- | -------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------- | ----- |
|
||||
| 1 | **HTTP 响应验签** | 发起请求并处理响应的代码(OkHttp `execute()` / HttpClient `send()` 等) | 收到 2XX 响应后直接解析返回数据,中间无任何验签调用 | 🔴 致命 |
|
||||
| 2 | **回调通知验签** | 处理回调通知的代码(含 `event_type` / `resource_type` / `encrypt-resource` 等字段) | 收到通知后**先解密或解析业务数据**,验签缺失或在解密之后 | 🔴 致命 |
|
||||
| 3 | **幂等去重 + 并发锁** | 回调处理流程的入口 | 既无按"业务唯一标识 + `event_type`"的去重查询,也无加锁逻辑(Redis 锁 / 行锁 / `synchronized` 等) | 🔴 致命 |
|
||||
| 4 | **探测流量未做特殊跳过** | 验签代码分支 | 对签名值含 `WECHATPAY/SIGNTEST/` 前缀的请求做了特殊跳过/早返回 | 🟠 可选 |
|
||||
|
||||
|
||||
### 产品专属雷达(微信支付分)
|
||||
|
||||
> **来源**:[商户开发指引](https://pay.weixin.qq.com/doc/v3/merchant/4012587166.md) + [常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4012587200.md) + [权限类问题排查指南](https://pay.weixin.qq.com/doc/v3/merchant/4018398339.md)
|
||||
|
||||
|
||||
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
|
||||
| --- | -------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------ | ------------- |
|
||||
| 1 | **APIv2 与 APIv3 密钥分离** | 调起支付分小程序的前端 sign 生成代码 | 前端 sign 用 APIv3 密钥生成(应该是 APIv2) | 🔴 致命 |
|
||||
| 2 | **回调验签后再解密** | 用户确认/支付成功/退款 三类回调的处理入口 | 直接解密 ciphertext,未先验签 | 🔴 致命 |
|
||||
| 3 | **回调三类事件分别幂等** | 回调路由代码 | 三类回调没有按 `out_order_no + event_type` 分别去重,可能用同一锁导致并发问题 | 🔴 致命 |
|
||||
| 4 | **CREATED 订单定时查询兜底** | 订单查询代码 | 仅依赖回调,没有对超时未确认的 CREATED 订单做定时查询(30 天后自动 EXPIRED) | 🔴 致命 |
|
||||
| 5 | **完结金额合法性预校验** | 完结订单调用前 | 未在调用前校验 `total_amount = Σpost_payments.amount - Σpost_discounts.amount`,导致 `PARAM_ERROR 最终总金额计算非法` | 🔴 致命 |
|
||||
| 6 | **风险金上限校验** | 创单调用前 | 未对 `risk_fund.amount` 做"≤ service_id 风险金额上限"的硬校验,依赖微信侧返回错误才发现 | 🟡 推荐 |
|
||||
| 7 | **金额类型为整数** | 所有 amount 字段 | amount 用 `double` / `float` / `BigDecimal` 而非 `int` / `long`,单位不是"分" | 🔴 致命 |
|
||||
| 8 | **订单状态判断三字段联合** | 状态判断逻辑 | 仅判断 `state` 不结合 `state_description` 和 `collection.state`,导致 USER_CONFIRM/MCH_COMPLETE/USER_PAYING 区分错误 | 🔴 致命 |
|
||||
| 9 | **完结/取消/修改订单的状态前提** | 调用前的状态判断 | 未检查当前状态就直接调用,导致 `INVALID_REQUEST 当前订单状态不合法` | 🟡 推荐 |
|
||||
| 10 | **退款用 transaction_id 而非 out_order_no** | 退款调用代码 | 用 `out_order_no` 申请退款,报"订单不存在" | 🔴 致命 |
|
||||
| 11 | **退款金额累加校验** | 退款调用前 | 未累加历史退款金额校验是否超过订单总金额 | 🔴 致命 |
|
||||
| 12 | **out_order_no 唯一性与字符集** | 创单参数构造 | `out_order_no` 含非"数字/大小写字母/`_- | `"字符或超过 32 字符 |
|
||||
| 13 | **拉起 appid 与创单 appid 一致** | 拉起代码与创单参数对照 | 多 appid 场景下拉起 appid 与创单 appid 不一致,触发 4108 | 🔴 致命 |
|
||||
| 14 | **post_payments 严格按行业传参** | 完结/创单的 post_payments 字段 | 未按行业 [传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587259.md),导致订单详情页字段不展示,影响验收上线 | 🔴 致命 |
|
||||
| 15 | **device.start_device_id 必传场景** | 创单参数构造 | 售货机/充电宝/充电桩等无人自助设备未传 device.start_device_id | 🟡 推荐 |
|
||||
| 16 | **同步状态接口幂等** | 同步状态调用 | 用户走其他渠道支付后,同步接口未做幂等保护,重复调用导致状态错乱 | 🟡 推荐 |
|
||||
| 17 | **测试白名单兼容** | 调用前的环境分支 | 上线前未将测试微信号加入白名单,导致联调全报"暂无法使用此服务" | 🟠 可选 |
|
||||
| 18 | **APIv3 密钥与 APIv2 密钥不可硬编码** | 配置加载 | 密钥/私钥写死在代码或配置文件,未走环境变量 / KMS | 🔴 致命 |
|
||||
| 19 | **商户 API 证书私钥保护** | 私钥加载与权限 | 私钥文件权限为 644 或更宽,未做最小权限 | 🟡 推荐 |
|
||||
| 20 | **分账(如使用)profit_sharing 时序** | 分账调用代码 | 完结时未传 `profit_sharing=true` 直接尝试分账,或在扣款成功前调用分账 | 🟡 推荐 |
|
||||
| 21 | **JSAPI/小程序回调地址未做 HTTPS / 备案校验** | notify_url 配置 | 使用 HTTP / 内网地址 / 未备案域名 / 携带 query 参数 | 🔴 致命 |
|
||||
| 22 | **修改订单金额只能下调** | 修改金额调用前 | 代码允许传入大于待支付金额的值,触发参数错误 | 🟡 推荐 |
|
||||
| 23 | **回调 5 秒内应答** | 回调处理流程耗时 | 回调处理同步等待业务长耗时操作(DB 慢查询、第三方调用),导致 5 秒超时被微信认为失败重试 | 🔴 致命 |
|
||||
| 24 | **回调金额与本地订单比对** | 支付成功回调处理 | 回调中的支付金额未与本地订单金额比对,可能放过被篡改的伪造请求 | 🔴 致命 |
|
||||
|
||||
|
||||
182
.codex/skills/wechatpay-payscore/references/1-商户/接入指南/签名与验签规则.md
Normal file
182
.codex/skills/wechatpay-payscore/references/1-商户/接入指南/签名与验签规则.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 商户模式签名与验签规则
|
||||
|
||||
> 本文档为微信支付 APIv3 **通用签名与验签规范**,适用于**商户**、**品牌直连**、**服务商**三种接入模式。
|
||||
>
|
||||
> **核心结论**:商户与服务商在签名链路上**完全一致**(签名方案、身份标识、API 路径前缀、SDK 工具类、签名串格式均相同)。两者只在**请求参数命名**上有差异,与签名规则无关:服务商的 `mchid` 是服务商号(不是子商户号),签名用服务商的 API 证书(不是子商户的),请求体多 `sub_mchid` / `sub_appid`,部分接口路径多 `partner/` 段。**真正不同的是品牌直连**——专属签名方案、专属工具类、`/brand/` 路径前缀。
|
||||
|
||||
## 一、三种模式签名方案差异
|
||||
|
||||
| 项目 | 商户 / 服务商 | 品牌直连 |
|
||||
| ---- | ----------- | -------- |
|
||||
| 签名方案 | `WECHATPAY2-SHA256-RSA2048` | `WECHATPAY-BRAND-SHA256-RSA2048` |
|
||||
| 身份标识 | `mchid="xxx"` | `brand_id="xxx"` |
|
||||
| 签名私钥 | 商户 API 证书私钥 | 品牌 API 证书私钥 |
|
||||
| 验签公钥 | 微信支付公钥 / 平台证书 | 微信支付公钥 |
|
||||
| API 路径前缀 | `/v3/` | `/brand/` |
|
||||
| Java / Go 工具类 | `WXPayUtility` / `wxpay_utility` | `WXPayBrandUtility` / `wxpay_brand_utility` |
|
||||
| 配置对象 | `MchConfig`(传 `mchid`) | `BrandConfig`(传 `brand_id`) |
|
||||
|
||||
**最常见错误**:用错工具类(商户工具类调品牌接口或反过来)、签名方案漏写 `BRAND` 后缀、`mchid` 与 `brand_id` 混用 → 全部表现为 401 SIGN_ERROR。
|
||||
|
||||
## 二、请求接口签名串(5 行格式,三方一致)
|
||||
|
||||
```
|
||||
HTTP请求方法\n
|
||||
URL\n
|
||||
请求时间戳\n
|
||||
请求随机串\n
|
||||
请求报文主体\n
|
||||
```
|
||||
|
||||
### 自查要点
|
||||
|
||||
1. 严格 5 行,每行末尾必须有 `\n`(包括最后一行;参数本身以 `\n` 结尾时仍需再附加一个)
|
||||
2. URL 是**去掉域名的绝对路径**(不含 `https://api.mch.weixin.qq.com`)
|
||||
3. 空请求体(如 GET、证书下载)该行为空字符串,仍需附加 `\n`
|
||||
4. 签名时的请求体与实际发送的请求体**必须字节级一致**(含字段顺序、空格、换行)
|
||||
5. 时间戳为系统当前 UNIX 秒数,须保持系统时间准确(太旧的请求会被拒绝)
|
||||
|
||||
### 四种参数类型构造规则
|
||||
|
||||
| 类型 | 关键规则 | URL 示例 | 第 5 行(请求体) |
|
||||
| ---- | -------- | -------- | ---------------- |
|
||||
| **Path 参数** | URL 中 `{xxx}` 必须替换为实际值 | `/v3/refund/domestic/refunds/123123123123` | 空(仅 `\n`) |
|
||||
| **Body 参数** | 第 5 行 = 完整请求体 JSON,与实际发送的字节级一致 | `/v3/pay/transactions/jsapi` | `{"appid":"wxd6...","mchid":"19000...","amount":{"total":100,"currency":"CNY"},...}` |
|
||||
| **Query 参数** | URL 必须含完整 `?key=val&...`;含特殊字符的值(JSON、中文)**必须 URL Encode** | `/v3/marketing/partnerships?limit=5&offset=10&authorized_data=%7B...%7D` | 空(仅 `\n`) |
|
||||
| **图片上传** | 第 5 行 = `meta` 字段 JSON(含 `filename`、`sha256`),**不是整个 multipart body**;HTTP 头需 `Content-Type: multipart/form-data` | `/v3/marketing/favor/media/image-upload` | `{"filename":"x.png","sha256":"d2973a45..."}` |
|
||||
|
||||
完整的 Body 签名串示例(JSAPI 下单):
|
||||
|
||||
```
|
||||
POST\n
|
||||
/v3/pay/transactions/jsapi\n
|
||||
1554208460\n
|
||||
593BEC0C930BF1AFEB40B4A08C8FB242\n
|
||||
{"appid":"wxd678efh567hg6787","mchid":"1900007291","description":"Image形象店-深圳腾大-QQ公仔","out_trade_no":"1217752501201407033233368018","notify_url":"https://www.weixin.qq.com/wxpay/pay.php","amount":{"total":100,"currency":"CNY"},"payer":{"openid":"oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"}}\n
|
||||
```
|
||||
|
||||
**四类参数高频踩雷**:
|
||||
|
||||
- Path:保留了 `{xxx}` 占位符未替换;URL 末尾多/少 `/`
|
||||
- Body:签名时压缩成一行但实际发送是格式化的(或反之);字段顺序不一致
|
||||
- Query:只签了路径漏掉 query 串;JSON 类参数值未做 URL Encode;参数顺序不一致
|
||||
- 图片上传:用整个 multipart body 参与签名;`sha256` 算成了 meta JSON 的摘要而不是文件内容
|
||||
|
||||
## 三、响应验签 与 回调验签
|
||||
|
||||
两者**验签算法完全一致**,唯一差异是签名头来源:响应验签从 HTTP 响应头取,回调验签从回调请求头取。
|
||||
|
||||
**验签步骤**:
|
||||
|
||||
1. 获取 4 个签名头:`Wechatpay-Timestamp`、`Wechatpay-Nonce`、`Wechatpay-Signature`、`Wechatpay-Serial`
|
||||
2. 构造验签串(**3 行**,每行以 `\n` 结尾):
|
||||
|
||||
```
|
||||
应答时间戳\n
|
||||
应答随机串\n
|
||||
应答报文主体\n
|
||||
```
|
||||
|
||||
3. 用对应公钥对验签串和签名值做 SHA256 with RSA 验证
|
||||
4. 校验 `Wechatpay-Serial` 与本地持有的微信支付公钥 ID / 平台证书序列号是否匹配
|
||||
|
||||
### 静态扫描检查方式
|
||||
|
||||
| 场景 | 第一步 定位 | 第二步 检查 | 判定为"未实现"的特征 |
|
||||
| ---- | ----------- | ----------- | ------------------ |
|
||||
| **响应验签** | 找发 HTTP 请求并处理响应的代码(OkHttp `execute()` / HttpClient `send()` 等) | 处理 2XX 响应分支中是否调用验签方法(`validateResponse` / `verify` 或手写步骤) | 收到 2XX 响应后直接解析返回数据,中间无任何验签逻辑 |
|
||||
| **回调验签** | 找处理回调通知的代码(含 `event_type` / `resource_type` / `encrypt-resource` 等字段) | **解密业务数据之前**是否调用验签方法(`validateNotification` / `verify`) | 收到通知后直接解密/解析业务数据,中间无任何验签逻辑 |
|
||||
|
||||
⚠️ 若代码使用 `parseNotification` 等封装方法,需检查该方法**内部**是否包含验签步骤,而非只做解密。
|
||||
|
||||
## 四、幂等与并发控制
|
||||
|
||||
同一通知/请求可能多次到达,业务**必须能正确处理重复**。
|
||||
|
||||
**推荐做法**:① 收到后先按业务唯一标识 + `event_type` 查询是否已处理 → ② 未处理则进入业务流程(**前置加锁**)→ ③ 已处理则直接返回成功。
|
||||
|
||||
**并发锁选型**:Redis 分布式锁(`setnx` / `RedisLock`)、数据库行锁(`SELECT ... FOR UPDATE`)、`synchronized` / `ReentrantLock` 等;锁粒度建议「业务唯一标识 + `event_type`」。
|
||||
|
||||
**静态扫描判定**:回调处理代码中**既无去重查询逻辑也无加锁逻辑** → 判定未实现。
|
||||
|
||||
## 五、探测流量处理
|
||||
|
||||
微信支付会定期发送探测流量验证商户系统的连通性和验签逻辑:
|
||||
|
||||
- 探测请求的签名值以 `WECHATPAY/SIGNTEST/` 为前缀
|
||||
- 商户系统**不应做特殊跳过**,应当作正常通知/响应进行验签处理
|
||||
- 排查时可通过该前缀快速识别探测流量
|
||||
- 若对探测流量返回错误,微信支付可能判定回调地址不可用
|
||||
|
||||
## 六、Authorization 头格式
|
||||
|
||||
```
|
||||
# 商户 / 服务商
|
||||
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900007291",nonce_str="<随机串>",signature="<签名值>",timestamp="<时间戳>",serial_no="<商户API证书序列号>"
|
||||
|
||||
# 品牌直连
|
||||
Authorization: WECHATPAY-BRAND-SHA256-RSA2048 brand_id="100XX",nonce_str="<随机串>",signature="<签名值>",timestamp="<时间戳>",serial_no="<品牌API证书序列号>"
|
||||
```
|
||||
|
||||
### 自查要点
|
||||
|
||||
| 检查项 | 正确做法 |
|
||||
| ------ | -------- |
|
||||
| 认证类型与身份标识 | 见上方两个示例 |
|
||||
| `nonce_str` / `timestamp` | 必须与签名串中的值**字节级一致** |
|
||||
| `serial_no` | 签名所用私钥对应的**商户/品牌 API 证书**序列号;⚠️ **不是**微信支付平台证书序列号 |
|
||||
| `Wechatpay-Serial`(独立请求头) | 验签用,传微信支付公钥 ID(`PUB_KEY_ID_xxxxxx`)或平台证书序列号 |
|
||||
| 整行不换行 | Authorization 值必须在一行 |
|
||||
| 五项顺序 | 无顺序要求 |
|
||||
|
||||
## 七、调起支付签名(仅支付类业务)
|
||||
|
||||
> ⚠️ 仅当业务涉及"调起客户端支付"(APP / JSAPI / 小程序 / H5)时适用,营销类业务(商品券、立减金、积分等)通常不涉及。
|
||||
|
||||
调起支付签名**与请求接口签名不同**:固定 **4 行**(不是 5 行),每行 `\n` 结尾(含最后一行)。私钥仍是商户 API 证书私钥(与下单接口同一对,不可分开)。
|
||||
|
||||
| 客户端 | 签名串第 1 行 | 第 4 行(关键差异) | 客户端字段 |
|
||||
| ------ | ------------- | ------------------ | -------- |
|
||||
| **APP** | 移动应用 AppID(开放平台获取) | **纯 prepay_id**(不带前缀) | `appId` / `partnerId` / `prepayId` / `packageValue=Sign=WXPay` / `nonceStr` / `timeStamp` / `sign` |
|
||||
| **JSAPI** | 公众号 AppID | **`prepay_id=<value>`**(必须带前缀) | `appId` / `timeStamp` / `nonceStr` / `package=prepay_id=xxx` / `signType=RSA` / `paySign` |
|
||||
| **小程序** | 小程序 AppID | **`prepay_id=<value>`**(必须带前缀,与 JSAPI 完全相同的格式) | `timeStamp` / `nonceStr` / `package=prepay_id=xxx` / `signType=RSA` / `paySign` |
|
||||
|
||||
签名串通用格式:
|
||||
|
||||
```
|
||||
appId(或小程序 appID)\n
|
||||
时间戳\n
|
||||
随机字符串\n
|
||||
prepay_id 或 prepay_id=<value>\n
|
||||
```
|
||||
|
||||
JSAPI / 小程序 示例:
|
||||
|
||||
```
|
||||
wx2421b1c4370ec43b\n
|
||||
1554208460\n
|
||||
593BEC0C930BF1AFEB40B4A08C8FB242\n
|
||||
prepay_id=wx201410272009395522657a690389285100\n
|
||||
```
|
||||
|
||||
**调起支付高频踩雷**:
|
||||
|
||||
1. 用了 5 行格式(与请求签名混淆)
|
||||
2. JSAPI/小程序漏掉 `prepay_id=` 前缀,或 APP 加了前缀
|
||||
3. `signType` 误参与签名(它仅客户端调起时传,固定 `RSA`,不入签名串)
|
||||
4. 调起支付与下单使用了**不同**的商户 API 证书(必须同源)
|
||||
|
||||
## 八、401 SIGN_ERROR 自查清单
|
||||
|
||||
无需提供私钥文件,按顺序逐项确认:
|
||||
|
||||
1. **签名方案**:商户/服务商 = `WECHATPAY2-SHA256-RSA2048`;品牌 = `WECHATPAY-BRAND-SHA256-RSA2048`
|
||||
2. **身份标识**:商户/服务商 = `mchid`;品牌 = `brand_id`
|
||||
3. **`serial_no`**:是商户/品牌 API 证书序列号,**不是**平台证书序列号
|
||||
4. **签名串行数**:请求接口 = 5 行;调起支付 = 4 行;响应/回调验签 = 3 行
|
||||
5. **URL**:去域名、Path 占位符已替换、Query 串完整且特殊字符已 URL Encode
|
||||
6. **请求体一致性**:签名时与发送时字节级一致(顺序、空格、换行)
|
||||
7. **`nonce_str` / `timestamp`**:签名串与 Authorization 头中的值相同;时间戳未过期、系统时间准确
|
||||
8. **图片上传**(如适用):第 5 行用 meta 的 JSON 而非整个 multipart body
|
||||
9. **调起支付**(如适用):4 行格式;JSAPI/小程序带 `prepay_id=` 前缀,APP 不带
|
||||
10. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CancelServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("2304203423948239423"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
Reason: wxpay_utility.String("用户投诉"),
|
||||
}
|
||||
|
||||
response, err := CancelServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func CancelServiceOrder(config *wxpay_utility.MchConfig, request *CancelServiceOrderRequest) (response *CancelServiceOrderResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/serviceorder/{out_order_no}/cancel"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &CancelServiceOrderResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CancelServiceOrderRequest struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (o *CancelServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias CancelServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type CancelServiceOrderResponse struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CompleteServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
PostPayments: []Payment{Payment{
|
||||
Name: wxpay_utility.String("就餐费用"),
|
||||
Amount: wxpay_utility.Int64(40000),
|
||||
Description: wxpay_utility.String("就餐人均100元"),
|
||||
Count: wxpay_utility.Int64(4),
|
||||
}},
|
||||
PostDiscounts: []ServiceOrderCoupon{ServiceOrderCoupon{
|
||||
Name: wxpay_utility.String("满20减1元"),
|
||||
Description: wxpay_utility.String("不与其他优惠叠加"),
|
||||
Amount: wxpay_utility.Int64(100),
|
||||
Count: wxpay_utility.Int64(2),
|
||||
}},
|
||||
TotalAmount: wxpay_utility.Int64(50000),
|
||||
TimeRange: &TimeRange{
|
||||
StartTime: wxpay_utility.String("20091225091010"),
|
||||
EndTime: wxpay_utility.String("20091225121010"),
|
||||
StartTimeRemark: wxpay_utility.String("备注1"),
|
||||
EndTimeRemark: wxpay_utility.String("备注2"),
|
||||
},
|
||||
Location: &Location{
|
||||
StartLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
EndLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
},
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
GoodsTag: wxpay_utility.String("goods_tag"),
|
||||
Device: &Device{
|
||||
StartDeviceId: wxpay_utility.String("HG123456"),
|
||||
EndDeviceId: wxpay_utility.String("HG123456"),
|
||||
MaterielNo: wxpay_utility.String("example_materiel_no"),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := CompleteServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func CompleteServiceOrder(config *wxpay_utility.MchConfig, request *CompleteServiceOrderRequest) (response *CompleteServiceOrderResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/serviceorder/{out_order_no}/complete"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &CompleteServiceOrderResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CompleteServiceOrderRequest struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
func (o *CompleteServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias CompleteServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type CompleteServiceOrderResponse struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
NeedCollection *bool `json:"need_collection,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
StartDeviceId *string `json:"start_device_id,omitempty"`
|
||||
EndDeviceId *string `json:"end_device_id,omitempty"`
|
||||
MaterielNo *string `json:"materiel_no,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
ServiceIntroduction: wxpay_utility.String("某某酒店"),
|
||||
PostPayments: []Payment{Payment{
|
||||
Name: wxpay_utility.String("就餐费用"),
|
||||
Amount: wxpay_utility.Int64(40000),
|
||||
Description: wxpay_utility.String("就餐人均100元"),
|
||||
Count: wxpay_utility.Int64(4),
|
||||
}},
|
||||
PostDiscounts: []ServiceOrderCoupon{ServiceOrderCoupon{
|
||||
Name: wxpay_utility.String("满20减1元"),
|
||||
Description: wxpay_utility.String("不与其他优惠叠加"),
|
||||
Amount: wxpay_utility.Int64(100),
|
||||
Count: wxpay_utility.Int64(2),
|
||||
}},
|
||||
TimeRange: &TimeRange{
|
||||
StartTime: wxpay_utility.String("20091225091010"),
|
||||
EndTime: wxpay_utility.String("20091225121010"),
|
||||
StartTimeRemark: wxpay_utility.String("备注1"),
|
||||
EndTimeRemark: wxpay_utility.String("备注2"),
|
||||
},
|
||||
Location: &Location{
|
||||
StartLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
EndLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
},
|
||||
RiskFund: &RiskFund{
|
||||
Name: wxpay_utility.String("DEPOSIT"),
|
||||
Amount: wxpay_utility.Int64(10000),
|
||||
Description: wxpay_utility.String("就餐的预估费用"),
|
||||
},
|
||||
Attach: wxpay_utility.String("Easdfowealsdkjfnlaksjdlfkwqoi&wl3l2sald"),
|
||||
NotifyUrl: wxpay_utility.String("https://api.test.com"),
|
||||
NeedUserConfirm: wxpay_utility.Bool(false),
|
||||
Device: &Device{
|
||||
StartDeviceId: wxpay_utility.String("HG123456"),
|
||||
EndDeviceId: wxpay_utility.String("HG123456"),
|
||||
MaterielNo: wxpay_utility.String("example_materiel_no"),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := CreateServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func CreateServiceOrder(config *wxpay_utility.MchConfig, request *CreateServiceOrderRequest) (response *CreateServiceOrderResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/serviceorder"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &CreateServiceOrderResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateServiceOrderRequest struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
NeedUserConfirm *bool `json:"need_user_confirm,omitempty"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type CreateServiceOrderResponse struct {
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
Package *string `json:"package,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
StartDeviceId *string `json:"start_device_id,omitempty"`
|
||||
EndDeviceId *string `json:"end_device_id,omitempty"`
|
||||
MaterielNo *string `json:"materiel_no,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &ModifyServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
PostPayments: []Payment{Payment{
|
||||
Name: wxpay_utility.String("就餐费用"),
|
||||
Amount: wxpay_utility.Int64(40000),
|
||||
Description: wxpay_utility.String("就餐人均100元"),
|
||||
Count: wxpay_utility.Int64(4),
|
||||
}},
|
||||
PostDiscounts: []ServiceOrderCoupon{ServiceOrderCoupon{
|
||||
Name: wxpay_utility.String("满20减1元"),
|
||||
Description: wxpay_utility.String("不与其他优惠叠加"),
|
||||
Amount: wxpay_utility.Int64(100),
|
||||
Count: wxpay_utility.Int64(2),
|
||||
}},
|
||||
TotalAmount: wxpay_utility.Int64(50000),
|
||||
Reason: wxpay_utility.String("用户投诉"),
|
||||
Device: &Device{
|
||||
StartDeviceId: wxpay_utility.String("HG123456"),
|
||||
EndDeviceId: wxpay_utility.String("HG123456"),
|
||||
MaterielNo: wxpay_utility.String("example_materiel_no"),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := ModifyServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func ModifyServiceOrder(config *wxpay_utility.MchConfig, request *ModifyServiceOrderRequest) (response *ServiceOrderEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/serviceorder/{out_order_no}/modify"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &ServiceOrderEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type ModifyServiceOrderRequest struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
func (o *ModifyServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias ModifyServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type ServiceOrderEntity struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
PostPayments *Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
NeedCollection *bool `json:"need_collection,omitempty"`
|
||||
Collection *Collection `json:"collection,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
StartDeviceId *string `json:"start_device_id,omitempty"`
|
||||
EndDeviceId *string `json:"end_device_id,omitempty"`
|
||||
MaterielNo *string `json:"materiel_no,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
State *string `json:"state,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
PayingAmount *int64 `json:"paying_amount,omitempty"`
|
||||
PaidAmount *int64 `json:"paid_amount,omitempty"`
|
||||
Details []Detail `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Seq *int64 `json:"seq,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
PaidType *string `json:"paid_type,omitempty"`
|
||||
PaidTime *string `json:"paid_time,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &GetServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
QueryId: wxpay_utility.String("15646546545165651651"),
|
||||
}
|
||||
|
||||
response, err := GetServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func GetServiceOrder(config *wxpay_utility.MchConfig, request *GetServiceOrderRequest) (response *ServiceOrderEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/payscore/serviceorder"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
if request.OutOrderNo != nil {
|
||||
query.Add("out_order_no", *request.OutOrderNo)
|
||||
}
|
||||
if request.ServiceId != nil {
|
||||
query.Add("service_id", *request.ServiceId)
|
||||
}
|
||||
if request.Appid != nil {
|
||||
query.Add("appid", *request.Appid)
|
||||
}
|
||||
if request.QueryId != nil {
|
||||
query.Add("query_id", *request.QueryId)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &ServiceOrderEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type GetServiceOrderRequest struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
QueryId *string `json:"query_id,omitempty"`
|
||||
}
|
||||
|
||||
func (o *GetServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias GetServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
QueryId *string `json:"query_id,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
OutOrderNo: nil,
|
||||
ServiceId: nil,
|
||||
Appid: nil,
|
||||
QueryId: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type ServiceOrderEntity struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
PostPayments *Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
NeedCollection *bool `json:"need_collection,omitempty"`
|
||||
Collection *Collection `json:"collection,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
State *string `json:"state,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
PayingAmount *int64 `json:"paying_amount,omitempty"`
|
||||
PaidAmount *int64 `json:"paid_amount,omitempty"`
|
||||
Details []Detail `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Seq *int64 `json:"seq,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
PaidType *string `json:"paid_type,omitempty"`
|
||||
PaidTime *string `json:"paid_time,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &SyncServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
Type: wxpay_utility.String("Order_Paid"),
|
||||
Detail: &SyncDetail{
|
||||
PaidTime: wxpay_utility.String("20091225091210"),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := SyncServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func SyncServiceOrder(config *wxpay_utility.MchConfig, request *SyncServiceOrderRequest) (response *ServiceOrderEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/serviceorder/{out_order_no}/sync"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &ServiceOrderEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type SyncServiceOrderRequest struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Detail *SyncDetail `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func (o *SyncServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias SyncServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type ServiceOrderEntity struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
PostPayments *Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
NeedCollection *bool `json:"need_collection,omitempty"`
|
||||
Collection *Collection `json:"collection,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
type SyncDetail struct {
|
||||
PaidTime *string `json:"paid_time,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
State *string `json:"state,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
PayingAmount *int64 `json:"paying_amount,omitempty"`
|
||||
PaidAmount *int64 `json:"paid_amount,omitempty"`
|
||||
Details []Detail `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Seq *int64 `json:"seq,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
PaidType *string `json:"paid_type,omitempty"`
|
||||
PaidTime *string `json:"paid_time,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateRequest{
|
||||
TransactionId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
Reason: wxpay_utility.String("商品已售完"),
|
||||
NotifyUrl: wxpay_utility.String("https://weixin.qq.com"),
|
||||
FundsAccount: REQFUNDSACCOUNT_AVAILABLE.Ptr(),
|
||||
Amount: &AmountReq{
|
||||
Refund: wxpay_utility.Int64(888),
|
||||
From: []FundsFromItem{FundsFromItem{
|
||||
Account: ACCOUNT_AVAILABLE.Ptr(),
|
||||
Amount: wxpay_utility.Int64(444),
|
||||
}},
|
||||
Total: wxpay_utility.Int64(888),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhone6s 16G"),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
RefundAmount: wxpay_utility.Int64(528800),
|
||||
RefundQuantity: wxpay_utility.Int64(1),
|
||||
}},
|
||||
}
|
||||
|
||||
response, err := Create(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func Create(config *wxpay_utility.MchConfig, request *CreateRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/refund/domestic/refunds"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
FundsAccount *ReqFundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *AmountReq `json:"amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type ReqFundsAccount string
|
||||
|
||||
func (e ReqFundsAccount) Ptr() *ReqFundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
REQFUNDSACCOUNT_AVAILABLE ReqFundsAccount = "AVAILABLE"
|
||||
REQFUNDSACCOUNT_UNSETTLED ReqFundsAccount = "UNSETTLED"
|
||||
)
|
||||
|
||||
type AmountReq struct {
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_CASH PromotionType = "CASH"
|
||||
PROMOTIONTYPE_NOCASH PromotionType = "NOCASH"
|
||||
)
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/merchant/4015119334
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryByOutRefundNoRequest{
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018")
|
||||
}
|
||||
|
||||
response, err := QueryByOutRefundNo(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryByOutRefundNo(config *wxpay_utility.MchConfig, request *QueryByOutRefundNoRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/refund/domestic/refunds/{out_refund_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_refund_no}", url.PathEscape(*request.OutRefundNo), -1)
|
||||
query := reqUrl.Query()
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryByOutRefundNoRequest struct {
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryByOutRefundNoRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryByOutRefundNoRequest
|
||||
a := &struct {
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutRefundNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_CASH PromotionType = "CASH"
|
||||
PROMOTIONTYPE_NOCASH PromotionType = "NOCASH"
|
||||
)
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
# 退款结果回调通知(商户 - Go)
|
||||
|
||||
> 内容与 [`Java/5-回调通知/退款结果回调通知说明.md`](../../Java/5-回调通知/退款结果回调通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
|
||||
|
||||
> 源文档:[退款结果通知](https://pay.weixin.qq.com/doc/v3/merchant/4012587976.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
商户调用「申请退款」接口(`/v3/payscore/refunds`)受理后,微信支付分异步处理实际退款,退款进入终态时(`SUCCESS` / `CLOSED` / `ABNORMAL`)回推本通知。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "REFUND.SUCCESS",
|
||||
"summary": "退款成功",
|
||||
"resource": {
|
||||
"original_type": "refund",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到退款详情>",
|
||||
"associated_data": "refund",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## event_type 一览
|
||||
|
||||
| event_type | 含义 | 处理建议 |
|
||||
|------------|------|---------|
|
||||
| `REFUND.SUCCESS` | 退款成功 | 更新业务退款单为成功,触发对账 / 通知用户 |
|
||||
| `REFUND.CLOSED` | 退款被关闭 | 一般为商户重复发起 / 资金不足等,需按 `refund_status` + `error_msg` 分支处理 |
|
||||
| `REFUND.ABNORMAL` | 退款异常 | 按异常原因人工介入,可发起「异常退款」补救 |
|
||||
|
||||
## 解密后关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `out_refund_no` | 商户退款单号,幂等键 |
|
||||
| `refund_id` | 微信侧退款单号,唯一 |
|
||||
| `out_order_no` | 关联的支付分订单号 |
|
||||
| `transaction_id` | 关联的支付单号(若该退款关联具体扣款流水) |
|
||||
| `amount.refund` | 实际退款金额(分) |
|
||||
| `refund_status` | `SUCCESS` / `CLOSED` / `PROCESSING` / `ABNORMAL` |
|
||||
| `success_time` | 退款成功时间 |
|
||||
|
||||
## 商户处理要求
|
||||
|
||||
1. **验签 + 解密** 与 `回调处理.md` 一致。
|
||||
2. **幂等**:以 `out_refund_no` 入库去重;同一单多次回调取最新终态。
|
||||
3. **状态机**:仅信任 `refund_status` 字段,不要以 HTTP 200 作为退款成功判据。
|
||||
4. **多退一致性**:如需多次部分退款,需累加 `amount.refund`,确保不超过 `total_amount`。
|
||||
5. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 确认订单回调通知(商户 - Go)
|
||||
|
||||
> 内容与 [`Java/5-回调通知/确认订单回调通知说明.md`](../../Java/5-回调通知/确认订单回调通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
|
||||
|
||||
> 源文档:[确认订单回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4012587953.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
> 通用签名规则:[../接入指南/签名与验签规则.md](../../../接入指南/签名与验签规则.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
用户在「确认订单」页面(小程序 / APP / H5)点击同意后,微信支付分会以 **POST** 方式向商户在创建订单时填写的 `notify_url` 推送本通知,标识订单已变为 `DOING` 状态、可正式发起服务。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_CONFIRM",
|
||||
"summary": "支付分订单用户已确认",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到 ServiceOrderEntity>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 固定为 `PAYSCORE.USER_CONFIRM`;用于回调路由判断(与 `PAYSCORE.USER_PAID` / `REFUND.SUCCESS` 区分) |
|
||||
| `resource.algorithm` | 固定 `AEAD_AES_256_GCM` |
|
||||
| `resource.ciphertext` | 解密后为商户视角的支付分订单实体,包含 `out_order_no`、`service_id`、`appid`、`mchid`、`state`、`openid`、`order_id`、`risk_fund`、`time_range`、`location` 等字段 |
|
||||
| 解密所得 `state` | `DOING`(用户已确认,可开始提供服务) |
|
||||
|
||||
## 商户处理要求
|
||||
|
||||
1. **验签**:使用 `Wechatpay-Signature` / `Wechatpay-Timestamp` / `Wechatpay-Nonce` / `Wechatpay-Serial` 头,配合微信支付公钥校验请求体;任一不匹配立即返回 `401`。
|
||||
2. **解密**:用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密 `resource.ciphertext`。
|
||||
3. **路由**:以 `event_type` 区分确认 / 支付 / 退款回调;以 `out_order_no` 入库去重(`UNIQUE INDEX(out_order_no, event_type)`)。
|
||||
4. **入库**:将订单状态更新为 `DOING`,登记 `openid`、`order_id`,触发后续业务(如开门、放行、发货)。
|
||||
5. **应答**:处理成功返回 `200 + {"code":"SUCCESS","message":"成功"}`;业务异常返回 `500 + {"code":"FAIL","message":"<原因>"}` 触发重试。
|
||||
6. **幂等**:同一 `out_order_no + event_type` 多次到达必须只生效一次。
|
||||
|
||||
## 测试要点
|
||||
|
||||
- 主动调用「查询支付分订单」接口与回调入库结果做交叉校验,避免遗漏。
|
||||
- 模拟回调延迟 / 重试场景(处理超时不影响业务)。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 支付成功回调通知(商户 - Go)
|
||||
|
||||
> 内容与 [`Java/5-回调通知/支付成功回调通知说明.md`](../../Java/5-回调通知/支付成功回调通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
|
||||
|
||||
> 源文档:[支付成功回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4012587960.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
完结订单(`/v3/payscore/serviceorder/{out_order_no}/complete`)后,若订单 `need_collection = true`(需收款),微信支付分将异步发起代扣并在扣款 / 收款进展变化时回推本通知。商户需以本通知为准更新「收款(collection)」状态。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_PAID",
|
||||
"summary": "用户支付成功",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到 ServiceOrderEntity>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 固定为 `PAYSCORE.USER_PAID` |
|
||||
| 解密后 `state` | `DONE` |
|
||||
| 解密后 `collection.state` | `USER_PAYING` / `USER_ACCEPT` / `MCH_LOADING`,最终态多为 `USER_PAID` |
|
||||
| 解密后 `collection.details[]` | 每一笔扣款明细:`amount`、`paid_type`、`paid_time`、`transaction_id` |
|
||||
|
||||
## 商户处理要求
|
||||
|
||||
1. **验签 + 解密**:流程同确认订单回调。
|
||||
2. **路由 / 幂等**:以 `event_type = PAYSCORE.USER_PAID` 区分,按 `out_order_no` 幂等。
|
||||
3. **状态机**:
|
||||
- `collection.state` 在终态前可能多次回推(多笔代扣 / 退回 / 重试),商户需累加 `paid_amount`、写入流水表,避免覆盖。
|
||||
- 终态 `state = DONE` 且 `collection.paid_amount` 与 `total_amount` 一致时方可计完结。
|
||||
4. **资金对账**:以 `transaction_id` 为唯一支付单号入账;如需分账,按业务发起 `/v3/profitsharing/orders`。
|
||||
5. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`,否则返回 5xx + 错误描述触发重试。
|
||||
@@ -0,0 +1,71 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Host = "https://api.mch.weixin.qq.com"
|
||||
|
||||
// SendGet 发送 GET 请求并返回已验签的应答 Body
|
||||
func SendGet(config *MchConfig, uri string) ([]byte, error) {
|
||||
return sendRequest(config, "GET", uri, nil)
|
||||
}
|
||||
|
||||
// SendPost 发送 POST 请求并返回已验签的应答 Body
|
||||
func SendPost(config *MchConfig, uri string, reqBody []byte) ([]byte, error) {
|
||||
return sendRequest(config, "POST", uri, reqBody)
|
||||
}
|
||||
|
||||
func sendRequest(config *MchConfig, method string, uri string, reqBody []byte) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if reqBody != nil {
|
||||
bodyReader = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest(method, Host+uri, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
|
||||
authorization, err := BuildAuthorization(config.MchId(), config.CertificateSerialNo(),
|
||||
config.PrivateKey(), method, uri, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
if reqBody != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody, err := ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tjfoc/gmsm/sm3"
|
||||
)
|
||||
|
||||
type MchConfig struct {
|
||||
mchId string
|
||||
certificateSerialNo string
|
||||
privateKeyFilePath string
|
||||
wechatPayPublicKeyId string
|
||||
wechatPayPublicKeyFilePath string
|
||||
privateKey *rsa.PrivateKey
|
||||
wechatPayPublicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) MchId() string {
|
||||
return c.mchId
|
||||
}
|
||||
|
||||
func (c *MchConfig) CertificateSerialNo() string {
|
||||
return c.certificateSerialNo
|
||||
}
|
||||
|
||||
func (c *MchConfig) PrivateKey() *rsa.PrivateKey {
|
||||
return c.privateKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKeyId() string {
|
||||
return c.wechatPayPublicKeyId
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKey() *rsa.PublicKey {
|
||||
return c.wechatPayPublicKey
|
||||
}
|
||||
|
||||
func CreateMchConfig(
|
||||
mchId string,
|
||||
certificateSerialNo string,
|
||||
privateKeyFilePath string,
|
||||
wechatPayPublicKeyId string,
|
||||
wechatPayPublicKeyFilePath string,
|
||||
) (*MchConfig, error) {
|
||||
mchConfig := &MchConfig{
|
||||
mchId: mchId,
|
||||
certificateSerialNo: certificateSerialNo,
|
||||
privateKeyFilePath: privateKeyFilePath,
|
||||
wechatPayPublicKeyId: wechatPayPublicKeyId,
|
||||
wechatPayPublicKeyFilePath: wechatPayPublicKeyFilePath,
|
||||
}
|
||||
privateKey, err := LoadPrivateKeyWithPath(mchConfig.privateKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.privateKey = privateKey
|
||||
wechatPayPublicKey, err := LoadPublicKeyWithPath(mchConfig.wechatPayPublicKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.wechatPayPublicKey = wechatPayPublicKey
|
||||
return mchConfig, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKey(privateKeyStr string) (privateKey *rsa.PrivateKey, err error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode private key err")
|
||||
}
|
||||
if block.Type != "PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PRVATE KEY")
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key err:%s", err.Error())
|
||||
}
|
||||
privateKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not a RSA private key")
|
||||
}
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func LoadPublicKey(publicKeyStr string) (publicKey *rsa.PublicKey, err error) {
|
||||
block, _ := pem.Decode([]byte(publicKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("decode public key error")
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PUBLIC KEY")
|
||||
}
|
||||
key, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse public key err:%s", err.Error())
|
||||
}
|
||||
publicKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not rsa public key", publicKeyStr)
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKeyWithPath(path string) (privateKey *rsa.PrivateKey, err error) {
|
||||
privateKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPrivateKey(string(privateKeyBytes))
|
||||
}
|
||||
|
||||
func LoadPublicKeyWithPath(path string) (publicKey *rsa.PublicKey, err error) {
|
||||
publicKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPublicKey(string(publicKeyBytes))
|
||||
}
|
||||
|
||||
func EncryptOAEPWithPublicKey(message string, publicKey *rsa.PublicKey) (ciphertext string, err error) {
|
||||
if publicKey == nil {
|
||||
return "", fmt.Errorf("you should input *rsa.PublicKey")
|
||||
}
|
||||
ciphertextByte, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, publicKey, []byte(message), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt message with public key err:%s", err.Error())
|
||||
}
|
||||
ciphertext = base64.StdEncoding.EncodeToString(ciphertextByte)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func DecryptAES256GCM(aesKey, associatedData, nonce, ciphertext string) (plaintext string, err error) {
|
||||
decodedCiphertext, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := aes.NewCipher([]byte(aesKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataBytes, err := gcm.Open(nil, []byte(nonce), decodedCiphertext, []byte(associatedData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(dataBytes), nil
|
||||
}
|
||||
|
||||
func SignSHA256WithRSA(source string, privateKey *rsa.PrivateKey) (signature string, err error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key should not be nil")
|
||||
}
|
||||
h := crypto.Hash.New(crypto.SHA256)
|
||||
_, err = h.Write([]byte(source))
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
hashed := h.Sum(nil)
|
||||
signatureByte, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signatureByte), nil
|
||||
}
|
||||
|
||||
func VerifySHA256WithRSA(source string, signature string, publicKey *rsa.PublicKey) error {
|
||||
if publicKey == nil {
|
||||
return fmt.Errorf("public key should not be nil")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify failed: signature is not base64 encoded")
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(source))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify signature with public key error:%s", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateNonce() (string, error) {
|
||||
const (
|
||||
NonceSymbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
NonceLength = 32
|
||||
)
|
||||
|
||||
bytes := make([]byte, NonceLength)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
symbolsByteLength := byte(len(NonceSymbols))
|
||||
for i, b := range bytes {
|
||||
bytes[i] = NonceSymbols[b%symbolsByteLength]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func BuildAuthorization(
|
||||
mchid string,
|
||||
certificateSerialNo string,
|
||||
privateKey *rsa.PrivateKey,
|
||||
method string,
|
||||
canonicalURL string,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
const (
|
||||
SignatureMessageFormat = "%s\n%s\n%d\n%s\n%s\n"
|
||||
HeaderAuthorizationFormat = "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\""
|
||||
)
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp := time.Now().Unix()
|
||||
message := fmt.Sprintf(SignatureMessageFormat, method, canonicalURL, timestamp, nonce, body)
|
||||
signature, err := SignSHA256WithRSA(message, privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorization := fmt.Sprintf(
|
||||
HeaderAuthorizationFormat,
|
||||
mchid, nonce, timestamp, certificateSerialNo, signature,
|
||||
)
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
func ExtractResponseBody(response *http.Response) ([]byte, error) {
|
||||
if response.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body err:[%s]", err.Error())
|
||||
}
|
||||
response.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
|
||||
const (
|
||||
WechatPayTimestamp = "Wechatpay-Timestamp"
|
||||
WechatPayNonce = "Wechatpay-Nonce"
|
||||
WechatPaySignature = "Wechatpay-Signature"
|
||||
WechatPaySerial = "Wechatpay-Serial"
|
||||
RequestID = "Request-Id"
|
||||
)
|
||||
|
||||
func validateWechatPaySignature(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
timestampStr := headers.Get(WechatPayTimestamp)
|
||||
serialNo := headers.Get(WechatPaySerial)
|
||||
signature := headers.Get(WechatPaySignature)
|
||||
nonce := headers.Get(WechatPayNonce)
|
||||
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
if time.Now().Sub(time.Unix(timestamp, 0)) > 5*time.Minute {
|
||||
return fmt.Errorf("timestamp expired: %d", timestamp)
|
||||
}
|
||||
|
||||
if serialNo != wechatpayPublicKeyId {
|
||||
return fmt.Errorf(
|
||||
"serial-no mismatch: got %s, expected %s",
|
||||
serialNo,
|
||||
wechatpayPublicKeyId,
|
||||
)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s\n%s\n%s\n", timestampStr, nonce, body)
|
||||
if err := VerifySHA256WithRSA(message, signature, wechatpayPublicKey); err != nil {
|
||||
return fmt.Errorf("invalid signature: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateResponse(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate response err: %w, RequestID: %s", err, headers.Get(RequestID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate notification err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
OriginalType string `json:"original_type"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime *time.Time `json:"create_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Resource *Resource `json:"resource"`
|
||||
Summary string `json:"summary"`
|
||||
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
func (c *Notification) validate() error {
|
||||
if c.Resource == nil {
|
||||
return errors.New("resource is nil")
|
||||
}
|
||||
|
||||
if c.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
||||
return fmt.Errorf("unsupported algorithm: %s", c.Resource.Algorithm)
|
||||
}
|
||||
|
||||
if c.Resource.Ciphertext == "" {
|
||||
return errors.New("ciphertext is empty")
|
||||
}
|
||||
|
||||
if c.Resource.AssociatedData == "" {
|
||||
return errors.New("associated_data is empty")
|
||||
}
|
||||
|
||||
if c.Resource.Nonce == "" {
|
||||
return errors.New("nonce is empty")
|
||||
}
|
||||
|
||||
if c.Resource.OriginalType == "" {
|
||||
return fmt.Errorf("original_type is empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Notification) decrypt(apiv3Key string) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("notification format err: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := DecryptAES256GCM(
|
||||
apiv3Key,
|
||||
c.Resource.AssociatedData,
|
||||
c.Resource.Nonce,
|
||||
c.Resource.Ciphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
c.Plaintext = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
apiv3Key string,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) (*Notification, error) {
|
||||
if err := validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notification := &Notification{}
|
||||
if err := json.Unmarshal(body, notification); err != nil {
|
||||
return nil, fmt.Errorf("parse notification err: %w", err)
|
||||
}
|
||||
|
||||
if err := notification.decrypt(apiv3Key); err != nil {
|
||||
return nil, fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
type ApiException struct {
|
||||
statusCode int
|
||||
header http.Header
|
||||
body []byte
|
||||
errorCode string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (c *ApiException) Error() string {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(fmt.Sprintf("api error:[StatusCode: %d, Body: %s", c.statusCode, string(c.body)))
|
||||
if len(c.header) > 0 {
|
||||
buf.WriteString(" Header: ")
|
||||
for key, value := range c.header {
|
||||
buf.WriteString(fmt.Sprintf("\n - %v=%v", key, value))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("]")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (c *ApiException) StatusCode() int {
|
||||
return c.statusCode
|
||||
}
|
||||
|
||||
func (c *ApiException) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *ApiException) Body() []byte {
|
||||
return c.body
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorCode() string {
|
||||
return c.errorCode
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorMessage() string {
|
||||
return c.errorMessage
|
||||
}
|
||||
|
||||
func NewApiException(statusCode int, header http.Header, body []byte) error {
|
||||
ret := &ApiException{
|
||||
statusCode: statusCode,
|
||||
header: header,
|
||||
body: body,
|
||||
}
|
||||
|
||||
bodyObject := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &bodyObject); err == nil {
|
||||
if val, ok := bodyObject["code"]; ok {
|
||||
ret.errorCode = val.(string)
|
||||
}
|
||||
if val, ok := bodyObject["message"]; ok {
|
||||
ret.errorMessage = val.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func Time(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func String(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func Bytes(b []byte) *[]byte {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Bool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Float64(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Float32(f float32) *float32 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Int64(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func Int32(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func generateHashFromStream(reader io.Reader, hashFunc func() hash.Hash, algorithmName string) (string, error) {
|
||||
hash := hashFunc()
|
||||
if _, err := io.Copy(hash, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for %s: %w", algorithmName, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func GenerateSHA256FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha256.New, "SHA256")
|
||||
}
|
||||
|
||||
func GenerateSHA1FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha1.New, "SHA1")
|
||||
}
|
||||
|
||||
func GenerateSM3FromStream(reader io.Reader) (string, error) {
|
||||
h := sm3.New()
|
||||
if _, err := io.Copy(h, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for SM3: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 取消
|
||||
*/
|
||||
public class CancelServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/serviceorder/{out_order_no}/cancel";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CancelServiceOrder client = new CancelServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CancelServiceOrderRequest request = new CancelServiceOrderRequest();
|
||||
request.outOrderNo = "2304203423948239423";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.reason = "用户投诉";
|
||||
try {
|
||||
CancelServiceOrderResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public CancelServiceOrderResponse run(CancelServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return WXPayUtility.fromJson(respBody, CancelServiceOrderResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CancelServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CancelServiceOrderRequest {
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
}
|
||||
|
||||
public static class CancelServiceOrderResponse {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 完结
|
||||
*/
|
||||
public class CompleteServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/serviceorder/{out_order_no}/complete";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CompleteServiceOrder client = new CompleteServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CompleteServiceOrderRequest request = new CompleteServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.postPayments = new ArrayList<>();
|
||||
{
|
||||
Payment postPaymentsItem = new Payment();
|
||||
postPaymentsItem.name = "就餐费用";
|
||||
postPaymentsItem.amount = 40000L;
|
||||
postPaymentsItem.description = "就餐人均100元";
|
||||
postPaymentsItem.count = 4L;
|
||||
request.postPayments.add(postPaymentsItem);
|
||||
};
|
||||
request.postDiscounts = new ArrayList<>();
|
||||
{
|
||||
ServiceOrderCoupon postDiscountsItem = new ServiceOrderCoupon();
|
||||
postDiscountsItem.name = "满20减1元";
|
||||
postDiscountsItem.description = "不与其他优惠叠加";
|
||||
postDiscountsItem.amount = 100L;
|
||||
postDiscountsItem.count = 2L;
|
||||
request.postDiscounts.add(postDiscountsItem);
|
||||
};
|
||||
request.totalAmount = 50000L;
|
||||
request.timeRange = new TimeRange();
|
||||
request.timeRange.startTime = "20091225091010";
|
||||
request.timeRange.endTime = "20091225121010";
|
||||
request.timeRange.startTimeRemark = "备注1";
|
||||
request.timeRange.endTimeRemark = "备注2";
|
||||
request.location = new Location();
|
||||
request.location.startLocation = "嗨客时尚主题展餐厅";
|
||||
request.location.endLocation = "嗨客时尚主题展餐厅";
|
||||
request.profitSharing = false;
|
||||
request.goodsTag = "goods_tag";
|
||||
request.device = new Device();
|
||||
request.device.startDeviceId = "HG123456";
|
||||
request.device.endDeviceId = "HG123456";
|
||||
request.device.materielNo = "example_materiel_no";
|
||||
try {
|
||||
CompleteServiceOrderResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public CompleteServiceOrderResponse run(CompleteServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return WXPayUtility.fromJson(respBody, CompleteServiceOrderResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CompleteServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CompleteServiceOrderRequest {
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments = new ArrayList<Payment>();
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("device")
|
||||
public Device device;
|
||||
}
|
||||
|
||||
public static class CompleteServiceOrderResponse {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("need_collection")
|
||||
public Boolean needCollection;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Device {
|
||||
@SerializedName("start_device_id")
|
||||
public String startDeviceId;
|
||||
|
||||
@SerializedName("end_device_id")
|
||||
public String endDeviceId;
|
||||
|
||||
@SerializedName("materiel_no")
|
||||
public String materielNo;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 创建
|
||||
*/
|
||||
public class CreateServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/serviceorder";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
CreateServiceOrder client = new CreateServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreateServiceOrderRequest request = new CreateServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.serviceIntroduction = "某某酒店";
|
||||
request.postPayments = new ArrayList<>();
|
||||
{
|
||||
Payment postPaymentsItem = new Payment();
|
||||
postPaymentsItem.name = "就餐费用";
|
||||
postPaymentsItem.amount = 40000L;
|
||||
postPaymentsItem.description = "就餐人均100元";
|
||||
postPaymentsItem.count = 4L;
|
||||
request.postPayments.add(postPaymentsItem);
|
||||
};
|
||||
request.postDiscounts = new ArrayList<>();
|
||||
{
|
||||
ServiceOrderCoupon postDiscountsItem = new ServiceOrderCoupon();
|
||||
postDiscountsItem.name = "满20减1元";
|
||||
postDiscountsItem.description = "不与其他优惠叠加";
|
||||
postDiscountsItem.amount = 100L;
|
||||
postDiscountsItem.count = 2L;
|
||||
request.postDiscounts.add(postDiscountsItem);
|
||||
};
|
||||
request.timeRange = new TimeRange();
|
||||
request.timeRange.startTime = "20091225091010";
|
||||
request.timeRange.endTime = "20091225121010";
|
||||
request.timeRange.startTimeRemark = "备注1";
|
||||
request.timeRange.endTimeRemark = "备注2";
|
||||
request.location = new Location();
|
||||
request.location.startLocation = "嗨客时尚主题展餐厅";
|
||||
request.location.endLocation = "嗨客时尚主题展餐厅";
|
||||
request.riskFund = new RiskFund();
|
||||
request.riskFund.name = "DEPOSIT";
|
||||
request.riskFund.amount = 10000L;
|
||||
request.riskFund.description = "就餐的预估费用";
|
||||
request.attach = "Easdfowealsdkjfnlaksjdlfkwqoi&wl3l2sald";
|
||||
request.notifyUrl = "https://api.test.com";
|
||||
request.needUserConfirm = false;
|
||||
request.device = new Device();
|
||||
request.device.startDeviceId = "HG123456";
|
||||
request.device.endDeviceId = "HG123456";
|
||||
request.device.materielNo = "example_materiel_no";
|
||||
try {
|
||||
CreateServiceOrderResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public CreateServiceOrderResponse run(CreateServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return WXPayUtility.fromJson(respBody, CreateServiceOrderResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CreateServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CreateServiceOrderRequest {
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("need_user_confirm")
|
||||
public Boolean needUserConfirm;
|
||||
|
||||
@SerializedName("device")
|
||||
public Device device;
|
||||
}
|
||||
|
||||
public static class CreateServiceOrderResponse {
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("package")
|
||||
public String _package;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class Device {
|
||||
@SerializedName("start_device_id")
|
||||
public String startDeviceId;
|
||||
|
||||
@SerializedName("end_device_id")
|
||||
public String endDeviceId;
|
||||
|
||||
@SerializedName("materiel_no")
|
||||
public String materielNo;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 修改金额
|
||||
*/
|
||||
public class ModifyServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/serviceorder/{out_order_no}/modify";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
ModifyServiceOrder client = new ModifyServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
ModifyServiceOrderRequest request = new ModifyServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.postPayments = new ArrayList<>();
|
||||
{
|
||||
Payment postPaymentsItem = new Payment();
|
||||
postPaymentsItem.name = "就餐费用";
|
||||
postPaymentsItem.amount = 40000L;
|
||||
postPaymentsItem.description = "就餐人均100元";
|
||||
postPaymentsItem.count = 4L;
|
||||
request.postPayments.add(postPaymentsItem);
|
||||
};
|
||||
request.postDiscounts = new ArrayList<>();
|
||||
{
|
||||
ServiceOrderCoupon postDiscountsItem = new ServiceOrderCoupon();
|
||||
postDiscountsItem.name = "满20减1元";
|
||||
postDiscountsItem.description = "不与其他优惠叠加";
|
||||
postDiscountsItem.amount = 100L;
|
||||
postDiscountsItem.count = 2L;
|
||||
request.postDiscounts.add(postDiscountsItem);
|
||||
};
|
||||
request.totalAmount = 50000L;
|
||||
request.reason = "用户投诉";
|
||||
request.device = new Device();
|
||||
request.device.startDeviceId = "HG123456";
|
||||
request.device.endDeviceId = "HG123456";
|
||||
request.device.materielNo = "example_materiel_no";
|
||||
try {
|
||||
ServiceOrderEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceOrderEntity run(ModifyServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, ServiceOrderEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public ModifyServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class ModifyServiceOrderRequest {
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments = new ArrayList<Payment>();
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
|
||||
@SerializedName("device")
|
||||
public Device device;
|
||||
}
|
||||
|
||||
public static class ServiceOrderEntity {
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public Payment postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("need_collection")
|
||||
public Boolean needCollection;
|
||||
|
||||
@SerializedName("collection")
|
||||
public Collection collection;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class Device {
|
||||
@SerializedName("start_device_id")
|
||||
public String startDeviceId;
|
||||
|
||||
@SerializedName("end_device_id")
|
||||
public String endDeviceId;
|
||||
|
||||
@SerializedName("materiel_no")
|
||||
public String materielNo;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class Collection {
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("paying_amount")
|
||||
public Long payingAmount;
|
||||
|
||||
@SerializedName("paid_amount")
|
||||
public Long paidAmount;
|
||||
|
||||
@SerializedName("details")
|
||||
public List<Detail> details;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Detail {
|
||||
@SerializedName("seq")
|
||||
public Long seq;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("paid_type")
|
||||
public String paidType;
|
||||
|
||||
@SerializedName("paid_time")
|
||||
public String paidTime;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询
|
||||
*/
|
||||
public class GetServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/payscore/serviceorder";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
GetServiceOrder client = new GetServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
GetServiceOrderRequest request = new GetServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.queryId = "15646546545165651651";
|
||||
try {
|
||||
ServiceOrderEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceOrderEntity run(GetServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("out_order_no", request.outOrderNo);
|
||||
args.put("service_id", request.serviceId);
|
||||
args.put("appid", request.appid);
|
||||
args.put("query_id", request.queryId);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return WXPayUtility.fromJson(respBody, ServiceOrderEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public GetServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class GetServiceOrderRequest {
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
@Expose(serialize = false)
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
@Expose(serialize = false)
|
||||
public String appid;
|
||||
|
||||
@SerializedName("query_id")
|
||||
@Expose(serialize = false)
|
||||
public String queryId;
|
||||
}
|
||||
|
||||
public static class ServiceOrderEntity {
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public Payment postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("need_collection")
|
||||
public Boolean needCollection;
|
||||
|
||||
@SerializedName("collection")
|
||||
public Collection collection;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class Collection {
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("paying_amount")
|
||||
public Long payingAmount;
|
||||
|
||||
@SerializedName("paid_amount")
|
||||
public Long paidAmount;
|
||||
|
||||
@SerializedName("details")
|
||||
public List<Detail> details;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Detail {
|
||||
@SerializedName("seq")
|
||||
public Long seq;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("paid_type")
|
||||
public String paidType;
|
||||
|
||||
@SerializedName("paid_time")
|
||||
public String paidTime;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 同步服务订单信息
|
||||
*/
|
||||
public class SyncServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/serviceorder/{out_order_no}/sync";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
SyncServiceOrder client = new SyncServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
SyncServiceOrderRequest request = new SyncServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.type = "Order_Paid";
|
||||
request.detail = new SyncDetail();
|
||||
request.detail.paidTime = "20091225091210";
|
||||
try {
|
||||
ServiceOrderEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceOrderEntity run(SyncServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, ServiceOrderEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public SyncServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class SyncServiceOrderRequest {
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("detail")
|
||||
public SyncDetail detail;
|
||||
}
|
||||
|
||||
public static class ServiceOrderEntity {
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public Payment postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("need_collection")
|
||||
public Boolean needCollection;
|
||||
|
||||
@SerializedName("collection")
|
||||
public Collection collection;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
}
|
||||
|
||||
public static class SyncDetail {
|
||||
@SerializedName("paid_time")
|
||||
public String paidTime;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class Collection {
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("paying_amount")
|
||||
public Long payingAmount;
|
||||
|
||||
@SerializedName("paid_amount")
|
||||
public Long paidAmount;
|
||||
|
||||
@SerializedName("details")
|
||||
public List<Detail> details;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Detail {
|
||||
@SerializedName("seq")
|
||||
public Long seq;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("paid_type")
|
||||
public String paidType;
|
||||
|
||||
@SerializedName("paid_time")
|
||||
public String paidTime;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 退款申请
|
||||
*/
|
||||
public class Create {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/refund/domestic/refunds";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
Create client = new Create(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreateRequest request = new CreateRequest();
|
||||
request.transactionId = "1217752501201407033233368018";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
request.reason = "商品已售完";
|
||||
request.notifyUrl = "https://weixin.qq.com";
|
||||
request.fundsAccount = ReqFundsAccount.AVAILABLE;
|
||||
request.amount = new AmountReq();
|
||||
request.amount.refund = 888L;
|
||||
request.amount.from = new ArrayList<>();
|
||||
{
|
||||
FundsFromItem fromItem = new FundsFromItem();
|
||||
fromItem.account = Account.AVAILABLE;
|
||||
fromItem.amount = 444L;
|
||||
request.amount.from.add(fromItem);
|
||||
};
|
||||
request.amount.total = 888L;
|
||||
request.amount.currency = "CNY";
|
||||
request.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1217752501201407033233368018";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhone6s 16G";
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
goodsDetailItem.refundAmount = 528800L;
|
||||
goodsDetailItem.refundQuantity = 1L;
|
||||
request.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(CreateRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public Create(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CreateRequest {
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public ReqFundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public AmountReq amount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
}
|
||||
|
||||
public enum ReqFundsAccount {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED
|
||||
}
|
||||
|
||||
public static class AmountReq {
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("CASH")
|
||||
CASH,
|
||||
@SerializedName("NOCASH")
|
||||
NOCASH
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/merchant/4014931831
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询单笔退款(通过商户退款单号)
|
||||
*/
|
||||
public class QueryByOutRefundNo {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/refund/domestic/refunds/{out_refund_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
QueryByOutRefundNo client = new QueryByOutRefundNo(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest();
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(QueryByOutRefundNoRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_refund_no}", WXPayUtility.urlEncode(request.outRefundNo));
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryByOutRefundNo(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryByOutRefundNoRequest {
|
||||
@SerializedName("out_refund_no")
|
||||
@Expose(serialize = false)
|
||||
public String outRefundNo;
|
||||
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
}
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("CASH")
|
||||
CASH,
|
||||
@SerializedName("NOCASH")
|
||||
NOCASH
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
# JSAPI / 小程序调起支付分确认订单页(商户)
|
||||
|
||||
> 源文档:[JSAPI调起支付分确认订单页](https://pay.weixin.qq.com/doc/v3/merchant/4012587945.md)
|
||||
> 小程序入口:[wx.openBusinessView](https://pay.weixin.qq.com/doc/v3/merchant/4012587949.md)
|
||||
> APP 端入口:[Android](https://pay.weixin.qq.com/doc/v3/merchant/4012587909.md) / [iOS](https://pay.weixin.qq.com/doc/v3/merchant/4012596359.md) / [鸿蒙](https://pay.weixin.qq.com/doc/v3/merchant/4015271805.md)
|
||||
|
||||
## 接入说明
|
||||
|
||||
1. 商户后端调用「创建支付分订单」([CreatePayScoreOrder.java](../1-订单管理/CreatePayScoreOrder.java) / [create_payscore_order.go](../../Go/1-订单管理/create_payscore_order.go)),并将请求中 `need_user_confirm` 设为 `true`。
|
||||
2. 接口返回 `package` 字段(形如 `mch_id=...&service_id=...&out_order_no=...×tamp=...&nonce_str=...&sign_type=HMAC-SHA256&signature=...`),由商户后端组装后下发到前端。
|
||||
3. 前端通过 `WeixinJSBridge.invoke('openBusinessView', ...)`(公众号 H5)或 `wx.openBusinessView(...)`(小程序)拉起确认订单页。
|
||||
4. 用户确认后,微信会回调商户的 `notify_url`(参考 [5-回调通知/确认订单回调通知说明.md](../5-回调通知/确认订单回调通知说明.md))。
|
||||
|
||||
## 公众号 H5 示例代码
|
||||
|
||||
```javascript
|
||||
function onBridgeReady() {
|
||||
WeixinJSBridge.invoke(
|
||||
'openBusinessView',
|
||||
{
|
||||
businessType: 'wxpayScoreUse',
|
||||
queryString: '<package_in_create_response>' // 后端 CreateServiceOrder 应答中的 package 字段
|
||||
},
|
||||
function (res) {
|
||||
if (res.err_msg === 'open_business_view:ok') {
|
||||
// 用户已点击同意,前端不要直接据此判断业务成功
|
||||
// 必须以「确认订单回调通知」或主动「查询支付分订单」为准
|
||||
} else {
|
||||
// open_business_view:cancel 用户取消;其它为异常
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof WeixinJSBridge === 'undefined') {
|
||||
if (document.addEventListener) {
|
||||
document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
|
||||
} else if (document.attachEvent) {
|
||||
document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
|
||||
document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
|
||||
}
|
||||
} else {
|
||||
onBridgeReady();
|
||||
}
|
||||
```
|
||||
|
||||
## 小程序示例代码
|
||||
|
||||
```javascript
|
||||
wx.openBusinessView({
|
||||
businessType: 'wxpayScoreUse',
|
||||
extraData: {
|
||||
// package 字段需 URL Decode 后逐项填入:mch_id / service_id / out_order_no / timestamp / nonce_str / sign_type / signature
|
||||
mch_id: 'mch_id_from_package',
|
||||
service_id: 'service_id_from_package',
|
||||
out_order_no:'out_order_no_from_package',
|
||||
timestamp: 'timestamp_from_package',
|
||||
nonce_str: 'nonce_str_from_package',
|
||||
sign_type: 'HMAC-SHA256',
|
||||
signature: 'signature_from_package'
|
||||
},
|
||||
success(res) {
|
||||
// res.errMsg === 'openBusinessView:ok' 表示用户已点击同意
|
||||
// 业务成功仍以回调 / 查单为准
|
||||
},
|
||||
fail(err) {
|
||||
// 失败处理
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| `signature` 算法 | **APIv2 商户密钥 + HMAC-SHA256**(不要使用 APIv3 私钥)。错误使用 APIv3 私钥会返回 `SIGN_ERROR` |
|
||||
| `package` 来源 | 必须取自后端 `CreateServiceOrder` 应答字段;前端禁止自行拼接 |
|
||||
| `timestamp` 时效 | 5 分钟内有效,超时需重新下单 |
|
||||
| 业务判定 | 前端 `success` 仅代表用户同意 UI,必须以「确认订单回调」或「查询支付分订单」为准 |
|
||||
| `appid` 一致 | 调起的 appid 必须与 `CreateServiceOrder` 时入参 `appid` 相同 |
|
||||
@@ -0,0 +1,59 @@
|
||||
# JSAPI / 小程序调起支付分订单详情页(商户)
|
||||
|
||||
> 源文档:[JSAPI调起支付分订单详情页](https://pay.weixin.qq.com/doc/v3/merchant/4012587983.md)
|
||||
> 小程序入口:[wx.openBusinessView](https://pay.weixin.qq.com/doc/v3/merchant/4012587984.md)
|
||||
> APP 端入口:[Android](https://pay.weixin.qq.com/doc/v3/merchant/4012587980.md) / [iOS](https://pay.weixin.qq.com/doc/v3/merchant/4012596423.md) / [鸿蒙](https://pay.weixin.qq.com/doc/v3/merchant/4015271812.md)
|
||||
|
||||
## 接入说明
|
||||
|
||||
商户在用户使用过程或订单完结后,希望让用户在微信内查看本笔支付分订单的费用 / 状态 / 明细时,可调起「订单详情页」。
|
||||
|
||||
订单详情页所需参数 `query_string` 由商户后端按以下格式拼接并签名(与「调起确认订单页」逻辑相同):
|
||||
|
||||
```
|
||||
mch_id={mch_id}&service_id={service_id}&out_order_no={out_order_no}×tamp={timestamp}&nonce_str={nonce_str}&sign_type=HMAC-SHA256&signature={signature}
|
||||
```
|
||||
|
||||
签名算法:使用 **APIv2 商户密钥 + HMAC-SHA256**(详见 [签名与验签规则.md](../../../接入指南/签名与验签规则.md) 中"客户端拉起签名(V2)"小节)。
|
||||
|
||||
## 公众号 H5 示例代码
|
||||
|
||||
```javascript
|
||||
WeixinJSBridge.invoke(
|
||||
'openBusinessView',
|
||||
{
|
||||
businessType: 'wxpayScoreDetail',
|
||||
queryString: '<query_string_assembled_by_backend>'
|
||||
},
|
||||
function (res) {
|
||||
if (res.err_msg === 'open_business_view:ok') {
|
||||
// 用户已访问详情页
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 小程序示例代码
|
||||
|
||||
```javascript
|
||||
wx.openBusinessView({
|
||||
businessType: 'wxpayScoreDetail',
|
||||
extraData: {
|
||||
mch_id: 'xxx',
|
||||
service_id: 'xxx',
|
||||
out_order_no:'xxx',
|
||||
timestamp: 'xxx',
|
||||
nonce_str: 'xxx',
|
||||
sign_type: 'HMAC-SHA256',
|
||||
signature: 'xxx'
|
||||
},
|
||||
success(res) { /* ... */ },
|
||||
fail(err) { /* ... */ }
|
||||
});
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `appid` 必须与「创建支付分订单」时入参一致。
|
||||
- 详情页只读不可改:用户的修改 / 退款行为请走商户后台对应接口。
|
||||
- 仅当订单已存在(`CREATED` / `DOING` / `DONE`)时可调起;未创建会报错。
|
||||
@@ -0,0 +1,44 @@
|
||||
# APP 调起支付分(商户)
|
||||
|
||||
> 源文档:
|
||||
> - Android 确认订单页:[4012587909](https://pay.weixin.qq.com/doc/v3/merchant/4012587909.md)
|
||||
> - iOS 确认订单页:[4012596359](https://pay.weixin.qq.com/doc/v3/merchant/4012596359.md)
|
||||
> - 鸿蒙 确认订单页:[4015271805](https://pay.weixin.qq.com/doc/v3/merchant/4015271805.md)
|
||||
> - Android 订单详情页:[4012587980](https://pay.weixin.qq.com/doc/v3/merchant/4012587980.md)
|
||||
> - iOS 订单详情页:[4012596423](https://pay.weixin.qq.com/doc/v3/merchant/4012596423.md)
|
||||
> - 鸿蒙 订单详情页:[4015271812](https://pay.weixin.qq.com/doc/v3/merchant/4015271812.md)
|
||||
|
||||
## 接入说明
|
||||
|
||||
APP 端调起的参数与公众号 / 小程序 一致,由 **商户后端** 根据「创建支付分订单」应答的 `package` 字段拼接、并按 **APIv2 商户密钥 + HMAC-SHA256** 计算 `signature`,下发给 APP。
|
||||
|
||||
APP 端通过微信开放平台 SDK 的 `WXOpenBusinessView`(Android)/ `WXOpenBusinessViewReq`(iOS) 调起。
|
||||
|
||||
## Android 关键代码
|
||||
|
||||
```java
|
||||
WXOpenBusinessView req = new WXOpenBusinessView();
|
||||
req.businessType = "wxpayScoreUse"; // 详情页用 wxpayScoreDetail
|
||||
req.query = "mch_id=xxx&service_id=xxx&out_order_no=xxx×tamp=xxx&nonce_str=xxx&sign_type=HMAC-SHA256&signature=xxx";
|
||||
req.extInfo = "{\"miniProgramType\":0}";
|
||||
api.sendReq(req);
|
||||
```
|
||||
|
||||
## iOS 关键代码
|
||||
|
||||
```objective-c
|
||||
WXOpenBusinessViewReq *req = [[WXOpenBusinessViewReq alloc] init];
|
||||
req.businessType = @"wxpayScoreUse"; // 详情页用 wxpayScoreDetail
|
||||
req.query = @"mch_id=xxx&service_id=xxx&out_order_no=xxx×tamp=xxx&nonce_str=xxx&sign_type=HMAC-SHA256&signature=xxx";
|
||||
req.extInfo = @"{\"miniProgramType\":0}";
|
||||
[WXApi sendReq:req completion:nil];
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 项 | 要求 |
|
||||
|---|---|
|
||||
| 微信版本 | Android ≥ 7.0.5、iOS ≥ 7.0.5、鸿蒙 ≥ 微信支付 SDK 最新版 |
|
||||
| `appid` | 必须与商户在 `CreateServiceOrder` 时使用的 `appid` 完全一致 |
|
||||
| 业务结果 | APP 端回调 `errCode == 0` 仅表示用户操作完成,业务成功仍以「确认订单回调」或「查询支付分订单」为准 |
|
||||
| 鉴权失败 | 出现 `SIGN_ERROR` 多由"误用 APIv3 私钥代替 APIv2 密钥"导致,参考 [签名与验签规则.md](../../../接入指南/签名与验签规则.md) |
|
||||
@@ -0,0 +1,46 @@
|
||||
# 支付成功回调通知(商户)
|
||||
|
||||
> 源文档:[支付成功回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4012587960.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
完结订单(`/v3/payscore/serviceorder/{out_order_no}/complete`)后,若订单 `need_collection = true`(需收款),微信支付分将异步发起代扣并在扣款 / 收款进展变化时回推本通知。商户需以本通知为准更新「收款(collection)」状态。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_PAID",
|
||||
"summary": "用户支付成功",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到 ServiceOrderEntity>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 固定为 `PAYSCORE.USER_PAID` |
|
||||
| 解密后 `state` | `DONE` |
|
||||
| 解密后 `collection.state` | `USER_PAYING` / `USER_ACCEPT` / `MCH_LOADING`,最终态多为 `USER_PAID` |
|
||||
| 解密后 `collection.details[]` | 每一笔扣款明细:`amount`、`paid_type`、`paid_time`、`transaction_id` |
|
||||
|
||||
## 商户处理要求
|
||||
|
||||
1. **验签 + 解密**:流程同确认订单回调。
|
||||
2. **路由 / 幂等**:以 `event_type = PAYSCORE.USER_PAID` 区分,按 `out_order_no` 幂等。
|
||||
3. **状态机**:
|
||||
- `collection.state` 在终态前可能多次回推(多笔代扣 / 退回 / 重试),商户需累加 `paid_amount`、写入流水表,避免覆盖。
|
||||
- 终态 `state = DONE` 且 `collection.paid_amount` 与 `total_amount` 一致时方可计完结。
|
||||
4. **资金对账**:以 `transaction_id` 为唯一支付单号入账;如需分账,按业务发起 `/v3/profitsharing/orders`。
|
||||
5. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`,否则返回 5xx + 错误描述触发重试。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 确认订单回调通知(商户)
|
||||
|
||||
> 源文档:[确认订单回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4012587953.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
> 通用签名规则:[../接入指南/签名与验签规则.md](../../../接入指南/签名与验签规则.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
用户在「确认订单」页面(小程序 / APP / H5)点击同意后,微信支付分会以 **POST** 方式向商户在创建订单时填写的 `notify_url` 推送本通知,标识订单已变为 `DOING` 状态、可正式发起服务。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_CONFIRM",
|
||||
"summary": "支付分订单用户已确认",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到 ServiceOrderEntity>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `event_type` | 固定为 `PAYSCORE.USER_CONFIRM`;用于回调路由判断(与 `PAYSCORE.USER_PAID` / `REFUND.SUCCESS` 区分) |
|
||||
| `resource.algorithm` | 固定 `AEAD_AES_256_GCM` |
|
||||
| `resource.ciphertext` | 解密后为商户视角的支付分订单实体,包含 `out_order_no`、`service_id`、`appid`、`mchid`、`state`、`openid`、`order_id`、`risk_fund`、`time_range`、`location` 等字段 |
|
||||
| 解密所得 `state` | `DOING`(用户已确认,可开始提供服务) |
|
||||
|
||||
## 商户处理要求
|
||||
|
||||
1. **验签**:使用 `Wechatpay-Signature` / `Wechatpay-Timestamp` / `Wechatpay-Nonce` / `Wechatpay-Serial` 头,配合微信支付公钥校验请求体;任一不匹配立即返回 `401`。
|
||||
2. **解密**:用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密 `resource.ciphertext`。
|
||||
3. **路由**:以 `event_type` 区分确认 / 支付 / 退款回调;以 `out_order_no` 入库去重(`UNIQUE INDEX(out_order_no, event_type)`)。
|
||||
4. **入库**:将订单状态更新为 `DOING`,登记 `openid`、`order_id`,触发后续业务(如开门、放行、发货)。
|
||||
5. **应答**:处理成功返回 `200 + {"code":"SUCCESS","message":"成功"}`;业务异常返回 `500 + {"code":"FAIL","message":"<原因>"}` 触发重试。
|
||||
6. **幂等**:同一 `out_order_no + event_type` 多次到达必须只生效一次。
|
||||
|
||||
## 测试要点
|
||||
|
||||
- 主动调用「查询支付分订单」接口与回调入库结果做交叉校验,避免遗漏。
|
||||
- 模拟回调延迟 / 重试场景(处理超时不影响业务)。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 退款结果回调通知(商户)
|
||||
|
||||
> 源文档:[退款结果通知](https://pay.weixin.qq.com/doc/v3/merchant/4012587976.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
商户调用「申请退款」接口(`/v3/payscore/refunds`)受理后,微信支付分异步处理实际退款,退款进入终态时(`SUCCESS` / `CLOSED` / `ABNORMAL`)回推本通知。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "REFUND.SUCCESS",
|
||||
"summary": "退款成功",
|
||||
"resource": {
|
||||
"original_type": "refund",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用商户 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到退款详情>",
|
||||
"associated_data": "refund",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## event_type 一览
|
||||
|
||||
| event_type | 含义 | 处理建议 |
|
||||
|------------|------|---------|
|
||||
| `REFUND.SUCCESS` | 退款成功 | 更新业务退款单为成功,触发对账 / 通知用户 |
|
||||
| `REFUND.CLOSED` | 退款被关闭 | 一般为商户重复发起 / 资金不足等,需按 `refund_status` + `error_msg` 分支处理 |
|
||||
| `REFUND.ABNORMAL` | 退款异常 | 按异常原因人工介入,可发起「异常退款」补救 |
|
||||
|
||||
## 解密后关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `out_refund_no` | 商户退款单号,幂等键 |
|
||||
| `refund_id` | 微信侧退款单号,唯一 |
|
||||
| `out_order_no` | 关联的支付分订单号 |
|
||||
| `transaction_id` | 关联的支付单号(若该退款关联具体扣款流水) |
|
||||
| `amount.refund` | 实际退款金额(分) |
|
||||
| `refund_status` | `SUCCESS` / `CLOSED` / `PROCESSING` / `ABNORMAL` |
|
||||
| `success_time` | 退款成功时间 |
|
||||
|
||||
## 商户处理要求
|
||||
|
||||
1. **验签 + 解密** 与 `回调处理.md` 一致。
|
||||
2. **幂等**:以 `out_refund_no` 入库去重;同一单多次回调取最新终态。
|
||||
3. **状态机**:仅信任 `refund_status` 字段,不要以 HTTP 200 作为退款成功判据。
|
||||
4. **多退一致性**:如需多次部分退款,需累加 `amount.refund`,确保不超过 `total_amount`。
|
||||
5. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`。
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.java.utils;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
/**
|
||||
* 微信支付 HTTP 客户端,封装了请求签名、发送、应答验签的完整流程。
|
||||
* 依赖 WXPayUtility 提供的签名、验签、序列化等基础能力。
|
||||
*/
|
||||
public class WXPayClient {
|
||||
private static final String HOST = "https://api.mch.weixin.qq.com";
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public WXPayClient(String mchid, String certificateSerialNo, String privateKeyFilePath,
|
||||
String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 GET 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendGet(String uri) {
|
||||
return sendRequest("GET", uri, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendPost(String uri, String reqBody) {
|
||||
return sendRequest("POST", uri, reqBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公钥加密敏感信息
|
||||
*/
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
private String sendRequest(String method, String uri, String reqBody) {
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(
|
||||
mchid, certificateSerialNo, privateKey, method, uri, reqBody));
|
||||
|
||||
if (reqBody != null) {
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(method, body);
|
||||
} else {
|
||||
reqBuilder.method(method, null);
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
return respBody;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.java.utils;
|
||||
|
||||
import com.google.gson.ExclusionStrategy;
|
||||
import com.google.gson.FieldAttributes;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
import okhttp3.Headers;
|
||||
import okhttp3.Response;
|
||||
import okio.BufferedSource;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.security.MessageDigest;
|
||||
import java.io.InputStream;
|
||||
import org.bouncycastle.crypto.digests.SM3Digest;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import java.security.Security;
|
||||
|
||||
public class WXPayUtility {
|
||||
private static final Gson gson = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.addSerializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.addDeserializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.deserialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.create();
|
||||
private static final char[] SYMBOLS =
|
||||
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
|
||||
public static String toJson(Object object) {
|
||||
return gson.toJson(object);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
|
||||
return gson.fromJson(json, classOfT);
|
||||
}
|
||||
|
||||
private static String readKeyStringFromPath(String keyPath) {
|
||||
try {
|
||||
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(
|
||||
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
|
||||
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePublic(
|
||||
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromPath(String keyPath) {
|
||||
return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static String createNonce(int length) {
|
||||
char[] buf = new char[length];
|
||||
for (int i = 0; i < length; ++i) {
|
||||
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
|
||||
}
|
||||
return new String(buf);
|
||||
}
|
||||
|
||||
public static String encrypt(PublicKey publicKey, String plaintext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Plaintext is too long", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
|
||||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Ciphertext decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
|
||||
byte[] ciphertext) {
|
||||
final String transformation = "AES/GCM/NoPadding";
|
||||
final String algorithm = "AES";
|
||||
final int tagLengthBit = 128;
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(key, algorithm),
|
||||
new GCMParameterSpec(tagLengthBit, nonce));
|
||||
if (associatedData != null) {
|
||||
cipher.updateAAD(associatedData);
|
||||
}
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (InvalidKeyException
|
||||
| InvalidAlgorithmParameterException
|
||||
| BadPaddingException
|
||||
| IllegalBlockSizeException
|
||||
| NoSuchAlgorithmException
|
||||
| NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
|
||||
transformation), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sign(String message, String algorithm, PrivateKey privateKey) {
|
||||
byte[] sign;
|
||||
try {
|
||||
Signature signature = Signature.getInstance(algorithm);
|
||||
signature.initSign(privateKey);
|
||||
signature.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
sign = signature.sign();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("An error occurred during the sign process.", e);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(sign);
|
||||
}
|
||||
|
||||
public static boolean verify(String message, String signature, String algorithm,
|
||||
PublicKey publicKey) {
|
||||
try {
|
||||
Signature sign = Signature.getInstance(algorithm);
|
||||
sign.initVerify(publicKey);
|
||||
sign.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
return sign.verify(Base64.getDecoder().decode(signature));
|
||||
} catch (SignatureException e) {
|
||||
return false;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("verify uses an illegal publickey.", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String buildAuthorization(String mchid, String certificateSerialNo,
|
||||
PrivateKey privateKey,
|
||||
String method, String uri, String body) {
|
||||
String nonce = createNonce(32);
|
||||
long timestamp = Instant.now().getEpochSecond();
|
||||
|
||||
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
|
||||
body == null ? "" : body);
|
||||
|
||||
String signature = sign(message, "SHA256withRSA", privateKey);
|
||||
|
||||
return String.format(
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
|
||||
"timestamp=\"%d\",serial_no=\"%s\"",
|
||||
mchid, nonce, signature, timestamp, certificateSerialNo);
|
||||
}
|
||||
|
||||
private static String calculateHash(InputStream inputStream, String algorithm) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(algorithm + " algorithm not available", e);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-256");
|
||||
}
|
||||
|
||||
public static String sha1(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-1");
|
||||
}
|
||||
|
||||
public static String sm3(InputStream inputStream) {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
try {
|
||||
SM3Digest digest = new SM3Digest();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hashBytes, 0);
|
||||
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(String content) {
|
||||
try {
|
||||
return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(Map<String, Object> params) {
|
||||
if (params == null || params.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (Entry<String, Object> entry : params.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof List) {
|
||||
List<?> list = (List<?>) entry.getValue();
|
||||
for (Object temp : list) {
|
||||
appendParam(result, key, temp);
|
||||
}
|
||||
} else {
|
||||
appendParam(result, key, value);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder result, String key, Object value) {
|
||||
if (result.length() > 0) {
|
||||
result.append("&");
|
||||
}
|
||||
|
||||
String valueString;
|
||||
if (value instanceof String || value instanceof Number ||
|
||||
value instanceof Boolean || value instanceof Enum) {
|
||||
valueString = value.toString();
|
||||
} else {
|
||||
valueString = toJson(value);
|
||||
}
|
||||
|
||||
result.append(key)
|
||||
.append("=")
|
||||
.append(urlEncode(valueString));
|
||||
}
|
||||
|
||||
public static String extractBody(Response response) {
|
||||
if (response.body() == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedSource source = response.body().source();
|
||||
return source.readUtf8();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(String.format("An error occurred during reading response body. " +
|
||||
"Status: %d", response.code()), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
|
||||
Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
String requestId = headers.get("Request-ID");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
|
||||
"%s", wechatpayPublicKeyId, serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed,the WechatPay signature is incorrect.%n"
|
||||
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers.get("Request-ID"), headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateNotification(String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
|
||||
"Remote: %s",
|
||||
wechatpayPublicKeyId,
|
||||
serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, WechatPay signature is incorrect.\n"
|
||||
+ "responseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
|
||||
Notification notification = gson.fromJson(body, Notification.class);
|
||||
notification.decrypt(apiv3Key);
|
||||
return notification;
|
||||
}
|
||||
|
||||
public static class ApiException extends RuntimeException {
|
||||
private static final long serialVersionUID = 2261086748874802175L;
|
||||
|
||||
private final int statusCode;
|
||||
private final String body;
|
||||
private final Headers headers;
|
||||
private final String errorCode;
|
||||
private final String errorMessage;
|
||||
|
||||
public ApiException(int statusCode, String body, Headers headers) {
|
||||
super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
|
||||
body, headers));
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
|
||||
if (body != null && !body.isEmpty()) {
|
||||
JsonElement code;
|
||||
JsonElement message;
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
|
||||
code = jsonObject.get("code");
|
||||
message = jsonObject.get("message");
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
code = null;
|
||||
message = null;
|
||||
}
|
||||
this.errorCode = code == null ? null : code.getAsString();
|
||||
this.errorMessage = message == null ? null : message.getAsString();
|
||||
} else {
|
||||
this.errorCode = null;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public Headers getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Notification {
|
||||
@SerializedName("id")
|
||||
private String id;
|
||||
@SerializedName("create_time")
|
||||
private String createTime;
|
||||
@SerializedName("event_type")
|
||||
private String eventType;
|
||||
@SerializedName("resource_type")
|
||||
private String resourceType;
|
||||
@SerializedName("summary")
|
||||
private String summary;
|
||||
@SerializedName("resource")
|
||||
private Resource resource;
|
||||
private String plaintext;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public String getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public Resource getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public String getPlaintext() {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (resource == null) {
|
||||
throw new IllegalArgumentException("Missing required field `resource` in notification");
|
||||
}
|
||||
resource.validate();
|
||||
}
|
||||
|
||||
private void decrypt(String apiv3Key) {
|
||||
validate();
|
||||
|
||||
plaintext = aesAeadDecrypt(
|
||||
apiv3Key.getBytes(StandardCharsets.UTF_8),
|
||||
resource.associatedData.getBytes(StandardCharsets.UTF_8),
|
||||
resource.nonce.getBytes(StandardCharsets.UTF_8),
|
||||
Base64.getDecoder().decode(resource.ciphertext)
|
||||
);
|
||||
}
|
||||
|
||||
public static class Resource {
|
||||
@SerializedName("algorithm")
|
||||
private String algorithm;
|
||||
|
||||
@SerializedName("ciphertext")
|
||||
private String ciphertext;
|
||||
|
||||
@SerializedName("associated_data")
|
||||
private String associatedData;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("original_type")
|
||||
private String originalType;
|
||||
|
||||
public String getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String getCiphertext() {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
public String getAssociatedData() {
|
||||
return associatedData;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getOriginalType() {
|
||||
return originalType;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (algorithm == null || algorithm.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
|
||||
throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
|
||||
"Notification.Resource", algorithm));
|
||||
}
|
||||
|
||||
if (ciphertext == null || ciphertext.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (associatedData == null || associatedData.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `associatedData` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
|
||||
if (nonce == null || nonce.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (originalType == null || originalType.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `originalType` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getContentTypeByFileName(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = "";
|
||||
int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
|
||||
extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Map<String, String> contentTypeMap = new HashMap<>();
|
||||
contentTypeMap.put("png", "image/png");
|
||||
contentTypeMap.put("jpg", "image/jpeg");
|
||||
contentTypeMap.put("jpeg", "image/jpeg");
|
||||
contentTypeMap.put("gif", "image/gif");
|
||||
contentTypeMap.put("bmp", "image/bmp");
|
||||
contentTypeMap.put("webp", "image/webp");
|
||||
contentTypeMap.put("svg", "image/svg+xml");
|
||||
contentTypeMap.put("ico", "image/x-icon");
|
||||
contentTypeMap.put("pdf", "application/pdf");
|
||||
contentTypeMap.put("doc", "application/msword");
|
||||
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
contentTypeMap.put("xls", "application/vnd.ms-excel");
|
||||
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
|
||||
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
contentTypeMap.put("txt", "text/plain");
|
||||
contentTypeMap.put("html", "text/html");
|
||||
contentTypeMap.put("css", "text/css");
|
||||
contentTypeMap.put("js", "application/javascript");
|
||||
contentTypeMap.put("json", "application/json");
|
||||
contentTypeMap.put("xml", "application/xml");
|
||||
contentTypeMap.put("csv", "text/csv");
|
||||
contentTypeMap.put("mp3", "audio/mpeg");
|
||||
contentTypeMap.put("wav", "audio/wav");
|
||||
contentTypeMap.put("mp4", "video/mp4");
|
||||
contentTypeMap.put("avi", "video/x-msvideo");
|
||||
contentTypeMap.put("mov", "video/quicktime");
|
||||
contentTypeMap.put("zip", "application/zip");
|
||||
contentTypeMap.put("rar", "application/x-rar-compressed");
|
||||
contentTypeMap.put("7z", "application/x-7z-compressed");
|
||||
|
||||
return contentTypeMap.getOrDefault(extension, "application/octet-stream");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# 商户模式接口索引
|
||||
|
||||
> 根据用户确认的开发语言加载对应文件,Java/Go 目录结构一致。
|
||||
> 本索引覆盖商户视角下的全部支付分服务端接口、客户端拉起脚本、回调通知报文说明,以及共用 SDK 工具类。
|
||||
|
||||
## 命名约定
|
||||
|
||||
- 分组目录:`{编号}-{业务名}/`,编号从 `1` 起(`1-订单管理/`、`2-退款/`、`3-小程序拉起/`、`4-APP拉起/`、`5-回调通知/`、`6-SDK工具类/`)
|
||||
- Java 代码文件:大驼峰 `.java`(如 `CreatePayScoreOrder.java`)
|
||||
- Go 代码文件:蛇形 `.go`(如 `create_payscore_order.go`)
|
||||
- 回调通知 `.md`:内容语言无关,**Java/ 与 Go/ 各放一份**——Java/ 用中文命名(如 `确认订单回调通知说明.md`),Go/ 用蛇形拼音(如 `user_confirm_callback.md`),内容完全一致
|
||||
- 客户端拉起 `.md`:语言无关的集成说明,统一放在 Java/ 下即可(无需 Go 副本)
|
||||
|
||||
---
|
||||
|
||||
## 业务接口
|
||||
|
||||
> 每个业务分组一张表,列含义如下:
|
||||
> - **服务端 API**(如下单 / 查单 / 退款):`Java` / `Go` 列分别为对应语言的可执行代码文件路径
|
||||
> - **回调通知**:`Java` / `Go` 列分别指向**同一份**报文说明 `.md`(语言无关,按目录约定各放一份方便项目查找)
|
||||
> - **客户端拉起**(小程序 / APP):跨语言通用的 `.md` 集成说明,仅列 `Java` 一列即可
|
||||
|
||||
### 1-订单管理(服务端 API)
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|---|---|---|---|
|
||||
| 创建支付分订单 | POST /v3/payscore/serviceorder | `Java/1-订单管理/CreatePayScoreOrder.java` | `Go/1-订单管理/create_payscore_order.go` |
|
||||
| 查询支付分订单 | GET /v3/payscore/serviceorder | `Java/1-订单管理/QueryPayScoreOrder.java` | `Go/1-订单管理/query_payscore_order.go` |
|
||||
| 取消支付分订单 | POST /v3/payscore/serviceorder/{out_order_no}/cancel | `Java/1-订单管理/CancelPayScoreOrder.java` | `Go/1-订单管理/cancel_payscore_order.go` |
|
||||
| 完结支付分订单 | POST /v3/payscore/serviceorder/{out_order_no}/complete | `Java/1-订单管理/CompletePayScoreOrder.java` | `Go/1-订单管理/complete_payscore_order.go` |
|
||||
| 修改订单金额 | POST /v3/payscore/serviceorder/{out_order_no}/modify | `Java/1-订单管理/ModifyPayScoreOrder.java` | `Go/1-订单管理/modify_payscore_order.go` |
|
||||
| 同步订单状态 | POST /v3/payscore/serviceorder/{out_order_no}/sync | `Java/1-订单管理/SyncPayScoreOrder.java` | `Go/1-订单管理/sync_payscore_order.go` |
|
||||
|
||||
> 端到端业务流程、状态机、`risk_fund` / 完结金额公式等字段约束详见 [📄 开发参数与业务规则.md](../接入指南/开发参数与业务规则.md)。
|
||||
|
||||
### 2-退款(服务端 API)
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|---|---|---|---|
|
||||
| 申请退款 | POST /v3/refund/domestic/refunds | `Java/2-退款/CreatePayScoreRefund.java` | `Go/2-退款/create_payscore_refund.go` |
|
||||
| 查询退款 | GET /v3/refund/domestic/refunds/{out_refund_no} | `Java/2-退款/QueryPayScoreRefund.java` | `Go/2-退款/query_payscore_refund.go` |
|
||||
|
||||
> 退款必须用 `transaction_id`(不是 `out_order_no`)。
|
||||
|
||||
### 3-小程序拉起(客户端集成)
|
||||
|
||||
| 业务 | 接口 | Java |
|
||||
|---|---|---|
|
||||
| JSAPI / 小程序 拉起确认订单页 | `WeixinJSBridge.invoke('openBusinessView', businessType='wxpayScoreUse')` / `wx.openBusinessView` | `Java/3-小程序拉起/JsapiInvokeConfirm.md` |
|
||||
| JSAPI / 小程序 拉起订单详情页 | `WeixinJSBridge.invoke('openBusinessView', businessType='wxpayScoreDetail')` / `wx.openBusinessView` | `Java/3-小程序拉起/JsapiInvokeDetail.md` |
|
||||
|
||||
> `signature` 必须使用 **APIv2 商户密钥 + HMAC-SHA256**。误用 APIv3 私钥将触发 `SIGN_ERROR`,详见 [📄 签名与验签规则.md](../接入指南/签名与验签规则.md)。
|
||||
|
||||
### 4-APP拉起(Android / iOS / 鸿蒙)
|
||||
|
||||
| 业务 | 客户端入口 | Java |
|
||||
|---|---|---|
|
||||
| APP 拉起确认订单页 / 订单详情页 | Android `WXOpenBusinessView` / iOS `WXOpenBusinessViewReq` | `Java/4-APP拉起/AppInvoke.md` |
|
||||
|
||||
### 5-回调通知(异步事件,无可执行代码)
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|---|---|---|---|
|
||||
| 用户确认订单回调通知(`PAYSCORE.USER_CONFIRM`) | 回调报文格式与处理要求 | `Java/5-回调通知/确认订单回调通知说明.md` | `Go/5-回调通知/user_confirm_callback.md` |
|
||||
| 支付成功回调通知(`PAYSCORE.USER_PAID`) | 回调报文格式与处理要求 | `Java/5-回调通知/支付成功回调通知说明.md` | `Go/5-回调通知/user_paid_callback.md` |
|
||||
| 退款结果回调通知(`REFUND.SUCCESS` / `REFUND.CLOSED` / `REFUND.ABNORMAL`) | 回调报文格式与处理要求 | `Java/5-回调通知/退款结果回调通知说明.md` | `Go/5-回调通知/refund_result_callback.md` |
|
||||
|
||||
> 通用解密 / 验签 / 回包流程参考 [📄 回调处理.md](../接入指南/回调处理.md)。
|
||||
|
||||
---
|
||||
|
||||
## 6-SDK 工具类(所有接口的公共依赖)
|
||||
|
||||
> 所有示例代码都依赖此工具类,提供签名、验签、加解密、HTTP 请求等基础能力。**提醒用户需一并集成**。
|
||||
>
|
||||
> ‼️ 详见 [📄 签名与验签规则.md](../接入指南/签名与验签规则.md)。
|
||||
|
||||
| 语言 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| Java | `Java/6-SDK工具类/WXPayUtility.java` | 签名、验签、加解密 |
|
||||
| Java | `Java/6-SDK工具类/WXPayClient.java` | HTTP 客户端,封装请求签名 → 发送 → 验签 |
|
||||
| Go | `Go/6-SDK工具类/wxpay_utility.go` | 签名、验签、加解密 |
|
||||
| Go | `Go/6-SDK工具类/wxpay_client.go` | HTTP 客户端,封装请求签名 → 发送 → 验签 |
|
||||
454
.codex/skills/wechatpay-payscore/references/1-商户/问题排查/排障手册.md
Normal file
454
.codex/skills/wechatpay-payscore/references/1-商户/问题排查/排障手册.md
Normal file
@@ -0,0 +1,454 @@
|
||||
# 商户模式排障手册
|
||||
|
||||
> 本文档是本角色 + 本产品排障的**唯一入口**。另一接入模式见对应角色目录下同名文件。
|
||||
>
|
||||
> ‼️ **使用规则**:用户报告任何问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 对账差异等),**先加载本文档**按下方流程匹配,不要先翻其他文档或猜原因。
|
||||
>
|
||||
> ‼️ **语气**:像有经验的技术支持,自然对话解释原因和方案,不要冷冰冰罗列文档目录。
|
||||
|
||||
## 排障流程
|
||||
|
||||
1. **能给 Request-Id?** → 走「一、错误码 TOP 20」:取 Request-Id 末尾 `-` 后的数字(如 `...CF05-268578704` → `268578704`)在速查表匹配,命中后用「错误码详细排查」对应段落回复。
|
||||
2. **不能给 / 未命中 TOP 20?** → 走「二、常见问题」:按现象(HTTP / 回调 / 签名 / 退款 / 角色特有 / 业务规则 / 通用配置)定位子节。
|
||||
3. **两条都没命中?** → 用末尾「排障信息收集清单」回收信息后再判断。
|
||||
|
||||
---
|
||||
|
||||
## 一、错误码 TOP 20(Request-Id 场景)
|
||||
|
||||
> 来源:本产品真实工单 / 客服系统统计的高频错误码。
|
||||
|
||||
### 1.1 TOP 20 速查表
|
||||
|
||||
| 错误码 | 错误信息 | 分类 |
|
||||
|:------:|---------|:----:|
|
||||
| 271317510 | 查询单据不存在 | 订单查询 |
|
||||
| 271316574 | 支付分扣款失败 | 扣款 |
|
||||
| 271302262 | 当前订单状态不合法 | 订单状态 |
|
||||
| 268435464 | 参数超出取值范围 | 参数校验 |
|
||||
| 271316657 | 仅服务订单支付状态为待支付时,才可使用此能力 | 订单状态 |
|
||||
| 271316598 | 订单重入参数校验失败 | 订单重入 |
|
||||
| 271302144 | mch_id 不存在 | 商户配置 |
|
||||
| 271302149 | mch_id 和 appid 未绑定 | 商户配置 |
|
||||
| 268435472 | 请求过于频繁 | 频控 |
|
||||
| 271300099 | 综合评估未通过 | 风控 |
|
||||
| 271302333 | 当前订单状态不合法(已取消) | 订单状态 |
|
||||
| 271299627 | 存在待处理的超时未支付订单 | 业务规则 |
|
||||
| 271302332 | 当前订单状态不合法(确认状态) | 订单状态 |
|
||||
| 268503786 | 当前无权限进行此操作 | 权限 |
|
||||
| 271316592 | 当前订单状态不满足撤销条件 | 订单状态 |
|
||||
| 270924332 | Authorization 不合法 | 签名 |
|
||||
| 271316754 | 单据正在扣款中,请稍后重试 | 扣款 |
|
||||
| 271316656 | 总金额超过此服务的服务风险金额 | 业务规则 |
|
||||
| 271316637 | 真实结束时间小于预计开始时间 | 参数校验 |
|
||||
| 268560785 | 已开启青少年模式支付限额,暂无法使用支付分先用后付服务 | 用户侧限制 |
|
||||
|
||||
### 1.2 错误码详细排查
|
||||
|
||||
#### 271317510 — 查询单据不存在
|
||||
**常见原因**:
|
||||
- 用 `out_order_no` 在错误的 `mchid` 下查询(多商户号场景商户号弄混)
|
||||
- 创单失败但业务侧把 `out_order_no` 当作"已创建"入库后又用它查询
|
||||
- 创单接口实际返回非 2xx,但业务侧只看了"调用完成"未校验返回码
|
||||
- 查询时机过早,创单与查询有数十毫秒级延迟(极少见)
|
||||
|
||||
**🔧 脚本确认**:建议先调"查询单据"接口确认 mchid + out_order_no 组合是否真实存在;如本地有多个 mchid,把所有候选 mchid 都扫一遍。
|
||||
|
||||
**💡 推荐集成**:查询接口是支付分排障的"瑞士军刀",强烈建议接入"按 out_order_no 查询订单"作为常驻能力,回调与异常恢复都要用到。
|
||||
|
||||
---
|
||||
|
||||
#### 271316574 — 支付分扣款失败
|
||||
**常见原因**:
|
||||
- 用户支付分账户余额不足或绑定的零钱 / 银行卡扣款失败
|
||||
- 用户已主动关闭微信支付分免密授权
|
||||
- 完结金额异常(远高于风险金或与 `post_payments` 不一致触发风控)
|
||||
|
||||
> ⚠️ 与本错误码无关的常见误区:完结时漏传 `profit_sharing=true` 只是导致这笔单不能调用「请求分账」,**不会影响扣款**,也不会触发 271316574。分账发生在扣款成功之后,分账失败也不会回滚扣款。
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"看 `state` / `state_description` / `collection.state`:
|
||||
- `DOING + USER_PAYING` → 扣款重试中,业务侧不要再调完结
|
||||
- `DOING + MCH_COMPLETE` → 已完结但收款中
|
||||
- `DONE + USER_PAID` → 实际已收款,本次失败可忽略
|
||||
|
||||
**💡 推荐集成**:建议接入"支付成功回调通知"(payscore-paid)作为收款判定的最终依据,不能仅凭完结接口的同步返回判定。
|
||||
|
||||
---
|
||||
|
||||
#### 271302262 / 271302333 / 271302332 — 当前订单状态不合法
|
||||
**常见原因**:
|
||||
- **271302262**(通用):完结接口在 `state ≠ DOING` 或 `state_description ∉ {USER_CONFIRM, MCH_COMPLETE}` 时调用
|
||||
- **271302333**:订单已被取消(CLOSED),却仍然调取消 / 完结 / 修改金额接口
|
||||
- **271302332**:订单还停留在 `CREATED + USER_CONFIRM` 等待用户确认阶段,就发起完结 / 修改金额 / 取消,必须等到 `DOING` 才能操作
|
||||
|
||||
**🔧 脚本确认**:先调"查询订单"拿到当前 `state` + `state_description` + `collection.state`,三者结合判断:
|
||||
- `CREATED` → 等用户确认(或调"取消"释放)
|
||||
- `DOING` → 可"完结" / "修改金额" / "同步"
|
||||
- `DONE` → 终态,不可再操作
|
||||
- `CLOSED` → 已关闭,重发新单
|
||||
|
||||
**💡 推荐集成**:业务侧应在调用前置接口(完结 / 取消 / 修改金额)前,先做一次本地缓存的状态判定;CREATED 类订单建议加定时查询兜底,避免错过状态变更通知。
|
||||
|
||||
---
|
||||
|
||||
#### 268435464 — 参数超出取值范围
|
||||
**常见原因**:
|
||||
- 金额字段单位错误(误传"元"而非"分",或传成浮点数)
|
||||
- `time_range.start_time` / `end_time` 不是 RFC3339 `yyyy-MM-ddTHH:mm:ss+08:00` 格式
|
||||
- 字符串字段超长(如 `description` 超过 32 个字符)
|
||||
- 数组型字段(`post_payments` / `post_discounts`)单元素金额为负或为 0
|
||||
|
||||
**🔧 脚本确认**:拿到完整请求 body 与 [API 字段规范](https://pay.weixin.qq.com/doc/v3/merchant/4012587200.md) 逐项对照。重点检查:金额是否为整数(单位分)、时间格式、数组元素是否完整。
|
||||
|
||||
---
|
||||
|
||||
#### 271316657 — 仅服务订单支付状态为待支付时,才可使用此能力
|
||||
**常见原因**:
|
||||
- 调"修改订单金额"或"取消订单"时,订单的 `collection.state` 已不是 `WAIT_PAY`(如已扣款 USER_PAID 或 PAYING)
|
||||
- 用户已自助通过"支付分订单详情页"提前支付,业务侧仍按"待支付"逻辑触发后续动作
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"看 `collection.state`,仅 `WAIT_PAY` 才可执行金额变更 / 取消。
|
||||
|
||||
---
|
||||
|
||||
#### 271316598 — 订单重入参数校验失败
|
||||
**常见原因**:
|
||||
- 同一 `out_order_no` 第二次创单时,关键参数(金额 / `service_id` / `appid` / 用户标识 / `risk_fund.amount`)与第一次不一致
|
||||
- 创单超时但实际成功,业务侧重试时改了报文
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"用原 `out_order_no` 查到首次创单的真实参数,比对差异:① 完全一致 → 原创单已成功,无需重试;② 不一致 → 用新的 `out_order_no` 重新创单。
|
||||
|
||||
**💡 推荐集成**:创单要做幂等:本地保存 `out_order_no` 与请求摘要,重试时严格透传相同参数;超时未拿到响应时不要立即换号重发,先查询。
|
||||
|
||||
---
|
||||
|
||||
#### 271302144 — mch_id 不存在
|
||||
**常见原因**:
|
||||
- 配置文件 / 环境变量里的 `mchid` 写错(前后空格、误填 sub_mchid)
|
||||
- 沙箱、灰度、正式环境的 mchid 串了
|
||||
- 服务商场景误用本接口(应使用服务商创单接口并传 `sub_mchid`)
|
||||
|
||||
**🔧 脚本确认**:把发起请求的 `mchid` 与商户平台「账户中心 → 商户信息」核对一致。
|
||||
|
||||
---
|
||||
|
||||
#### 271302149 — mch_id 和 appid 未绑定
|
||||
**常见原因**:
|
||||
- `appid` 未与 `mchid` 完成绑定(公众号、小程序、APP appid 之一)
|
||||
- 在服务商场景下错传了主商户 `appid` 而非"绑定到 sub_mchid 的 sub_appid"
|
||||
- 应用级 appid 升级 / 迁移后未在商户平台同步绑定
|
||||
|
||||
**🔧 脚本确认**:商户平台 → 产品中心 → AppID 账号管理 → 确认目标 appid 在已绑定列表中。
|
||||
|
||||
---
|
||||
|
||||
#### 268435472 — 请求过于频繁
|
||||
**常见原因**:
|
||||
- 同一 `out_order_no` 在短时间内(< 1s)重复调用同一接口
|
||||
- 业务定时任务大批量并发调用查询 / 完结接口
|
||||
- 异常重试无指数退避
|
||||
|
||||
**🔧 脚本确认**:检查重试机制是否做了指数退避(建议初值 200ms,最多 3 次);并发查询建议加令牌桶限流。
|
||||
|
||||
---
|
||||
|
||||
#### 271300099 — 综合评估未通过
|
||||
**常见原因**:风控不通过,触发条件可能为:
|
||||
- 用户进行中的支付分订单 > 3 笔
|
||||
- 用户实名稳定性 / 信用记录不达标
|
||||
- 创单 `risk_fund.amount` 偏高,超出该用户的可承受额度
|
||||
- 用户近期累计的"超时未支付订单"过多
|
||||
|
||||
**🔧 处理建议**:业务侧无法直接干预。可建议用户:① 先完结进行中订单;② 商户侧适当下调 `risk_fund.amount`;③ 提示用户改用其他支付方式(押金 / 普通预付)。**不要在前端展示"风控不通过"原文**,建议引导文案为"暂不支持先用后付,请选择其他方式"。
|
||||
|
||||
---
|
||||
|
||||
#### 271299627 — 存在待处理的超时未支付订单
|
||||
**常见原因**:用户名下累计的"超时未支付"支付分订单过多,平台拦截新订单创建。
|
||||
|
||||
**🔧 处理建议**:提示用户先在微信"我 → 服务 → 钱包 → 支付分 → 订单"中处理掉欠款订单后再下单。业务侧不可绕开。
|
||||
|
||||
---
|
||||
|
||||
#### 268503786 — 当前无权限进行此操作
|
||||
**常见原因**:
|
||||
- `mchid` 未开通微信支付分产品
|
||||
- `service_id` 未在该 `mchid` 下完成绑定(最常见)
|
||||
- 在测试期,调单 `mchid` 不在 service_id 的"测试号配置"白名单
|
||||
- 调用了角色不匹配的接口(商户号调了服务商专属能力)
|
||||
|
||||
**🔧 处理建议**:① 商户平台 → 产品中心 → 微信支付分 确认开通;② 联系微信支付行业运营在 `service_id` 上做增量绑定;③ 测试期把测试 mchid / 微信号加入 service_id 测试白名单。
|
||||
|
||||
---
|
||||
|
||||
#### 271316592 — 当前订单状态不满足撤销条件
|
||||
**常见原因**:
|
||||
- 商户已调过「完结订单」,订单进入 `DOING + state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,扣款进行中)后再调取消
|
||||
- 订单已 `DONE` / `REVOKED` / `EXPIRED` 终态后再调取消
|
||||
- 订单已 `CLOSED` 后重复取消
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"看 `state` + `state_description` + `collection.state` 三元组:
|
||||
- `CREATED` → ✅ 可取消(商户主动)
|
||||
- `DOING + USER_CONFIRM` → ✅ 可取消(用户已确认但商户尚未完结)
|
||||
- `DOING + MCH_COMPLETE`(`collection.state=USER_PAYING`)→ ❌ 已进入扣款,改走"修改金额"或等待回调
|
||||
- `DONE` / `REVOKED` / `EXPIRED` → ❌ 终态,无需也不可取消
|
||||
|
||||
---
|
||||
|
||||
#### 270924332 — Authorization 不合法
|
||||
**常见原因**:
|
||||
- 签名串拼接错误(HTTP 方法 / URL path / 时间戳 / 随机串 / body 顺序错了,或 body 为空时缺末尾 `\n`)
|
||||
- 商户私钥与上送的 `serial_no` 不匹配(换证书时只换了 serial_no 没同步换私钥)
|
||||
- 商户私钥文件被错误加载(PEM 头尾被裁剪、CRLF 转 LF 后失效)
|
||||
- Authorization 头格式错误(缺 `WECHATPAY2-SHA256-RSA2048` 前缀、字段间缺逗号、字段未带英文双引号)
|
||||
- 调起小程序场景误把 APIv3 签名当作 APIv2 签名(**调起 sign 必须用 APIv2 密钥 + HMAC-SHA256/MD5**)
|
||||
|
||||
**🔧 脚本确认**:建议直接切官方 SDK(`wechatpay-java` / `wechatpay-go` / `wechatpay-php` 等)做签名,绝大多数 401 都是手写签名引起的。
|
||||
|
||||
**💡 推荐集成**:建议加载本 skill 的「签名与验签规则.md」对照排查,特别注意小程序拉起 sign 与 APIv3 请求签名是两套密钥体系。
|
||||
|
||||
---
|
||||
|
||||
#### 271316754 — 单据正在扣款中,请稍后重试
|
||||
**常见原因**:
|
||||
- 业务侧并发触发了多次完结接口
|
||||
- 上次完结请求在微信端排队中,业务侧重试过早(< 5 秒)
|
||||
- 没等扣款回调就再次发起金额修改 / 完结
|
||||
|
||||
**🔧 处理建议**:等待 5-10 秒后查"查询订单"判断 `collection.state`:
|
||||
- `USER_PAYING` → 扣款仍在进行,再等等
|
||||
- `USER_PAID` → 已成功,无需重试
|
||||
- `WAIT_PAY` → 上次扣款失败,可重发
|
||||
|
||||
---
|
||||
|
||||
#### 271316656 — 总金额超过此服务的服务风险金额
|
||||
**常见原因**:
|
||||
- 创单 `risk_fund.amount` 大于该 `service_id` 在行业准入时核定的"风险金额上限"
|
||||
- 完结时 `total_amount` 远高于创单 `risk_fund.amount`(一般要求完结金额 ≤ 风险金额 × 1.x 倍,按服务类型不同上限不同)
|
||||
|
||||
**🔧 处理建议**:
|
||||
1. 联系微信支付行业运营确认该 service_id 的风险金额上限
|
||||
2. 业务侧在创单时按用户分层动态设置 `risk_fund.amount`,常见分位避免一刀切
|
||||
3. 若上限确实不够用,可申请提额
|
||||
|
||||
---
|
||||
|
||||
#### 271316637 — 真实结束时间小于预计开始时间
|
||||
**常见原因**:完结订单时传的 `time_range.end_time` 早于创单时的 `time_range.start_time`。常见诱因:
|
||||
- 业务侧时区错误(+0800 / UTC 混用)
|
||||
- 完结时只传 `end_time` 未同时透传 `start_time`,与创单值产生跨天误判
|
||||
- 用户提前结束服务但 `end_time` 计算时减错了时长
|
||||
|
||||
**🔧 处理建议**:完结时**同时透传 `start_time` 和 `end_time`**,并保证 `end_time ≥ start_time`;时间字段必须 RFC3339 含时区。
|
||||
|
||||
---
|
||||
|
||||
#### 268560785 — 已开启青少年模式支付限额,暂无法使用支付分先用后付服务
|
||||
**常见原因**:用户在微信内开启了"青少年模式"或"支付限额管理",未授权使用支付分。
|
||||
|
||||
**🔧 处理建议**:业务侧不可绕开。前端可提示"该用户当前无法使用先用后付,请选择其他付款方式",并自动降级到普通押金 / 预付流程。
|
||||
|
||||
---
|
||||
|
||||
## 二、常见问题(无 Request-Id 场景)
|
||||
|
||||
> 来源:本产品官方「常见问题」文档 + 通用接入经验沉淀。
|
||||
|
||||
### 2.1 HTTP 错误(401 / 400 / 403)
|
||||
|
||||
| 状态码 | 含义 | 常见原因 | 排查要点 |
|
||||
|:----:|------|---------|---------|
|
||||
| 401 | 签名验证失败 | 私钥与证书不匹配;serial_no 填错;签名串拼接有误(换行符 / URL / body 为空时缺末尾换行);时间戳偏差过大 | 检查 Authorization 头格式;确认私钥正确加载;建议用官方 SDK |
|
||||
| 400 | 请求参数错误 | 必填参数缺失;金额单位是分不是元;时间格式不符 RFC 3339;JSON 层级错误 | 对照 API 文档逐项检查;金额单位是**分**;时间格式 `yyyy-MM-ddTHH:mm:ss+08:00` |
|
||||
| 403 | 权限不足 | 未开通对应支付产品;IP 不在白名单;商户号状态异常 | 商户平台 → 产品中心确认开通状态 |
|
||||
|
||||
### 2.2 回调问题
|
||||
|
||||
**收不到回调排查清单**(按优先级):① 地址不可达(URL 错 / 域名解析失败 / localhost / 服务未启动)→ ② URL 前后有空格致 DNS 失败 → ③ 防火墙拦截(未对回调 IP 段开白名单,见下方 IP)→ ④ 登录态拦截(notify_url 须从鉴权中间件中排除)→ ⑤ 响应非 200(如 FAIL / 404,重试后放弃)→ ⑥ 处理超时(须 5 秒内应答)→ ⑦ 域名未 ICP 备案 → ⑧ 商户号用错(实际收到了但用另一个 mchid 查单导致"订单不存在")。
|
||||
|
||||
**回调行为 Q&A**:
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 怎么确认微信发了回调? | 微信不提供回调日志查询,检查自身服务器访问日志 + 查单接口确认 |
|
||||
| 2 | 回调会重复收到吗? | 会,未正确响应时微信会重试,业务必须做幂等 |
|
||||
| 3 | 回调延迟正常吗? | 数秒到数十秒均属正常。建议回调 + 主动查单双保险 |
|
||||
| 4 | 能直接将回调当最终结果吗? | 不能,回调不保证送达,需结合查单接口确认 |
|
||||
| 5 | 商户平台能查回调状态吗? | 不支持,需调用查单接口 |
|
||||
| 6 | 回调怎么测试? | 无测试接口,需生产环境真实业务验证 |
|
||||
|
||||
**回调解密与验签**:
|
||||
|
||||
| # | 报错 | 原因 | 解法 |
|
||||
|---|------|------|------|
|
||||
| 1 | `cipher: message authentication failed` / `AEADBadTagException` | APIv3 密钥错误(最常见:密钥重置后代码未同步)或密文被截断 | 检查代码中的 APIv3 密钥与商户平台一致 |
|
||||
| 2 | "证书序列号不一致" | 用商户证书做了验签(应用平台证书)或平台证书过期 | 改用平台证书并确保未过期 |
|
||||
| 3 | `Last unit does not have enough valid bits` | 签名探测流量 | 检查 `Wechatpay-Signature` 是否以 `WECHATPAY/SIGNTEST/` 开头,是则返回非 2xx |
|
||||
| 4 | 签名参数顺序错误 | 参数个数 / 顺序 / 大小写不对或末尾缺 `\n` | 严格按文档顺序拼接,末尾必须有 `\n` |
|
||||
|
||||
**回调 IP 白名单**:
|
||||
|
||||
| 出口位置 | IP 网段 |
|
||||
|---------|---------|
|
||||
| 上海电信 / 联通 / CAP | `101.226.103.0/25` / `140.207.54.0/25` / `121.51.58.128/25` |
|
||||
| 深圳电信 / 联通 / CAP | `183.3.234.0/25` / `58.251.80.0/25` / `121.51.30.128/25` |
|
||||
| 香港 / 广州腾讯云 | `203.205.219.128/25` / `81.71.199.64`、`81.71.198.25`、`81.71.199.59` |
|
||||
|
||||
退款 / 分账通知 IP:`175.24.214.208`、`175.24.211.24`、`175.24.213.135`、`109.244.180.23`、`114.132.203.119`、`43.139.43.69`
|
||||
|
||||
### 2.3 签名与证书
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 证书序列号怎么获取? | 商户平台 → 账户中心 → API安全 → 商户API证书 → 管理证书 |
|
||||
| 2 | 一个商户号能设多个 API 证书吗? | 可以 |
|
||||
| 3 | 平台证书过期怎么换? | 参考 https://pay.weixin.qq.com/doc/v3/merchant/4012068829 ,建议代码实现自动轮换 |
|
||||
| 4 | 换 serial_no 后报签名错误? | 证书编号与私钥一一对应,更新 serial_no 时必须同步换私钥文件 |
|
||||
| 5 | V2 签名失败怎么排查? | 逐项对比签名原串:① 字段按 ASCII 字典序;② 大小写一致;③ 无多余空格或遗漏字段。本产品**调起小程序 sign 强依赖 APIv2 密钥**,常被忽略 |
|
||||
| 6 | V2 签名方式? | MD5 或 HMAC-SHA256(不是 V3 的 SHA256-RSA2048) |
|
||||
| 7 | API 只能通过域名访问吗? | 是,不支持 IP 直连 |
|
||||
| 8 | APIv2 密钥改后验签失败? | 密钥重置后代码中的密钥必须同步更新 |
|
||||
|
||||
### 2.4 退款常见问题
|
||||
|
||||
> 产品专属退款规则(不可退期限、最小金额等)见 2.6。
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 余额不足 | 商户账户可用余额不足,充值后重试 |
|
||||
| 2 | 重复退款 | `out_refund_no` 已使用,换一个或查询原单状态 |
|
||||
| 3 | 订单未支付 | 只有支付 / 扣款成功的订单才能退款 |
|
||||
| 4 | 支付和退款能跨 V2/V3 吗? | 可以,但不建议 |
|
||||
| 5 | 退款没到账 / 状态异常 | 先查退款状态:`PROCESSING`=处理中(1-3 工作日);`SUCCESS`=已成功;`ABNORMAL`=异常退款,走异常流程;`CLOSED`=退款关闭,用新单号重发。代码见对应模式接口索引 |
|
||||
|
||||
### 2.5 本角色特有问题
|
||||
|
||||
> 来源:本产品商户模式真实工单沉淀。
|
||||
|
||||
| 报错信息 | 原因 | 解决 |
|
||||
|---------|------|------|
|
||||
| `NO_AUTH 商户暂无权限使用此服务`(创单时) | mchid / appid 未绑到 service_id;或当前 service_id 是「需确认订单」模式但用了「免确认订单」接口(反之亦然) | ① 联系微信支付行业运营在 service_id 上做绑定;② 严格按 service_id 类型选用对应接口文档([需确认订单](https://pay.weixin.qq.com/doc/v3/merchant/4012587900) / [免确认订单](https://pay.weixin.qq.com/doc/v3/merchant/4012587929)) |
|
||||
| 商户解除用户授权时返回"商户解除授权的授权码不是这个用户签约的授权" | 解约接口传入的 `authorization_code` 不是该用户在本商户下签约时返回的那一个 | 重新查询用户授权记录拿到正确的 `authorization_code`;不要把其他商户的授权码传过来 |
|
||||
| 解约报"存在未完成订单" | 用户在本商户下还有进行中(≤ 3 笔)+ 待支付(≤ 1 笔)订单 | 引导用户在「微信 → 我 → 钱包 → 支付分 → 全部订单类型 → 进行中」处理掉未完结订单后再解约 |
|
||||
| 接口提示"暂无法使用此服务,微信支付分逐步开放中" | service_id 未上线 + 当前用户未在测试白名单 | 商户平台 → 产品中心 → 微信支付分 → 测试号配置 → 添加测试微信号;上线后所有用户可用 |
|
||||
|
||||
### 2.6 业务规则 Q&A
|
||||
|
||||
> 来源:[微信支付分常见问题(商户)](https://pay.weixin.qq.com/doc/v3/merchant/4012587200.md) + [权限类问题排查指南](https://pay.weixin.qq.com/doc/v3/merchant/4018398339.md)
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | "暂无法使用此服务,微信支付分逐步开放中"是什么意思? | 服务尚未上线且当前用户不在测试白名单。商户平台 → 产品中心 → 微信支付分 → 测试号配置添加测试微信号;上线后所有用户可用 |
|
||||
| 2 | `NO_AUTH 商户暂无权限使用此服务` 怎么处理? | 创单时使用的 mchid / appid 不在 service_id 报备清单内。联系微信支付行业运营在 service_id 上做增量绑定 |
|
||||
| 3 | "综合评估未通过"是什么原因? | 风控不通过:用户进行中订单过多 / 信用记录 / 实名稳定性 / 风险金过高。业务侧无法直接干预;可建议用户先完结当前订单或下调 `risk_fund.amount` |
|
||||
| 4 | "同一实名身份下进行中订单过多" / "超时未支付记录累计过多" | 单用户进行中订单 > 3 笔 或长期累积超时未支付订单。提示用户先处理完进行中订单 |
|
||||
| 5 | `INVALID_REQUEST 当前订单状态不合法` 是什么时候报? | 完结接口仅在 `state=DOING` 且 `state_description ∈ {USER_CONFIRM, MCH_COMPLETE}` 时可调,其他状态会报错。先调查询订单确认状态 |
|
||||
| 6 | `PARAM_ERROR 最终总金额计算非法` 是什么意思? | 完结时未严格满足 `total_amount = Σpost_payments.amount - Σpost_discounts.amount`。本地按公式校验后再发请求 |
|
||||
| 7 | "创建订单的订单风险金额超过此服务的服务风险金额" | `risk_fund.amount` 超过 service_id 风险金额上限。找运营确认上限并调小金额 |
|
||||
| 8 | 退款报"订单不存在" | 用 `out_order_no` 申请退款会报。必须用 `transaction_id`(来自支付成功回调或查询订单接口) |
|
||||
| 9 | 完结报"真实结束时间小于预计开始时间" | 完结的 `time_range.end_time` 早于创单的 `start_time`。同时传 `start_time` / `end_time` 或确保 `end_time` 晚于创单 `start_time` |
|
||||
| 10 | `post_payments` 商品信息未在订单详情显示 | 未严格按行业字段规范传参。参考行业 [post_payments 字段传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587259.md) |
|
||||
| 11 | 订单状态怎么判断? | 必须 `state` + `state_description` + `collection.state` **三者结合**,不能只看 `state` |
|
||||
| 12 | 订单 30 天未确认会怎样? | CREATED 状态 30 天未变动自动 EXPIRED。建议对 CREATED 订单做定时查询兜底,避免漏接确认通知 |
|
||||
| 13 | 修改订单金额能上调吗? | 不能,**只能下调**。代码侧应限制传入值不大于原待支付金额 |
|
||||
| 14 | 用户走其他渠道支付了怎么办? | 调"同步订单状态"接口,订单变 DONE,避免重复扣款 |
|
||||
| 15 | 订单详情页 / 录屏验收不通过怎么办? | UI 必须满足 [支付分合作品牌线上应用规范](https://pay.weixin.qq.com/doc/v3/merchant/4012587220.md);`post_payments` 严格按行业传参 |
|
||||
|
||||
### 2.7 通用接入配置
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | V2 和 V3 可以同时用吗? | 可以,密钥体系独立互不影响 |
|
||||
| 2 | 微信支付有测试环境吗? | **没有**,所有调试需在生产环境进行 |
|
||||
| 3 | 同一错误为什么返回不同错误码? | 存在参数校验优先级,多参数错误时可能先返回 `PARAM_ERROR` |
|
||||
| 4 | 接口地址能在浏览器直接打开吗? | **不能**,需程序调用并携带证书,建议用 Postman 调试 |
|
||||
| 5 | 防火墙拦截(如医院场景)怎么办? | 微信服务端 IP 动态更新,建议以**域名白名单**配置防火墙 |
|
||||
|
||||
---
|
||||
|
||||
## 三、真实工单 Q&A 沉淀(商户模式)
|
||||
|
||||
> 来源:商户真实工单沉淀,按主题归类高频问题与官方答复口径,可作为客服 / 一线开发的参考标准答案。
|
||||
|
||||
### 3.1 扣款相关
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 用户支付分订单一直处于"扣款中" / 扣款延迟 | 大概率是用户侧原因:绑定的支付方式余额不足、银行卡状态异常、微信账户被风控限制等。**官方话术**:引导用户在「微信 → 我 → 钱包 → 支付分 → 待支付」中**主动支付**试试,账户异常时会有具体提示 |
|
||||
| 2 | "银行卡可用余额不足(如信用卡则为可透支额度不足)" | 用户绑定的所有扣款方式余额都不足,通常会轮询多张卡均失败。引导用户充值或更换支付方式后,自行在支付分待支付列表里主动拉起 |
|
||||
| 3 | 扣款时报"实际结束时间不能晚于使用完结接口的时间" | 完结接口里 `time_range.end_time` 不能晚于调用完结接口的当前时间(即不允许"未来时间"完结)。修正 `end_time` 为当前时间或更早即可 |
|
||||
| 4 | 订单进入"待支付"后多久会被关闭? | **不会被自动关闭**。商户调过「完结订单」后订单进入 `DOING + state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,即"待支付"),微信支付分会**自动轮询扣款直到成功**,不存在"超时自动关单"。如需加速:引导用户在「微信 → 我 → 钱包 → 支付分 → 待支付」主动支付(充值后会立刻重试);如需中止扣款:商户主动调「同步订单状态」同步为 DONE 或 CLOSED(见 #6)。注意:「30 天自动失效」仅适用于 `state=CREATED` 且 30 天未变动的场景,**不适用于"待支付"** |
|
||||
| 5 | 订单创建后几秒钟就被关闭了 | **不是**"扣款失败自动关单"——按 #4,进入"待支付"后只会重试不会关闭。"刚下单就关"通常是其他原因:① 用户在订单确认页主动拒绝或关闭;② 风控综合评估未通过,订单还没进入扣款链路就被拦截;③ 商户业务系统自身调了「取消支付分订单」(如超时兜底逻辑误触)。排查路径:调「查询订单」看 `state` + `cancel_time` + `reason` 字段,逐一对照 |
|
||||
| 6 | 怎么主动让一笔扣款不再继续? | 调用「同步订单状态」把订单同步为 DONE 或 CLOSED,避免重复扣款 |
|
||||
|
||||
### 3.2 取消订单与状态流转
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 已经登记扣款但用户还没支付,能取消吗? | 看订单当前状态:`state=CREATED`(已创建未确认)→ ✅ 可取消;`state=DOING` 且 `state_description=USER_CONFIRM`(用户已确认但商户**未调完结**)→ ✅ 可取消;`state=DOING` 且 `state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,**商户已调完结**、扣款进行中)→ ❌ 不能取消,改走「修改金额」或等待支付结果回调 |
|
||||
| 2 | 哪些状态不能取消? | `DOING + MCH_COMPLETE`(已进入扣款流程,只能改金额或等回调)、`DONE`(已扣款完成)、`REVOKED`(已取消)、`EXPIRED`(已失效,CREATED 30 天未变动)四种状态不可取消;强行调用会得到 `271316592 当前订单状态不满足撤销条件` |
|
||||
| 3 | 同步订单状态返回"收款结果不明,请稍后重试" | 微信端扣款仍在进行中。等几分钟后查"查询订单"看 `collection.state`,若已 `USER_PAID` 即为成功 |
|
||||
| 4 | 怎么准确判断订单是否已扣款成功? | 必须看「查询订单」返回的三元组:`state` + `state_description` + `collection.state`。`DONE + USER_PAID` 才是真正完成 |
|
||||
| 5 | 订单详情页显示"已取消"具体什么意思? | 服务订单已被商户或系统取消(CLOSED)。`state_description` 会带具体取消原因,可结合"查询订单"返回的 `cancel_time` / `reason` 判断 |
|
||||
|
||||
### 3.3 风控 / 综合评估
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | "综合评估未通过"的真实含义? | 平台风控拦截。受多因素影响:用户进行中 / 待支付订单数量、信用记录、实名稳定性、商户传入的 `risk_fund.amount` 等。商户**无法获知具体原因**,也无法直接干预 |
|
||||
| 2 | 用户分数 / 评估结果商户能拿到吗? | **不能**。商户既拿不到具体支付分分数,也收不到"分数过低"的标识,这是支付分小程序底层逻辑 |
|
||||
| 3 | 用户拉起小程序提示"评估不通过" | 正常风控拦截。建议引导文案:「暂不支持先用后付,请选择其他付款方式」,并自动降级押金 / 普通预付流程 |
|
||||
| 4 | 用户拉起报"校验用户登录态失败,请稍后重试" | 用户微信登录态过期或异常。引导用户重启微信或退出重进后再试 |
|
||||
| 5 | 用户用其他渠道能用,但用我们渠道报"综合评估未通过" | 平台风控会结合**该商户场景**评估。建议用户保持良好的实名认证和消费行为再重试;商户侧可适当下调 `risk_fund.amount` |
|
||||
| 6 | 对恶意不支付用户能制约吗? | 没有商户侧手段。但平台会通过用户实名身份做关联:用户换微信号但实名相同时,平台会限制新微信号也无法使用支付分 |
|
||||
|
||||
### 3.4 风险金 / 金额规则
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 创单报"创建订单的订单风险金额超过此服务的服务风险金额" | `risk_fund.amount` 超过该 service_id 的风险金额上限。联系微信支付行业运营确认上限并适配;或临时下调 `risk_fund.amount` |
|
||||
| 2 | 完结报"总金额超过此服务的服务风险金额" | 完结金额超过 service_id 准入的服务风险金额。引导用户在支付分待支付列表里**主动拉起支付**,或线下补足差额 |
|
||||
| 3 | 用户消费金额超过风险金,订单收不到钱怎么办? | 联系用户线下补差,或引导用户主动通过支付分待支付列表完成支付 |
|
||||
| 4 | `post_payments[].amount` 与"付费金额"映射失败 | `post_payments` 内字段类型必须严格按文档:`amount` 为 64 位无符号整数(单位分),不能为浮点 / 负数 / 字符串。逐字段对齐文档后重试 |
|
||||
| 5 | `post_payments` 字段哪些必传? | 参考行业 [post_payments 字段传参说明](https://pay.weixin.qq.com/doc/v3/merchant/4012587259.md),不同行业要求不同;不能仅传 `amount` 不传业务字段,否则订单详情页商品信息不显示 |
|
||||
|
||||
### 3.5 拉起小程序 / APP / 鸿蒙
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 调起小程序报"商户请求错误" / "签名校验失败" | 拉起 sign 必须用**商户 APIv2 密钥**(HMAC-SHA256 / MD5),不能用 APIv3 密钥 |
|
||||
| 2 | 调起报"4108 商户请求错误" | 创单时使用的 `appid` 必须与拉起场景(小程序 / APP / 公众号)的 appid 一致 |
|
||||
| 3 | 鸿蒙端拉起报"第三方应用信息校验失败,bundleId 错误" | 微信开放平台后台配置的鸿蒙应用 Bundle ID / App Identifier 必须与项目实际签名信息匹配。Bundle ID 来自项目 `AppScope/app.json5` 的 `bundleName`;App Identifier 通过 `bundleManager.getBundleInfoForSelf` 获取 |
|
||||
| 4 | 用户在小程序拉起前,商户能预判用户能否使用支付分吗? | **不能**。商户无法获取分数,也无法预判风控结果。前端建议直接拉起 `wx.openBusinessView`,根据返回结果决定后续流程 |
|
||||
|
||||
### 3.6 解约 / 解除授权
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 用户能自行解除授权吗? | 可以。用户可通过「微信 → 我 → 服务 → 钱包 → 支付分 → 我的服务」主动解除授权。商户应通过「查询用户授权记录」接口同步状态 |
|
||||
| 2 | 用户解除授权后还能扣款吗? | 已存在的服务中订单仍可正常扣款;新订单创建会返回 `NO_AUTH`,需用户重新授权 |
|
||||
| 3 | 怎么主动检查用户是否已授权? | 调用「查询用户授权记录」接口,按 `authorization_code` / `openid` 查询当前授权状态 |
|
||||
|
||||
### 3.7 回调与查单
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 怎么确认微信发了回调? | 微信不提供回调日志查询。检查自己服务器的访问日志 + 调用「查询订单」接口确认 |
|
||||
| 2 | 创单到支付成功回调用了几十秒,正常吗? | 数秒到数十秒均正常,不算异常 |
|
||||
| 3 | 完结后没收到回调,但用户已被扣款 | 回调可能在你日志保留期之外。建议用「查询订单」接口确认 `collection.state` 是否 `USER_PAID`,回调与查单做双保险 |
|
||||
| 4 | 待支付订单为什么收不到支付成功回调? | 待支付订单还没扣款成功,自然不会有支付成功回调,符合预期。看 `collection.state`:`WAIT_PAY` → 等待用户主动支付;`USER_PAYING` → 扣款重试中 |
|
||||
|
||||
### 3.8 证书与公钥模式
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 我们用的平台证书,支付分文档说要用微信支付公钥,必须改吗? | **不用改**。平台证书模式与微信支付公钥模式商户可任选其一,都支持 |
|
||||
| 2 | 用微信支付公钥模式上线,会有问题吗? | 不会。微信支付公钥模式无需担心证书过期问题;若后续切换至平台证书模式,需在证书到期前完成更新 |
|
||||
| 3 | 切换流程参考? | [如何从微信支付公钥切换成平台证书指引](https://pay.weixin.qq.com/doc/v3/merchant/4015419357) |
|
||||
|
||||
---
|
||||
|
||||
## 排障信息收集清单
|
||||
|
||||
两条路径都未命中时,请用户提供:接入模式、出错环节(创单 / 完结 / 修改金额 / 同步 / 退款 / 回调 / 对账)、HTTP 状态码 + 完整响应体、Request-Id(含尾段错误码)、商户号 mchid(如涉及 service_id 请一并提供)+ 业务单号 `out_order_no` + 请求时间。
|
||||
@@ -0,0 +1,79 @@
|
||||
# 服务商模式产品介绍
|
||||
|
||||
> 来源:[服务商产品介绍](https://pay.weixin.qq.com/doc/v3/partner/4012586132.md) + [开发指引](https://pay.weixin.qq.com/doc/v3/partner/4012586134.md) + [权限申请](https://pay.weixin.qq.com/doc/v3/partner/4012586133.md)
|
||||
|
||||
## 一、产品概览
|
||||
|
||||
微信支付分是基于用户身份特质、支付行为、使用历史等综合计算的信用分值。
|
||||
|
||||
**服务商模式**:具备技术开发能力的第三方平台,通过进件方式让不具备开发能力的商户成为其子商户,由服务商代调接口为子商户创建支付分订单。订单资金默认全部结算到子商户账户,服务商可通过 [分账](https://pay.weixin.qq.com/doc/v3/partner/4012072582.md) 对订单资金进行分配结算。
|
||||
|
||||
服务商接入支付分可以:
|
||||
|
||||
- 一套技术能力赋能多个子商户,统一开发与运维
|
||||
- 子商户无需自研技术,快速上线先免/先享场景
|
||||
- 通过分账模式抽取技术服务费、平台分成等
|
||||
- 与已有进件/分账/账单等服务商能力天然兼容
|
||||
|
||||
## 二、需确认订单模式(先免 / 先享)
|
||||
|
||||
| 子模式 | 说明 | 评估通过 | 评估不通过 |
|
||||
| ---- | ---- | -------- | -------- |
|
||||
| **先免模式** | 用户先免风险金(押金/预付款/保证金)享受服务,服务结束按实际费用扣款 | 免风险金使用服务 | 需缴纳风险金,服务结束后退还剩余 |
|
||||
| **先享模式** | 用户先享受服务后支付(按预估金额评估) | 先享后付 | 无法使用服务 |
|
||||
|
||||
> ‼️ 子模式由微信支付行业运营在权限审批时与服务商共同确定,并落到 `service_id` 上。同一 `service_id` 内的所有订单遵循统一子模式,创单参数 `risk_fund.name` 取值随之约束。
|
||||
|
||||
## 三、典型使用场景
|
||||
|
||||
| 场景 | 适用子商户业务 | 服务商常见做法 |
|
||||
| ---- | -------------- | -------------- |
|
||||
| **免押租借** | 共享充电宝、共享雨伞 | 服务商集中接入 SaaS 化提供给运营商 |
|
||||
| **先享后付** | 电商、网约车、寄快递、电动车充电、酒店等 | 服务商搭建中台对接多家商户 |
|
||||
| **智慧零售** | 无人售货机柜 | 服务商提供机柜云控 + 支付分能力 |
|
||||
| **停车** | 智慧停车 | 配合 [微信支付分停车服务](https://pay.weixin.qq.com/doc/v3/partner/4012077223.md) 接入 |
|
||||
|
||||
## 四、与商户模式的核心差异
|
||||
|
||||
| 项目 | 商户模式 | 服务商模式 |
|
||||
| ---- | -------- | ---------- |
|
||||
| 平台 | [商户平台](https://pay.weixin.qq.com/) | [服务商平台](https://pay.weixin.qq.com/index.php/partner/public/home) |
|
||||
| API 路径前缀 | `/v3/payscore/` | `/v3/payscore/partner/` |
|
||||
| 创单接口标识参数 | `appid` + `mchid` | `sp_appid` + `sp_mchid` + `sub_mchid` + `sub_appid`(可选) |
|
||||
| 服务 ID 绑定 | 商户号 + appid 组合 | 服务商号 + 子商户号 + appid + sub_appid 组合 |
|
||||
| 资金归属 | 商户账户 | 子商户账户(服务商可通过分账分配) |
|
||||
| 子商户授权 | 不涉及 | 子商户须在商户平台「我的授权产品」点击"授权" |
|
||||
|
||||
## 五、接入前提
|
||||
|
||||
### 5.1 资质与权限
|
||||
|
||||
1. **服务商资质**:服务商主体合规,已具备特约商户进件能力
|
||||
2. **子商户资质**:与商户模式一致——营业执照经营类目需匹配业务场景
|
||||
3. **客服电话认证**:子商户客服电话需经过微信支付认证
|
||||
4. **服务质量达标**:服务商及其子商户主体无违规行为
|
||||
5. **权限申请**:发送邮件至 `weixinpay_scoreBD@tencent.com`,邮件标题/正文模板见 [权限申请文档](https://pay.weixin.qq.com/doc/v3/partner/4012586133.md)
|
||||
6. **签署协议**:登录服务商平台 → 产品中心 → 支付拓展工具 → 微信支付分 → 开通申请 + 签署协议
|
||||
7. **获取服务 ID**:行业运营在对接群提供 `service_id`
|
||||
|
||||
### 5.2 子商户授权流程(服务商专属)
|
||||
|
||||
支付分对子商户的接口调用需要"双向授权":
|
||||
|
||||
1. **服务商侧**:邮件申请绑定成功后,登录服务商平台 → 【产品中心 → 特约商户授权产品 → 服务商微信支付分 → 特约商户列表】找到子商户 → 点击"申请"
|
||||
2. **子商户侧**:登录商户平台 → 【产品中心 → 我的授权产品】找到"服务商微信支付分" → 点击"授权"
|
||||
|
||||
> ‼️ 任一步未做都会触发 `NO_AUTH 请检查sub_mchid是否授权mchid微信支付分产品权限`。
|
||||
|
||||
> ‼️ 开发参数清单(含 `sp_mchid` / `sp_appid` / `sub_mchid` / `sub_appid` / `service_id`、服务商 **APIv2 密钥**、APIv3 密钥、服务商 API 证书、微信支付公钥)、获取步骤、账户/AppID/服务 ID 的绑定关系自查清单与踩坑提示统一收拢到 [开发参数与业务规则](../接入指南/开发参数与业务规则.md),本页不再重复列出。
|
||||
|
||||
### 5.3 测试白名单
|
||||
|
||||
服务上线前,**仅服务商平台白名单中的微信号用户可使用**,配置入口:服务商平台 → 微信支付分 → 测试号配置(service_id 上线后入口隐藏)。
|
||||
|
||||
### 5.4 接入产物
|
||||
|
||||
1. 服务商代子商户创建支付分订单(`POST /v3/payscore/partner/serviceorder`)→ 拉起 JSAPI/APP/小程序确认页 → 接收"用户确认订单回调"
|
||||
2. 服务结束后调用"完结订单"接口,由微信支付分自动轮询扣款,资金结算到子商户账户 → 接收"支付成功回调"
|
||||
3. 异常处理:取消订单 / 修改金额 / 同步状态 / 申请退款 / 查询退款 / 退款回调
|
||||
4. 资金分配:完结订单时传 `profit_sharing=true`,扣款成功后调用"请求分账"
|
||||
141
.codex/skills/wechatpay-payscore/references/2-服务商/接入指南/回调处理.md
Normal file
141
.codex/skills/wechatpay-payscore/references/2-服务商/接入指南/回调处理.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# 服务商模式回调处理
|
||||
|
||||
> 本文档为微信支付**通用回调处理规范**,适用于**商户**、**品牌直连**、**服务商**三种接入模式。三方在**回调报文解密、IP 白名单、应答要求、幂等、收不到回调排查**上完全一致;仅在 **`notify_url` 配置方式**和**回调归属维度**上有差异,差异点已在文中以"模式分支"标注。
|
||||
>
|
||||
> 各业务(如商品券、营销立减金、基础支付等)的**事件类型清单、解密后业务字段、二次确认接口路径**等业务专属内容,由各业务自身的接口文档提供,不在本通用文档范围内。
|
||||
|
||||
## 一、回调处理
|
||||
|
||||
### 前提条件
|
||||
|
||||
1. **必须设置 APIv3 密钥**(32 字节),未设置不会收到任何回调
|
||||
2. **必须配置 `notify_url`**,按接入模式分支处理:
|
||||
- **商户模式**:在下单/业务请求体里直接传入 `notify_url` 字段(如 JSAPI 下单),或在商户平台「产品中心 → 开发配置」中预设
|
||||
- **品牌直连**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /brand/marketing/{业务}/notify-config`),品牌维度
|
||||
- **服务商模式**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /v3/marketing/{业务}/notify-config`),服务商维度,所有子商户/品牌共用同一地址
|
||||
3. 回调地址要求:HTTPS + 域名已 ICP 备案 + 公网可访问
|
||||
4. 不能使用内网地址(127.0.0.1 / 192.168.x.x / localhost)
|
||||
|
||||
### 回调 IP 白名单
|
||||
|
||||
商户侧对微信支付回调 IP 有防火墙策略限制的,需要对以下 IP 段开通白名单:
|
||||
|
||||
|
||||
| 出口 | 网段/IP |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| 上海电信出口网段 | 101.226.103.0/25 |
|
||||
| 上海联通出口网段 | 140.207.54.0/25 |
|
||||
| 上海CAP出口网段 | 121.51.58.128/25 |
|
||||
| 深圳电信出口网段 | 183.3.234.0/25 |
|
||||
| 深圳联通出口网段 | 58.251.80.0/25 |
|
||||
| 深圳CAP出口网段 | 121.51.30.128/25 |
|
||||
| 香港出口网段 | 203.205.219.128/25 |
|
||||
| 广州腾讯云出口IP | 81.71.199.64, 81.71.198.25, 81.71.199.59 |
|
||||
| 退款结果通知、分账动账通知IP | 175.24.214.208, 175.24.211.24, 175.24.213.135, 109.244.180.23, 114.132.203.119, 43.139.43.69 |
|
||||
|
||||
|
||||
同时关闭 WAF/CC 防护对回调 URL 的拦截,避免误将微信支付回调请求判定为恶意请求。
|
||||
|
||||
### 回调报文与解密
|
||||
|
||||
回调通知整体结构(三种接入模式完全一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "通知唯一ID",
|
||||
"create_time": "2025-08-02T00:00:00+08:00",
|
||||
"event_type": "事件类型,由具体业务定义",
|
||||
"resource_type": "encrypt-resource",
|
||||
"resource": { /* 加密资源对象,结构见下 */ }
|
||||
}
|
||||
```
|
||||
|
||||
`resource` 字段为加密资源对象,三种接入模式完全一致(参考官方文档:[商户](https://pay.weixin.qq.com/doc/v3/merchant/4012071382) / [品牌](https://pay.weixin.qq.com/doc/brand/4015407591) / 服务商):
|
||||
|
||||
```json
|
||||
{
|
||||
"original_type": "transaction",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "...",
|
||||
"nonce": "...",
|
||||
"associated_data": ""
|
||||
}
|
||||
```
|
||||
|
||||
- 算法:`AEAD_AES_256_GCM`,密钥:**APIv3 密钥(32 字节)—— 商户、品牌、服务商三方完全相同**
|
||||
- 从 `resource` 取 `nonce`、`ciphertext`、`associated_data` 进行解密
|
||||
- ‼️ 加密报文中的 `nonce` 与请求签名串中的随机串**没有任何关系**,是两个独立的值
|
||||
|
||||
### 回调处理要求
|
||||
|
||||
1. **必须返回 HTTP 2XX**(200 或 204),否则微信支付会重试
|
||||
2. **必须在 5 秒内应答**
|
||||
3. **必须做幂等处理**(按业务唯一标识 + `event_type` 去重)
|
||||
4. **必须验签**,防止伪造通知。验签密钥支持两种,**与接入模式无关**,取决于商户/服务商在平台的密钥配置:
|
||||
- **微信支付公钥**(推荐,2024 年后新增,公钥 ID 形如 `PUB_KEY_ID_xxxxxx`)
|
||||
- **微信支付平台证书**(旧方式,需定期下载更新,仍可继续使用)
|
||||
- 任意一种接入模式(商户 / 品牌 / 服务商)均可自由选择上述任一种验签方式
|
||||
5. 签名探测流量以 `WECHATPAY/SIGNTEST/` 开头,需正确处理
|
||||
6. 即使业务处理异常,也建议返回 200,通过告警系统人工介入
|
||||
|
||||
### 回调收不到的常见排查场景
|
||||
|
||||
#### 一、前置配置缺失
|
||||
|
||||
1. **未设置 APIv3 密钥** — 微信支付不会发送回调通知
|
||||
2. **未配置 `notify_url`** — 接口或商户平台未配置回调通知接收地址,微信支付不会发送回调通知
|
||||
|
||||
#### 二、回调地址配置类问题
|
||||
|
||||
1. **地址格式错误**
|
||||
- `notify_url` 未以 `https://` 或 `http://` 开头
|
||||
- URL 中只有域名,缺少具体路径(如 `http://www.weixin.qq.com`)
|
||||
- URL 携带了参数
|
||||
- 使用了内网地址(`127.0.0.1`、`192.168.x.x`、`localhost`)
|
||||
2. **域名未备案或解析异常**
|
||||
- 域名未完成工信部 ICP 备案(国内服务器必须)
|
||||
- DNS 解析失效(解析记录过期、未配置正确的 A/AAAA 记录)
|
||||
|
||||
#### 三、网络与服务器连通性问题
|
||||
|
||||
1. **防火墙/安全组拦截** — 未对上方「回调 IP 白名单」中的 IP 段开通入站规则
|
||||
2. **WAF/CC 防护误拦** — 安全策略将微信支付回调请求误判为恶意请求
|
||||
3. **网络链路故障** — 丢包或延迟过高(超过 3 秒)导致请求超时
|
||||
4. **CDN/反向代理配置异常** — Nginx、Cloudflare 等未将回调请求正确转发至后端服务
|
||||
|
||||
#### 四、回调处理逻辑问题
|
||||
|
||||
1. **登录态校验** — `notify_url` 的代码逻辑不能做登录态校验
|
||||
2. **未在 5 秒内应答** — 微信支付会认为通知失败并重复发送
|
||||
3. **未做幂等** — 同一通知可能多次发送,必须按业务唯一标识 + `event_type` 去重
|
||||
|
||||
### 各模式回调归属说明
|
||||
|
||||
| 模式 | 归属维度 | 区分多主体的关键字段 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| **商户** | 商户维度 | 无(回调本就属于该商户) | 一个商户一个回调地址 |
|
||||
| **品牌直连** | 品牌维度 | `brand_id` | 一个品牌一个回调地址 |
|
||||
| **服务商** | 服务商维度 | `sub_mchid` / `brand_id` | 所有子商户/品牌共用同一回调地址,**必须按字段路由到正确的子主体**,否则会出现"A 商户的订单被 B 商户业务处理"的串单事故 |
|
||||
|
||||
## 二、错误处理策略
|
||||
|
||||
|
||||
| 错误类型 | 处理策略 |
|
||||
| ---------------- | ---------------------------- |
|
||||
| 500 SYSTEM_ERROR | 使用相同请求号重试(指数退避) |
|
||||
| 400 参数错误 | 修正参数后重试 |
|
||||
| 401 签名错误 | 检查验签密钥(公钥 / 平台证书)是否与平台配置一致;服务商还需检查请求头 `Wechatpay-Serial` 是否携带正确的证书/公钥序列号 |
|
||||
| 回调超时 | 返回 200,异步补偿处理 |
|
||||
| 解密失败 | 检查 APIv3 密钥是否正确(32 字节、与商户/服务商平台配置一致)|
|
||||
|
||||
|
||||
## 三、幂等设计
|
||||
|
||||
- 所有写操作必须使用业务侧生成的唯一请求号(如 `out_trade_no`、`out_request_no`、各业务自定义的请求号)
|
||||
- 相同请求号重复请求不会创建重复资源
|
||||
- 建议格式:`{业务前缀}_{日期}_{序号}`,例如 `pay_20250801_000001`
|
||||
|
||||
## 四、请求域名
|
||||
|
||||
- 主域名: `https://api.mch.weixin.qq.com`
|
||||
- 备域名: `https://api2.mch.weixin.qq.com`
|
||||
@@ -0,0 +1,236 @@
|
||||
# 服务商模式开发参数与业务规则
|
||||
|
||||
> 来源:[服务商开发指引](https://pay.weixin.qq.com/doc/v3/partner/4012586134.md) + [权限申请](https://pay.weixin.qq.com/doc/v3/partner/4012586133.md) + [服务商模式开发必要参数说明](https://pay.weixin.qq.com/doc/v3/partner/4013080340.md) + 各 API 文档
|
||||
|
||||
本文档覆盖服务商接入本产品**前**需要准备的全部参数与产品特有的字段传参规范。业务全链路流程随各 API 示例代码注释展示,错误处置见 [📄 排障手册.md](../问题排查/排障手册.md)。
|
||||
|
||||
---
|
||||
|
||||
## 一、参数清单
|
||||
|
||||
| 参数 | 类型 | 用途 | 必备性 |
|
||||
| ---- | ---- | ---- | ------ |
|
||||
| `sp_mchid` | string | 服务商商户号(请求体 / 签名头中的 `mchid`) | 必备 |
|
||||
| `sp_appid` | string | 服务商应用 ID(公众号 / 小程序 / 移动应用),必须与 sp_mchid 绑定 | 必备 |
|
||||
| `sub_mchid` | string | 子商户号(特约商户号) | 必备 |
|
||||
| `sub_appid` | string | 子商户应用 ID(与 sub_mchid 绑定) | 可选 |
|
||||
| `service_id` | string | 支付分服务 ID | 必备 |
|
||||
| **APIv2 密钥** | string(32) | ‼️ 调起支付分小程序的前端 sign 用 **服务商 APIv2 密钥** | 必备 |
|
||||
| **APIv3 密钥** | string(32) | 解密回调通知密文(**服务商配置**) | 必备 |
|
||||
| 服务商 API 证书 | PEM 文件 | APIv3 接口请求签名(**服务商私钥,不是子商户私钥**) | 必备 |
|
||||
| 服务商 API 证书序列号 | string | Authorization 头 `serial_no` 字段 | 必备 |
|
||||
| 微信支付公钥 + 公钥 ID | PEM + string | APIv3 响应/回调验签(推荐) | 二选一 |
|
||||
| 微信支付平台证书 + 证书序列号 | PEM + string | APIv3 响应/回调验签(旧式,需定期下载) | 二选一 |
|
||||
| 通知回调地址 `notify_url` | URL | 接收回调,所有子商户共用一个服务商维度地址 | 必备 |
|
||||
|
||||
> ‼️ 三个常见踩坑:
|
||||
>
|
||||
> 1. **APIv2 密钥与 APIv3 密钥是两个独立密钥**,必须分别设置。支付分调起小程序专门依赖 APIv2,缺了会报"商户签名校验失败"。
|
||||
> 2. **APIv3 接口签名用服务商证书,不是子商户证书**。错用子商户证书签名会触发 401 SIGN_ERROR。
|
||||
> 3. **请求体中的 `mchid` 是服务商号 `sp_mchid`,子商户号通过 `sub_mchid` 字段单独传**。
|
||||
|
||||
## 二、获取步骤
|
||||
|
||||
### 2.1 sp_mchid(服务商商户号)
|
||||
|
||||
1. 登录 [服务商平台](https://pay.weixin.qq.com/index.php/partner/public/home)
|
||||
2. 进入「账户中心 → 商户信息」即可看到 `sp_mchid`(10 位数字),也可在右上角点击商户简称下拉查看
|
||||
3. ⚠️ 右上角直接显示的是「商户简称」(中文昵称),**不是** `sp_mchid`,请勿混用;签名头里的 `mchid` 应填该 10 位服务商商户号
|
||||
|
||||
### 2.2 sp_appid(服务商应用 ID)
|
||||
|
||||
| 应用类型 | 申请入口 |
|
||||
| -------- | -------- |
|
||||
| 公众号 / 小程序 | [微信公众平台](https://mp.weixin.qq.com/) |
|
||||
| 移动应用 | [微信开放平台](https://open.weixin.qq.com/) |
|
||||
|
||||
申请到后在服务商平台「产品中心 → AppID 账号管理」与 sp_mchid 绑定。
|
||||
|
||||
### 2.3 sub_mchid(子商户号)
|
||||
|
||||
子商户号有两种获取方式:
|
||||
|
||||
- **进件 API**:服务商通过 [特约商户进件 API](https://pay.weixin.qq.com/doc/v3/partner/4012761122.md) 提交资料 → 微信支付审核 → 返回 sub_mchid
|
||||
- **服务商平台手工添加**:服务商平台 → 商户管理 → 特约商户管理
|
||||
|
||||
### 2.4 sub_appid(子商户应用 ID,可选)
|
||||
|
||||
如果子商户使用自有 appid 发起支付分(而非走服务商 appid 透传),需要:
|
||||
|
||||
1. 子商户在公众平台 / 开放平台申请 appid
|
||||
2. 在商户平台将 sub_appid 与 sub_mchid 绑定(详见 [管理商户号绑定的 APPID](https://pay.weixin.qq.com/doc/v3/partner/4016329059.md))
|
||||
|
||||
> ‼️ sp_appid、sub_appid 必须与申请 service_id 时报备的组合一致。新增 appid / sub_appid 都需联系运营做 service_id 增量绑定。
|
||||
|
||||
### 2.5 service_id 与子商户授权
|
||||
|
||||
支付分权限审核通过后,由微信支付行业运营提供 `service_id`。**接口调用前必须完成"双向授权"**:
|
||||
|
||||
1. **服务商侧**:服务商平台 → 【产品中心 → 特约商户授权产品 → 服务商微信支付分 → 特约商户列表】→ 找到子商户号 → 点击"申请"
|
||||
2. **子商户侧**:子商户登录商户平台 → 【产品中心 → 我的授权产品】→ 找到"服务商微信支付分" → 点击"授权"
|
||||
|
||||
> 任一步未做都会触发 `NO_AUTH 请检查sub_mchid是否授权mchid微信支付分产品权限`。
|
||||
|
||||
### 2.6 APIv2 密钥(服务商,32 字节)
|
||||
|
||||
登录服务商平台 → 账户中心 → API 安全 → 设置 APIv2 密钥。**支付分调起小程序前端签名强依赖此密钥,必须设置**。
|
||||
|
||||
### 2.7 APIv3 密钥(服务商,32 字节)
|
||||
|
||||
1. 登录服务商平台 → 账户中心 → API 安全 → 设置 APIv3 密钥
|
||||
2. 详细步骤:[APIv3 密钥设置方法](https://pay.weixin.qq.com/doc/v3/partner/4012081991)
|
||||
|
||||
### 2.8 服务商 API 证书
|
||||
|
||||
1. 登录服务商平台 → 账户中心 → API 安全 → API 证书 → 申请并下载
|
||||
2. 详细步骤:[服务商 API 证书获取方法](https://pay.weixin.qq.com/doc/v3/partner/4012081992)
|
||||
3. 下载后得到 `apiclient_cert.pem`(公钥证书)+ `apiclient_key.pem`(私钥)+ 证书序列号
|
||||
|
||||
### 2.9 微信支付公钥(推荐)
|
||||
|
||||
1. 登录服务商平台 → 账户中心 → API 安全 → 微信支付公钥
|
||||
2. 下载后得到公钥文件(PEM)+ 公钥 ID(形如 `PUB_KEY_ID_xxxxxxxx`)
|
||||
|
||||
### 2.10 安全联系人(强烈建议)
|
||||
|
||||
服务商超级管理员设置技术同事为安全联系人,以便接收微信支付的技术风险提醒。详见 [安全联系人设置指引](https://pay.weixin.qq.com/doc/v3/partner/4012083124.md)。
|
||||
|
||||
### 2.11 notify_url 配置
|
||||
|
||||
服务商模式 `notify_url` **是服务商维度**,所有子商户的回调都进入同一个 URL。配置方式:
|
||||
|
||||
1. **创单接口直接传入 `notify_url` 字段**(推荐)
|
||||
2. **服务商平台预设回调地址**:产品中心 → 开发配置 → 服务通知
|
||||
|
||||
要求同商户模式:HTTPS、已备案、公网可达、不能携带 query 参数。
|
||||
|
||||
> ‼️ 服务商必须按 `sub_mchid` / `sub_appid` 路由到对应子商户业务,否则会出现"A 子商户的订单被 B 子商户业务处理"的串单事故。
|
||||
|
||||
### 2.12 测试白名单
|
||||
|
||||
服务上线前,**仅服务商平台白名单中的微信号用户可使用**。配置入口:服务商平台 → 微信支付分 → 测试号配置(service_id 上线后入口隐藏)。
|
||||
|
||||
## 三、绑定关系自查清单
|
||||
|
||||
任一项不满足都会触发 NO_AUTH 报错:
|
||||
|
||||
- [ ] sp_mchid 已开通服务商微信支付分产品权限
|
||||
- [ ] sp_appid 已与 sp_mchid 绑定
|
||||
- [ ] sub_mchid 与 sp_mchid 存在父子关系(进件后自动建立)
|
||||
- [ ] sub_appid(如使用)已与 sub_mchid 绑定
|
||||
- [ ] service_id 下已绑定 `sp_mchid + sub_mchid + sp_appid + sub_appid` 组合
|
||||
- [ ] 服务商已在【特约商户授权产品】对该 sub_mchid 申请支付分
|
||||
- [ ] 子商户已在【我的授权产品】点击"授权"
|
||||
- [ ] APIv2 密钥已设置(服务商)
|
||||
- [ ] APIv3 密钥已设置(服务商)
|
||||
- [ ] 服务商 API 证书已下载并保存证书序列号
|
||||
- [ ] 微信支付公钥(或平台证书)已下载
|
||||
- [ ] notify_url 已配置且公网可达
|
||||
- [ ] 测试微信号已加入白名单
|
||||
|
||||
---
|
||||
|
||||
## 四、订单状态流转
|
||||
|
||||
| state | state_description | collection.state | 含义 | 可执行操作 |
|
||||
| ----- | ----------------- | ---------------- | ---- | ---------- |
|
||||
| CREATED | - | - | 已创建,等待用户确认 | 取消、查询;30 天未确认自动失效 |
|
||||
| DOING | USER_CONFIRM | - | 用户已确认,服务进行中 | 完结、取消、查询、修改金额 |
|
||||
| DOING | MCH_COMPLETE | USER_PAYING | 商户已完结,用户待支付 | 修改金额、取消、同步、查询 |
|
||||
| DONE | - | - | 订单完成(终态) | 退款、查询 |
|
||||
| REVOKED | - | - | 商户取消订单(终态) | 查询 |
|
||||
| EXPIRED | - | - | 订单失效(终态) | 查询 |
|
||||
|
||||
> ‼️ 状态判断必须 `state` + `state_description` + `collection.state` **三者结合**,不能只看 `state`。
|
||||
|
||||
## 五、关键字段传参规范
|
||||
|
||||
### 5.1 创单 `risk_fund` 字段(受订单子模式约束)
|
||||
|
||||
| 子模式 | `risk_fund.name` 取值 | `risk_fund.amount` 上限 | 说明 |
|
||||
| ------ | --------------------- | ----------------------- | ---- |
|
||||
| **先免** | `DEPOSIT` / `ADVANCE` / `CASH_DEPOSIT` | ≤ service_id 风险金额上限 | 用户先免风险金享受服务 |
|
||||
| **先享** | `ESTIMATE_ORDER_COST` | ≤ service_id 风险金额上限 | 用户先享受服务后支付 |
|
||||
|
||||
> 子模式由微信支付行业运营在权限审批时与服务商共同确定,并落到 `service_id` 上。同一 `service_id` 内的所有订单遵循统一子模式。
|
||||
|
||||
### 5.2 完结金额公式(必校验)
|
||||
|
||||
完结接口 `total_amount` 必须严格满足:
|
||||
|
||||
```
|
||||
total_amount = Σpost_payments.amount - Σpost_discounts.amount
|
||||
```
|
||||
|
||||
不成立返回 `PARAM_ERROR 最终总金额计算非法`。
|
||||
|
||||
**金额硬约束(按子模式)**:
|
||||
|
||||
- 先免:`total_amount ≤ 创单的 risk_fund.amount`
|
||||
- 先享:`total_amount ≤ service_id 风险金额上限`
|
||||
|
||||
### 5.3 状态前提(完结 / 取消 / 修改)
|
||||
|
||||
| 操作 | 允许的状态 | 错误时报错 |
|
||||
| ---- | ---------- | ---------- |
|
||||
| 完结 | `state=DOING` 且 `state_description ∈ {USER_CONFIRM, MCH_COMPLETE}` | `INVALID_REQUEST 当前订单状态不合法` |
|
||||
| 修改金额 | `state=DOING` 且 `state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,仅可下调) | `INVALID_REQUEST 当前订单状态不合法` |
|
||||
| 取消 | `state=CREATED`,或 `state=DOING` 且 `state_description=USER_CONFIRM`(即用户已确认、子商户尚未完结) | `271316592 当前订单状态不满足撤销条件` |
|
||||
|
||||
> ‼️ 取消的边界要点:子商户一旦调过「完结订单」(订单进入 `DOING + MCH_COMPLETE`、`collection.state=USER_PAYING`,扣款已在进行)就**不能再取消**,应改走「修改金额」或等待支付结果回调;`DONE` / `REVOKED` / `EXPIRED` 终态同样不可取消。所有状态查询与取消调用必须带正确的 `sub_mchid`,否则会被前置的鉴权 / 路由错误拦截。
|
||||
|
||||
### 5.4 退款必须用 `transaction_id`
|
||||
|
||||
服务商发起退款必须使用**微信支付交易单号 `transaction_id`**,**不能用 `out_order_no`**,且请求体必须携带 `sub_mchid`。`transaction_id` 来自支付成功回调或查询订单接口的响应。
|
||||
|
||||
### 5.5 `profit_sharing` 分账时序
|
||||
|
||||
如需分账:完结订单时传 `profit_sharing=true`,**扣款成功后**才能调用 [请求分账](https://pay.weixin.qq.com/doc/v3/partner/4012691594.md)。完结时未传 `true` 直接调分账会报错;扣款未成功就分账也会失败。
|
||||
|
||||
### 5.6 `post_payments`(后付费项目)行业字段
|
||||
|
||||
服务商版与商户版的 `post_payments` 字段含义相同,但服务商在请求体中按 `sub_mchid` 业务模型回传。各行业传参说明:
|
||||
|
||||
| 行业 | 文档 |
|
||||
| ---- | ---- |
|
||||
| 总览 | [post_payments 字段总览](https://pay.weixin.qq.com/doc/v3/partner/4013163663.md) |
|
||||
| 二轮电动车充电桩 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586150.md) |
|
||||
| 充电宝 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586148.md) |
|
||||
| 共享单车 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586145.md) |
|
||||
| 快递行业 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586144.md) |
|
||||
| 智慧零售(无人设备) | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586146.md) |
|
||||
| 汽车充电桩 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586149.md) |
|
||||
| 汽车租赁 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586151.md) |
|
||||
| 酒店行业 | [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4012586147.md) |
|
||||
|
||||
### 5.7 `device.start_device_id`(无人自助设备必传)
|
||||
|
||||
售货机、充电宝、共享单车、共享雨伞、充电桩等**无人自助设备场景**,创单时必须传 `device.start_device_id`,否则订单详情页无法展示设备信息,影响验收。
|
||||
|
||||
### 5.8 `need_user_confirm`
|
||||
|
||||
需确认订单模式必须传 `true` 或不传(默认 true)。免确认是高级权限,需向运营单独申请。
|
||||
|
||||
### 5.9 `notify_url` 字段
|
||||
|
||||
写法已在 §2.11 说明。补充:所有 sub_mchid 共用同一地址,回调解密后必须按 `sub_mchid` 路由到对应子商户业务。
|
||||
|
||||
## 六、对账
|
||||
|
||||
- 次日 10:00 后通过服务商平台手动下载,或调用 [申请交易账单](https://pay.weixin.qq.com/doc/v3/partner/4013080242.md) + [下载账单](https://pay.weixin.qq.com/doc/v3/partner/4013080230.md)
|
||||
- 账单的「交易类型」与「商户订单号」按支付方式区分:
|
||||
- **主动支付**:交易类型为 `JSAPI`,「商户订单号」即商户创单上送的 `out_order_no`
|
||||
- **自动扣款**:交易类型为 `AUTH`,「商户订单号」由**微信侧生成**(不是 `out_order_no`),原 `out_order_no` 在「商户数据包」字段,格式:`wxzff|微信服务单号|商户服务单号`
|
||||
- 对账落库时按「交易类型」分支取键:`JSAPI` 直接用「商户订单号」;`AUTH` 必须解析「商户数据包」拿到原 `out_order_no` 再关联本地业务单;多 `sub_mchid` 场景仍按 `sub_mchid` 路由到对应子商户
|
||||
|
||||
## 七、上线验收
|
||||
|
||||
服务商用测试微信号完成开发测试后,按以下流程提交资料:
|
||||
|
||||
| 步骤 | 资料 |
|
||||
| ---- | ---- |
|
||||
| 1. 提交资料 | ① 第一人称视角录屏;② 第三方视角拍摄;③ 三个状态截图 |
|
||||
| 2. 提供上线信息 | 服务 ID、服务名称、场景、子商户号、上线计划 |
|
||||
| 3. 上线 | 微信支付侧完成服务配置 |
|
||||
|
||||
UI 必须满足 [支付分合作品牌线上应用规范](https://pay.weixin.qq.com/doc/v3/partner/4012586152.md)。
|
||||
@@ -0,0 +1,83 @@
|
||||
# 服务商模式接入质量检查
|
||||
|
||||
## 角色设定:金融支付系统技术专家
|
||||
|
||||
> ‼️ **本节角色、铁律和问题雷达是质检的全部驱动力,必须内化后再审代码。**
|
||||
|
||||
你是金融支付系统技术专家,全栈工程师出身,亲手写过从前端收银台到后端交易引擎的全链路代码。你主导过千万级用户规模的国民级支付系统架构设计,从零搭建过高并发交易平台。你熟悉主流支付平台的接入规范与安全体系,对 API 签名验签机制、异步回调通知处理、资金流对账有丰富的实战经验。你对代码质量有极强的直觉,尤其对资金链路上的异常处理缺失高度警觉。
|
||||
|
||||
你对支付系统的要求极高:接口交互必须有完善的异常处理和兜底方案,资金操作必须可追溯、可对账,所有外部输入必须经过校验才能进入业务逻辑。
|
||||
|
||||
## 铁律
|
||||
|
||||
**铁律一:高可用(99.9999%)**
|
||||
系统可用性要求 99.9999%(六个 9),即每一百万次请求中最多允许一次失败。支付链路上不允许存在单点故障,每一个外部调用都必须有超时、重试和降级方案。
|
||||
检查直觉:调用微信支付 API 超时了,代码会自动重试还是直接报错?重试的时候会不会导致重复下单?微信的支付回调一直没来,系统有没有定时去主动查询订单状态?用户快速点了两次支付按钮,会不会创建两笔订单?
|
||||
|
||||
**铁律二:资金安全(一分钱都不能错)**
|
||||
金额计算必须使用整数(单位:分),杜绝浮点精度丢失。每一笔资金变动(支付、退款、分账)都必须有据可查,系统必须在次日通过账单对账主动发现差异。
|
||||
检查直觉:金额字段的类型是 int/long 还是 double/float?用户申请退款时,代码有没有累加历史退款金额并校验是否超过订单总额?系统有没有每天自动拉取微信账单和本地订单做比对?
|
||||
|
||||
**铁律三:零信任(不信任任何未经验证的外部数据)**
|
||||
微信的回调通知、前端传入的参数、缓存中的数据,在进入业务逻辑前必须经过验证,未验证的输入一律视为不可信。
|
||||
检查直觉:收到支付回调后,代码是先验签还是直接解析 body 处理业务?下单接口的金额是从后端数据库查的还是直接用前端传过来的值?回调通知中的支付金额有没有和本地订单金额做比对?私钥是通过环境变量加载的还是硬编码在代码里?
|
||||
|
||||
## 检查方法
|
||||
|
||||
1. **扫代码** — 快速扫描代码,按问题雷达定位高风险区域
|
||||
2. **追链路** — 沿资金流完整走一遍:服务商创单(携带 sub_mchid)→ 拉起确认 → 用户确认 → 子商户提供服务 → 完结订单 → 自动扣款(资金到 sub_mchid 账户)→ 分账(可选)→ 退款,任何断点都是事故点
|
||||
3. **做预演** — 对每个关键节点问"如果这里故障了/超时了/被攻击了/来了两次,会怎样?多个 sub_mchid 同时调用时会不会串单?"
|
||||
|
||||
**输出要求**:发现问题必须给出修复方向,不能只说"有风险";必须基于代码事实,不基于猜测;结果按 🔴🟡🟠 分级,致命问题置顶。
|
||||
|
||||
## 问题雷达
|
||||
|
||||
> **来源**:通用安全雷达(固定 4 项)+ 产品专属雷达(**重点从「开发指引」与「常见问题」提炼**,其他文档作为补充)。
|
||||
>
|
||||
> 以下仅列举常见的高风险问题,**不要只检查列出的项**。检查时应反向运用铁律:逐条铁律审视代码,发现未列出的同类问题。
|
||||
|
||||
### 通用安全雷达(所有产品必查)
|
||||
|
||||
> 4 项**独立判定**,每项必须给出"通过 / 未实现 / 不涉及"三选一的明确结论,**禁止合并多项为一条**。具体检查方法见 [签名与验签规则](./签名与验签规则.md)。
|
||||
|
||||
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
|
||||
| --- | ------ | -------- | ---------------- | -------- |
|
||||
| 1 | **HTTP 响应验签** | 发起请求并处理响应的代码(OkHttp `execute()` / HttpClient `send()` 等) | 收到 2XX 响应后直接解析返回数据,中间无任何验签调用 | 🔴 致命 |
|
||||
| 2 | **回调通知验签** | 处理回调通知的代码(含 `event_type` / `resource_type` / `encrypt-resource` 等字段) | 收到通知后**先解密或解析业务数据**,验签缺失或在解密之后 | 🔴 致命 |
|
||||
| 3 | **幂等去重 + 并发锁** | 回调处理流程的入口 | 既无按"业务唯一标识 + `event_type`"的去重查询,也无加锁逻辑(Redis 锁 / 行锁 / `synchronized` 等) | 🔴 致命 |
|
||||
| 4 | **探测流量未做特殊跳过** | 验签代码分支 | 对签名值含 `WECHATPAY/SIGNTEST/` 前缀的请求做了特殊跳过/早返回 | 🟠 可选 |
|
||||
|
||||
### 产品专属雷达(微信支付分 - 服务商)
|
||||
|
||||
> **来源**:[服务商开发指引](https://pay.weixin.qq.com/doc/v3/partner/4012586134.md) + [常见问题](https://pay.weixin.qq.com/doc/v3/partner/4012586139.md) + [权限申请文档](https://pay.weixin.qq.com/doc/v3/partner/4012586133.md)
|
||||
|
||||
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
|
||||
| --- | ---- | ---- | ---- | ---- |
|
||||
| 1 | **回调按 sub_mchid 路由** | 回调处理入口路由逻辑 | 所有子商户共用 notify_url 但回调处理未按 `sub_mchid` 区分到对应子商户业务,存在串单风险 | 🔴 致命 |
|
||||
| 2 | **服务商 API 证书签名(不是子商户)** | 请求签名构造代码 | APIv3 请求签名用了子商户 API 证书,触发 401 SIGN_ERROR | 🔴 致命 |
|
||||
| 3 | **APIv2 与 APIv3 密钥分离** | 调起支付分小程序的前端 sign 生成代码 | 前端 sign 用 APIv3 密钥生成(应是服务商 APIv2 密钥) | 🔴 致命 |
|
||||
| 4 | **请求体含 sub_mchid / sub_appid** | 创单/完结/退款 请求体构造 | 请求体只传 mchid 没传 sub_mchid,或 sub_appid 与 sub_mchid 绑定关系不一致 | 🔴 致命 |
|
||||
| 5 | **回调验签后再解密** | 三类回调入口 | 直接解密 ciphertext,未先验签 | 🔴 致命 |
|
||||
| 6 | **回调三类事件分别幂等** | 回调路由代码 | 没有按 `sub_mchid + out_order_no + event_type` 三层去重 | 🔴 致命 |
|
||||
| 7 | **CREATED 订单定时查询兜底** | 订单查询代码 | 仅依赖回调,没有对超时未确认订单做定时查询 | 🔴 致命 |
|
||||
| 8 | **完结金额合法性预校验** | 完结订单调用前 | 未在调用前校验 `total_amount = Σpost_payments.amount - Σpost_discounts.amount` | 🔴 致命 |
|
||||
| 9 | **风险金上限校验** | 创单调用前 | 未对 `risk_fund.amount` 做"≤ service_id 风险金额上限"的硬校验 | 🟡 推荐 |
|
||||
| 10 | **金额类型为整数** | 所有 amount 字段 | amount 用 `double` / `float` 而非 `int` / `long`,单位不是"分" | 🔴 致命 |
|
||||
| 11 | **订单状态判断三字段联合** | 状态判断逻辑 | 仅判断 `state` 不结合 `state_description` 和 `collection.state` | 🔴 致命 |
|
||||
| 12 | **退款用 transaction_id 且带 sub_mchid** | 退款调用代码 | 用 `out_order_no` 退款,或退款请求体未传 `sub_mchid` | 🔴 致命 |
|
||||
| 13 | **退款金额累加校验** | 退款调用前 | 未累加历史退款金额校验是否超过订单总金额 | 🔴 致命 |
|
||||
| 14 | **拉起 appid 与创单 appid 一致** | 拉起代码与创单参数对照 | 创单 appid(sp_appid 或 sub_appid)与拉起 appid 不一致,触发 4108 | 🔴 致命 |
|
||||
| 15 | **post_payments 严格按行业传参** | 完结/创单 post_payments 字段 | 未按行业 [传参说明](https://pay.weixin.qq.com/doc/v3/partner/4013163663.md),订单详情页不展示,影响验收上线 | 🔴 致命 |
|
||||
| 16 | **双向授权前置校验** | 接入流程文档化与运维监控 | 未在新增子商户时检查"服务商申请 + 子商户授权"是否完成,导致首单 NO_AUTH | 🟡 推荐 |
|
||||
| 17 | **service_id 增量绑定** | 新增 sub_mchid/appid 流程 | 新增 sub_mchid/sub_appid 后未联系运营做 service_id 绑定,新组合直接 NO_AUTH | 🟡 推荐 |
|
||||
| 18 | **分账(如使用)profit_sharing 时序** | 分账调用代码 | 完结时未传 `profit_sharing=true` 直接尝试分账,或在扣款成功前调用分账 | 🟡 推荐 |
|
||||
| 19 | **同步状态接口幂等** | 同步状态调用 | 用户走其他渠道支付后,同步接口未做幂等保护 | 🟡 推荐 |
|
||||
| 20 | **APIv3 密钥与 APIv2 密钥不可硬编码** | 配置加载 | 密钥/私钥写死在代码或配置文件 | 🔴 致命 |
|
||||
| 21 | **服务商 API 证书私钥保护** | 私钥加载与权限 | 私钥文件权限为 644 或更宽,未做最小权限 | 🟡 推荐 |
|
||||
| 22 | **修改订单金额只能下调** | 修改金额调用前 | 代码允许传入大于待支付金额的值 | 🟡 推荐 |
|
||||
| 23 | **notify_url HTTPS / 备案 / 公网可达** | notify_url 配置 | 使用 HTTP / 内网地址 / 未备案域名 / 携带 query 参数 | 🔴 致命 |
|
||||
| 24 | **回调 5 秒内应答** | 回调处理流程耗时 | 回调处理同步等待业务长耗时操作,导致 5 秒超时 | 🔴 致命 |
|
||||
| 25 | **回调金额与本地订单比对** | 支付成功回调处理 | 回调中的支付金额未与本地订单金额比对 | 🔴 致命 |
|
||||
| 26 | **测试白名单兼容** | 调用前的环境分支 | 上线前未将测试微信号加入服务商平台白名单 | 🟠 可选 |
|
||||
| 27 | **device.start_device_id 必传场景** | 创单参数构造 | 售货机/充电宝/充电桩等场景未传 device.start_device_id | 🟡 推荐 |
|
||||
| 28 | **多 sub_mchid 并发下单的 out_order_no 唯一性** | 创单参数构造 | `out_order_no` 仅本服务商内唯一未按 sub_mchid 隔离命名空间,存在跨子商户冲突可能 | 🟡 推荐 |
|
||||
@@ -0,0 +1,182 @@
|
||||
# 服务商模式签名与验签规则
|
||||
|
||||
> 本文档为微信支付 APIv3 **通用签名与验签规范**,适用于**商户**、**品牌直连**、**服务商**三种接入模式。
|
||||
>
|
||||
> **核心结论**:商户与服务商在签名链路上**完全一致**(签名方案、身份标识、API 路径前缀、SDK 工具类、签名串格式均相同)。两者只在**请求参数命名**上有差异,与签名规则无关:服务商的 `mchid` 是服务商号(不是子商户号),签名用服务商的 API 证书(不是子商户的),请求体多 `sub_mchid` / `sub_appid`,部分接口路径多 `partner/` 段。**真正不同的是品牌直连**——专属签名方案、专属工具类、`/brand/` 路径前缀。
|
||||
|
||||
## 一、三种模式签名方案差异
|
||||
|
||||
| 项目 | 商户 / 服务商 | 品牌直连 |
|
||||
| ---- | ----------- | -------- |
|
||||
| 签名方案 | `WECHATPAY2-SHA256-RSA2048` | `WECHATPAY-BRAND-SHA256-RSA2048` |
|
||||
| 身份标识 | `mchid="xxx"` | `brand_id="xxx"` |
|
||||
| 签名私钥 | 商户 API 证书私钥 | 品牌 API 证书私钥 |
|
||||
| 验签公钥 | 微信支付公钥 / 平台证书 | 微信支付公钥 |
|
||||
| API 路径前缀 | `/v3/` | `/brand/` |
|
||||
| Java / Go 工具类 | `WXPayUtility` / `wxpay_utility` | `WXPayBrandUtility` / `wxpay_brand_utility` |
|
||||
| 配置对象 | `MchConfig`(传 `mchid`) | `BrandConfig`(传 `brand_id`) |
|
||||
|
||||
**最常见错误**:用错工具类(商户工具类调品牌接口或反过来)、签名方案漏写 `BRAND` 后缀、`mchid` 与 `brand_id` 混用 → 全部表现为 401 SIGN_ERROR。
|
||||
|
||||
## 二、请求接口签名串(5 行格式,三方一致)
|
||||
|
||||
```
|
||||
HTTP请求方法\n
|
||||
URL\n
|
||||
请求时间戳\n
|
||||
请求随机串\n
|
||||
请求报文主体\n
|
||||
```
|
||||
|
||||
### 自查要点
|
||||
|
||||
1. 严格 5 行,每行末尾必须有 `\n`(包括最后一行;参数本身以 `\n` 结尾时仍需再附加一个)
|
||||
2. URL 是**去掉域名的绝对路径**(不含 `https://api.mch.weixin.qq.com`)
|
||||
3. 空请求体(如 GET、证书下载)该行为空字符串,仍需附加 `\n`
|
||||
4. 签名时的请求体与实际发送的请求体**必须字节级一致**(含字段顺序、空格、换行)
|
||||
5. 时间戳为系统当前 UNIX 秒数,须保持系统时间准确(太旧的请求会被拒绝)
|
||||
|
||||
### 四种参数类型构造规则
|
||||
|
||||
| 类型 | 关键规则 | URL 示例 | 第 5 行(请求体) |
|
||||
| ---- | -------- | -------- | ---------------- |
|
||||
| **Path 参数** | URL 中 `{xxx}` 必须替换为实际值 | `/v3/refund/domestic/refunds/123123123123` | 空(仅 `\n`) |
|
||||
| **Body 参数** | 第 5 行 = 完整请求体 JSON,与实际发送的字节级一致 | `/v3/pay/transactions/jsapi` | `{"appid":"wxd6...","mchid":"19000...","amount":{"total":100,"currency":"CNY"},...}` |
|
||||
| **Query 参数** | URL 必须含完整 `?key=val&...`;含特殊字符的值(JSON、中文)**必须 URL Encode** | `/v3/marketing/partnerships?limit=5&offset=10&authorized_data=%7B...%7D` | 空(仅 `\n`) |
|
||||
| **图片上传** | 第 5 行 = `meta` 字段 JSON(含 `filename`、`sha256`),**不是整个 multipart body**;HTTP 头需 `Content-Type: multipart/form-data` | `/v3/marketing/favor/media/image-upload` | `{"filename":"x.png","sha256":"d2973a45..."}` |
|
||||
|
||||
完整的 Body 签名串示例(JSAPI 下单):
|
||||
|
||||
```
|
||||
POST\n
|
||||
/v3/pay/transactions/jsapi\n
|
||||
1554208460\n
|
||||
593BEC0C930BF1AFEB40B4A08C8FB242\n
|
||||
{"appid":"wxd678efh567hg6787","mchid":"1900007291","description":"Image形象店-深圳腾大-QQ公仔","out_trade_no":"1217752501201407033233368018","notify_url":"https://www.weixin.qq.com/wxpay/pay.php","amount":{"total":100,"currency":"CNY"},"payer":{"openid":"oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"}}\n
|
||||
```
|
||||
|
||||
**四类参数高频踩雷**:
|
||||
|
||||
- Path:保留了 `{xxx}` 占位符未替换;URL 末尾多/少 `/`
|
||||
- Body:签名时压缩成一行但实际发送是格式化的(或反之);字段顺序不一致
|
||||
- Query:只签了路径漏掉 query 串;JSON 类参数值未做 URL Encode;参数顺序不一致
|
||||
- 图片上传:用整个 multipart body 参与签名;`sha256` 算成了 meta JSON 的摘要而不是文件内容
|
||||
|
||||
## 三、响应验签 与 回调验签
|
||||
|
||||
两者**验签算法完全一致**,唯一差异是签名头来源:响应验签从 HTTP 响应头取,回调验签从回调请求头取。
|
||||
|
||||
**验签步骤**:
|
||||
|
||||
1. 获取 4 个签名头:`Wechatpay-Timestamp`、`Wechatpay-Nonce`、`Wechatpay-Signature`、`Wechatpay-Serial`
|
||||
2. 构造验签串(**3 行**,每行以 `\n` 结尾):
|
||||
|
||||
```
|
||||
应答时间戳\n
|
||||
应答随机串\n
|
||||
应答报文主体\n
|
||||
```
|
||||
|
||||
3. 用对应公钥对验签串和签名值做 SHA256 with RSA 验证
|
||||
4. 校验 `Wechatpay-Serial` 与本地持有的微信支付公钥 ID / 平台证书序列号是否匹配
|
||||
|
||||
### 静态扫描检查方式
|
||||
|
||||
| 场景 | 第一步 定位 | 第二步 检查 | 判定为"未实现"的特征 |
|
||||
| ---- | ----------- | ----------- | ------------------ |
|
||||
| **响应验签** | 找发 HTTP 请求并处理响应的代码(OkHttp `execute()` / HttpClient `send()` 等) | 处理 2XX 响应分支中是否调用验签方法(`validateResponse` / `verify` 或手写步骤) | 收到 2XX 响应后直接解析返回数据,中间无任何验签逻辑 |
|
||||
| **回调验签** | 找处理回调通知的代码(含 `event_type` / `resource_type` / `encrypt-resource` 等字段) | **解密业务数据之前**是否调用验签方法(`validateNotification` / `verify`) | 收到通知后直接解密/解析业务数据,中间无任何验签逻辑 |
|
||||
|
||||
⚠️ 若代码使用 `parseNotification` 等封装方法,需检查该方法**内部**是否包含验签步骤,而非只做解密。
|
||||
|
||||
## 四、幂等与并发控制
|
||||
|
||||
同一通知/请求可能多次到达,业务**必须能正确处理重复**。
|
||||
|
||||
**推荐做法**:① 收到后先按业务唯一标识 + `event_type` 查询是否已处理 → ② 未处理则进入业务流程(**前置加锁**)→ ③ 已处理则直接返回成功。
|
||||
|
||||
**并发锁选型**:Redis 分布式锁(`setnx` / `RedisLock`)、数据库行锁(`SELECT ... FOR UPDATE`)、`synchronized` / `ReentrantLock` 等;锁粒度建议「业务唯一标识 + `event_type`」。
|
||||
|
||||
**静态扫描判定**:回调处理代码中**既无去重查询逻辑也无加锁逻辑** → 判定未实现。
|
||||
|
||||
## 五、探测流量处理
|
||||
|
||||
微信支付会定期发送探测流量验证商户系统的连通性和验签逻辑:
|
||||
|
||||
- 探测请求的签名值以 `WECHATPAY/SIGNTEST/` 为前缀
|
||||
- 商户系统**不应做特殊跳过**,应当作正常通知/响应进行验签处理
|
||||
- 排查时可通过该前缀快速识别探测流量
|
||||
- 若对探测流量返回错误,微信支付可能判定回调地址不可用
|
||||
|
||||
## 六、Authorization 头格式
|
||||
|
||||
```
|
||||
# 商户 / 服务商
|
||||
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900007291",nonce_str="<随机串>",signature="<签名值>",timestamp="<时间戳>",serial_no="<商户API证书序列号>"
|
||||
|
||||
# 品牌直连
|
||||
Authorization: WECHATPAY-BRAND-SHA256-RSA2048 brand_id="100XX",nonce_str="<随机串>",signature="<签名值>",timestamp="<时间戳>",serial_no="<品牌API证书序列号>"
|
||||
```
|
||||
|
||||
### 自查要点
|
||||
|
||||
| 检查项 | 正确做法 |
|
||||
| ------ | -------- |
|
||||
| 认证类型与身份标识 | 见上方两个示例 |
|
||||
| `nonce_str` / `timestamp` | 必须与签名串中的值**字节级一致** |
|
||||
| `serial_no` | 签名所用私钥对应的**商户/品牌 API 证书**序列号;⚠️ **不是**微信支付平台证书序列号 |
|
||||
| `Wechatpay-Serial`(独立请求头) | 验签用,传微信支付公钥 ID(`PUB_KEY_ID_xxxxxx`)或平台证书序列号 |
|
||||
| 整行不换行 | Authorization 值必须在一行 |
|
||||
| 五项顺序 | 无顺序要求 |
|
||||
|
||||
## 七、调起支付签名(仅支付类业务)
|
||||
|
||||
> ⚠️ 仅当业务涉及"调起客户端支付"(APP / JSAPI / 小程序 / H5)时适用,营销类业务(商品券、立减金、积分等)通常不涉及。
|
||||
|
||||
调起支付签名**与请求接口签名不同**:固定 **4 行**(不是 5 行),每行 `\n` 结尾(含最后一行)。私钥仍是商户 API 证书私钥(与下单接口同一对,不可分开)。
|
||||
|
||||
| 客户端 | 签名串第 1 行 | 第 4 行(关键差异) | 客户端字段 |
|
||||
| ------ | ------------- | ------------------ | -------- |
|
||||
| **APP** | 移动应用 AppID(开放平台获取) | **纯 prepay_id**(不带前缀) | `appId` / `partnerId` / `prepayId` / `packageValue=Sign=WXPay` / `nonceStr` / `timeStamp` / `sign` |
|
||||
| **JSAPI** | 公众号 AppID | **`prepay_id=<value>`**(必须带前缀) | `appId` / `timeStamp` / `nonceStr` / `package=prepay_id=xxx` / `signType=RSA` / `paySign` |
|
||||
| **小程序** | 小程序 AppID | **`prepay_id=<value>`**(必须带前缀,与 JSAPI 完全相同的格式) | `timeStamp` / `nonceStr` / `package=prepay_id=xxx` / `signType=RSA` / `paySign` |
|
||||
|
||||
签名串通用格式:
|
||||
|
||||
```
|
||||
appId(或小程序 appID)\n
|
||||
时间戳\n
|
||||
随机字符串\n
|
||||
prepay_id 或 prepay_id=<value>\n
|
||||
```
|
||||
|
||||
JSAPI / 小程序 示例:
|
||||
|
||||
```
|
||||
wx2421b1c4370ec43b\n
|
||||
1554208460\n
|
||||
593BEC0C930BF1AFEB40B4A08C8FB242\n
|
||||
prepay_id=wx201410272009395522657a690389285100\n
|
||||
```
|
||||
|
||||
**调起支付高频踩雷**:
|
||||
|
||||
1. 用了 5 行格式(与请求签名混淆)
|
||||
2. JSAPI/小程序漏掉 `prepay_id=` 前缀,或 APP 加了前缀
|
||||
3. `signType` 误参与签名(它仅客户端调起时传,固定 `RSA`,不入签名串)
|
||||
4. 调起支付与下单使用了**不同**的商户 API 证书(必须同源)
|
||||
|
||||
## 八、401 SIGN_ERROR 自查清单
|
||||
|
||||
无需提供私钥文件,按顺序逐项确认:
|
||||
|
||||
1. **签名方案**:商户/服务商 = `WECHATPAY2-SHA256-RSA2048`;品牌 = `WECHATPAY-BRAND-SHA256-RSA2048`
|
||||
2. **身份标识**:商户/服务商 = `mchid`;品牌 = `brand_id`
|
||||
3. **`serial_no`**:是商户/品牌 API 证书序列号,**不是**平台证书序列号
|
||||
4. **签名串行数**:请求接口 = 5 行;调起支付 = 4 行;响应/回调验签 = 3 行
|
||||
5. **URL**:去域名、Path 占位符已替换、Query 串完整且特殊字符已 URL Encode
|
||||
6. **请求体一致性**:签名时与发送时字节级一致(顺序、空格、换行)
|
||||
7. **`nonce_str` / `timestamp`**:签名串与 Authorization 头中的值相同;时间戳未过期、系统时间准确
|
||||
8. **图片上传**(如适用):第 5 行用 meta 的 JSON 而非整个 multipart body
|
||||
9. **调起支付**(如适用):4 行格式;JSAPI/小程序带 `prepay_id=` 前缀,APP 不带
|
||||
10. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CancelPartnerServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
Reason: wxpay_utility.String("用户投诉"),
|
||||
}
|
||||
|
||||
err = CancelPartnerServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Println("请求成功")
|
||||
}
|
||||
|
||||
func CancelPartnerServiceOrder(config *wxpay_utility.MchConfig, request *CancelPartnerServiceOrderRequest) (err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/partner/serviceorder/{out_order_no}/cancel"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CancelPartnerServiceOrderRequest struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (o *CancelPartnerServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias CancelPartnerServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CompletePartnerServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
PostPayments: []Payment{Payment{
|
||||
Name: wxpay_utility.String("就餐费用"),
|
||||
Amount: wxpay_utility.Int64(40000),
|
||||
Description: wxpay_utility.String("就餐人均100元"),
|
||||
Count: wxpay_utility.Int64(4),
|
||||
}},
|
||||
PostDiscounts: []ServiceOrderCoupon{ServiceOrderCoupon{
|
||||
Name: wxpay_utility.String("满20减1元"),
|
||||
Description: wxpay_utility.String("不与其他优惠叠加"),
|
||||
Amount: wxpay_utility.Int64(100),
|
||||
Count: wxpay_utility.Int64(2),
|
||||
}},
|
||||
TotalAmount: wxpay_utility.Int64(50000),
|
||||
TimeRange: &TimeRange{
|
||||
StartTime: wxpay_utility.String("20091225091010"),
|
||||
EndTime: wxpay_utility.String("20091225121010"),
|
||||
StartTimeRemark: wxpay_utility.String("备注1"),
|
||||
EndTimeRemark: wxpay_utility.String("备注2"),
|
||||
},
|
||||
Location: &Location{
|
||||
StartLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
EndLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
},
|
||||
ProfitSharing: wxpay_utility.Bool(false),
|
||||
CompleteTime: wxpay_utility.Time(time.Now()),
|
||||
GoodsTag: wxpay_utility.String("goods_tag"),
|
||||
Device: &Device{
|
||||
StartDeviceId: wxpay_utility.String("HG123456"),
|
||||
EndDeviceId: wxpay_utility.String("HG123456"),
|
||||
MaterielNo: wxpay_utility.String("example_materiel_no"),
|
||||
},
|
||||
}
|
||||
|
||||
err = CompletePartnerServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Println("请求成功")
|
||||
}
|
||||
|
||||
func CompletePartnerServiceOrder(config *wxpay_utility.MchConfig, request *CompletePartnerServiceOrderRequest) (err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/partner/serviceorder/{out_order_no}/complete"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CompletePartnerServiceOrderRequest struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
ProfitSharing *bool `json:"profit_sharing,omitempty"`
|
||||
CompleteTime *time.Time `json:"complete_time,omitempty"`
|
||||
GoodsTag *string `json:"goods_tag,omitempty"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
func (o *CompletePartnerServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias CompletePartnerServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
StartDeviceId *string `json:"start_device_id,omitempty"`
|
||||
EndDeviceId *string `json:"end_device_id,omitempty"`
|
||||
MaterielNo *string `json:"materiel_no,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreatePartnerServiceOrderRequest{
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
Appid: wxpay_utility.String("wxd678efh567hg6787"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
SubAppid: wxpay_utility.String("wxd678efh567hg6999"),
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
ServiceIntroduction: wxpay_utility.String("XX充电宝"),
|
||||
PostPayments: []Payment{Payment{
|
||||
Name: wxpay_utility.String("就餐费用"),
|
||||
Amount: wxpay_utility.Int64(40000),
|
||||
Description: wxpay_utility.String("就餐人均100元"),
|
||||
Count: wxpay_utility.Int64(4),
|
||||
}},
|
||||
PostDiscounts: []ServiceOrderCoupon{ServiceOrderCoupon{
|
||||
Name: wxpay_utility.String("满20减1元"),
|
||||
Description: wxpay_utility.String("不与其他优惠叠加"),
|
||||
Amount: wxpay_utility.Int64(100),
|
||||
Count: wxpay_utility.Int64(2),
|
||||
}},
|
||||
RiskFund: &RiskFund{
|
||||
Name: wxpay_utility.String("DEPOSIT"),
|
||||
Amount: wxpay_utility.Int64(10000),
|
||||
Description: wxpay_utility.String("就餐的预估费用"),
|
||||
},
|
||||
TimeRange: &TimeRange{
|
||||
StartTime: wxpay_utility.String("20091225091010"),
|
||||
EndTime: wxpay_utility.String("20091225121010"),
|
||||
StartTimeRemark: wxpay_utility.String("备注1"),
|
||||
EndTimeRemark: wxpay_utility.String("备注2"),
|
||||
},
|
||||
Location: &Location{
|
||||
StartLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
EndLocation: wxpay_utility.String("嗨客时尚主题展餐厅"),
|
||||
},
|
||||
NeedUserConfirm: wxpay_utility.Bool(false),
|
||||
NotifyUrl: wxpay_utility.String("https://api.test.com"),
|
||||
Attach: wxpay_utility.String("Easdfowealsdkjfnlaksjdlfkwqoi&wl3l2sald"),
|
||||
Device: &Device{
|
||||
StartDeviceId: wxpay_utility.String("HG123456"),
|
||||
EndDeviceId: wxpay_utility.String("HG123456"),
|
||||
MaterielNo: wxpay_utility.String("example_materiel_no"),
|
||||
},
|
||||
}
|
||||
|
||||
response, err := CreatePartnerServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func CreatePartnerServiceOrder(config *wxpay_utility.MchConfig, request *CreatePartnerServiceOrderRequest) (response *CreatePartnerServiceOrderResponse, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/partner/serviceorder"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &CreatePartnerServiceOrderResponse{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreatePartnerServiceOrderRequest struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
SubAppid *string `json:"sub_appid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
NeedUserConfirm *bool `json:"need_user_confirm,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type CreatePartnerServiceOrderResponse struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
SubAppid *string `json:"sub_appid,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
Package *string `json:"package,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
StartDeviceId *string `json:"start_device_id,omitempty"`
|
||||
EndDeviceId *string `json:"end_device_id,omitempty"`
|
||||
MaterielNo *string `json:"materiel_no,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &ModifyPartnerServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
PostPayments: []Payment{Payment{
|
||||
Name: wxpay_utility.String("就餐费用"),
|
||||
Amount: wxpay_utility.Int64(40000),
|
||||
Description: wxpay_utility.String("就餐人均100元"),
|
||||
Count: wxpay_utility.Int64(4),
|
||||
}},
|
||||
PostDiscounts: []ServiceOrderCoupon{ServiceOrderCoupon{
|
||||
Name: wxpay_utility.String("满20减1元"),
|
||||
Description: wxpay_utility.String("不与其他优惠叠加"),
|
||||
Amount: wxpay_utility.Int64(100),
|
||||
Count: wxpay_utility.Int64(2),
|
||||
}},
|
||||
TotalAmount: wxpay_utility.Int64(50000),
|
||||
Reason: wxpay_utility.String("用户投诉"),
|
||||
Device: &Device{
|
||||
StartDeviceId: wxpay_utility.String("HG123456"),
|
||||
EndDeviceId: wxpay_utility.String("HG123456"),
|
||||
MaterielNo: wxpay_utility.String("example_materiel_no"),
|
||||
},
|
||||
}
|
||||
|
||||
err = ModifyPartnerServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Println("请求成功")
|
||||
}
|
||||
|
||||
func ModifyPartnerServiceOrder(config *wxpay_utility.MchConfig, request *ModifyPartnerServiceOrderRequest) (err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/partner/serviceorder/{out_order_no}/modify"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type ModifyPartnerServiceOrderRequest struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
PostPayments []Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
Device *Device `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
func (o *ModifyPartnerServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias ModifyPartnerServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
StartDeviceId *string `json:"start_device_id,omitempty"`
|
||||
EndDeviceId *string `json:"end_device_id,omitempty"`
|
||||
MaterielNo *string `json:"materiel_no,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &GetPartnerServiceOrderRequest{
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
QueryId: wxpay_utility.String("15646546545165651651"),
|
||||
}
|
||||
|
||||
response, err := GetPartnerServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func GetPartnerServiceOrder(config *wxpay_utility.MchConfig, request *GetPartnerServiceOrderRequest) (response *ServiceOrderEntity, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/payscore/partner/serviceorder"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := reqUrl.Query()
|
||||
if request.ServiceId != nil {
|
||||
query.Add("service_id", *request.ServiceId)
|
||||
}
|
||||
if request.SubMchid != nil {
|
||||
query.Add("sub_mchid", *request.SubMchid)
|
||||
}
|
||||
if request.OutOrderNo != nil {
|
||||
query.Add("out_order_no", *request.OutOrderNo)
|
||||
}
|
||||
if request.QueryId != nil {
|
||||
query.Add("query_id", *request.QueryId)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &ServiceOrderEntity{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type GetPartnerServiceOrderRequest struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
QueryId *string `json:"query_id,omitempty"`
|
||||
}
|
||||
|
||||
func (o *GetPartnerServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias GetPartnerServiceOrderRequest
|
||||
a := &struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
QueryId *string `json:"query_id,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
ServiceId: nil,
|
||||
SubMchid: nil,
|
||||
OutOrderNo: nil,
|
||||
QueryId: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type ServiceOrderEntity struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
Appid *string `json:"appid,omitempty"`
|
||||
Mchid *string `json:"mchid,omitempty"`
|
||||
SubAppid *string `json:"sub_appid,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
ServiceIntroduction *string `json:"service_introduction,omitempty"`
|
||||
State *string `json:"state,omitempty"`
|
||||
StateDescription *string `json:"state_description,omitempty"`
|
||||
PostPayments *Payment `json:"post_payments,omitempty"`
|
||||
PostDiscounts []ServiceOrderCoupon `json:"post_discounts,omitempty"`
|
||||
RiskFund *RiskFund `json:"risk_fund,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
NeedCollection *bool `json:"need_collection,omitempty"`
|
||||
Collection *Collection `json:"collection,omitempty"`
|
||||
TimeRange *TimeRange `json:"time_range,omitempty"`
|
||||
Location *Location `json:"location,omitempty"`
|
||||
Attach *string `json:"attach,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
Openid *string `json:"openid,omitempty"`
|
||||
SubOpenid *string `json:"sub_openid,omitempty"`
|
||||
OrderId *string `json:"order_id,omitempty"`
|
||||
}
|
||||
|
||||
type Payment struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type ServiceOrderCoupon struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Count *int64 `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
type RiskFund struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
State *string `json:"state,omitempty"`
|
||||
TotalAmount *int64 `json:"total_amount,omitempty"`
|
||||
PayingAmount *int64 `json:"paying_amount,omitempty"`
|
||||
PaidAmount *int64 `json:"paid_amount,omitempty"`
|
||||
Details []Detail `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type TimeRange struct {
|
||||
StartTime *string `json:"start_time,omitempty"`
|
||||
EndTime *string `json:"end_time,omitempty"`
|
||||
StartTimeRemark *string `json:"start_time_remark,omitempty"`
|
||||
EndTimeRemark *string `json:"end_time_remark,omitempty"`
|
||||
}
|
||||
|
||||
type Location struct {
|
||||
StartLocation *string `json:"start_location,omitempty"`
|
||||
EndLocation *string `json:"end_location,omitempty"`
|
||||
}
|
||||
|
||||
type Detail struct {
|
||||
Seq *int64 `json:"seq,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
PaidType *string `json:"paid_type,omitempty"`
|
||||
PaidTime *string `json:"paid_time,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &SyncPartnerServiceOrderRequest{
|
||||
OutOrderNo: wxpay_utility.String("1234323JKHDFE1243252"),
|
||||
ServiceId: wxpay_utility.String("2002000000000558128851361561536"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
Type: wxpay_utility.String("Order_Paid"),
|
||||
Detail: &SyncDetail{
|
||||
PaidTime: wxpay_utility.String("20091225091210"),
|
||||
},
|
||||
}
|
||||
|
||||
err = SyncPartnerServiceOrder(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("请求成功")
|
||||
}
|
||||
|
||||
func SyncPartnerServiceOrder(config *wxpay_utility.MchConfig, request *SyncPartnerServiceOrderRequest) (err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/payscore/partner/serviceorder/{out_order_no}/sync"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_order_no}", url.PathEscape(*request.OutOrderNo), -1)
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
return wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type SyncPartnerServiceOrderRequest struct {
|
||||
ServiceId *string `json:"service_id,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
Type *string `json:"type,omitempty"`
|
||||
Detail *SyncDetail `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func (o *SyncPartnerServiceOrderRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias SyncPartnerServiceOrderRequest
|
||||
a := &struct {
|
||||
OutOrderNo *string `json:"out_order_no,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
OutOrderNo: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type SyncDetail struct {
|
||||
PaidTime *string `json:"paid_time,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &CreateRequest{
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
TransactionId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
OutTradeNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
Reason: wxpay_utility.String("商品已售完"),
|
||||
NotifyUrl: wxpay_utility.String("https://weixin.qq.com"),
|
||||
FundsAccount: REQFUNDSACCOUNT_AVAILABLE.Ptr(),
|
||||
Amount: &AmountReq{
|
||||
Refund: wxpay_utility.Int64(888),
|
||||
From: []FundsFromItem{FundsFromItem{
|
||||
Account: ACCOUNT_AVAILABLE.Ptr(),
|
||||
Amount: wxpay_utility.Int64(444),
|
||||
}},
|
||||
Total: wxpay_utility.Int64(888),
|
||||
Currency: wxpay_utility.String("CNY"),
|
||||
},
|
||||
GoodsDetail: []GoodsDetail{GoodsDetail{
|
||||
MerchantGoodsId: wxpay_utility.String("1217752501201407033233368018"),
|
||||
WechatpayGoodsId: wxpay_utility.String("1001"),
|
||||
GoodsName: wxpay_utility.String("iPhone6s 16G"),
|
||||
UnitPrice: wxpay_utility.Int64(528800),
|
||||
RefundAmount: wxpay_utility.Int64(528800),
|
||||
RefundQuantity: wxpay_utility.Int64(1),
|
||||
}},
|
||||
RefundAccount: REFUNDACCOUNT_REFUND_SOURCE_SUB_MERCHANT.Ptr(),
|
||||
}
|
||||
|
||||
response, err := Create(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func Create(config *wxpay_utility.MchConfig, request *CreateRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "POST"
|
||||
path = "/v3/refund/domestic/refunds"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqBody, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), bytes.NewReader(reqBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
NotifyUrl *string `json:"notify_url,omitempty"`
|
||||
FundsAccount *ReqFundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *AmountReq `json:"amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
RefundAccount *RefundAccount `json:"refund_account,omitempty"`
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
RefundAccount *RefundAccount `json:"refund_account,omitempty"`
|
||||
}
|
||||
|
||||
type ReqFundsAccount string
|
||||
|
||||
func (e ReqFundsAccount) Ptr() *ReqFundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
REQFUNDSACCOUNT_AVAILABLE ReqFundsAccount = "AVAILABLE"
|
||||
REQFUNDSACCOUNT_UNSETTLED ReqFundsAccount = "UNSETTLED"
|
||||
)
|
||||
|
||||
type AmountReq struct {
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
}
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type RefundAccount string
|
||||
|
||||
func (e RefundAccount) Ptr() *RefundAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
REFUNDACCOUNT_REFUND_SOURCE_PARTNER_ADVANCE RefundAccount = "REFUND_SOURCE_PARTNER_ADVANCE"
|
||||
REFUNDACCOUNT_REFUND_SOURCE_SUB_MERCHANT RefundAccount = "REFUND_SOURCE_SUB_MERCHANT"
|
||||
REFUNDACCOUNT_REFUND_SOURCE_SUB_MERCHANT_ADVANCE RefundAccount = "REFUND_SOURCE_SUB_MERCHANT_ADVANCE"
|
||||
)
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
Advance *int64 `json:"advance,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_COUPON PromotionType = "COUPON"
|
||||
PROMOTIONTYPE_DISCOUNT PromotionType = "DISCOUNT"
|
||||
)
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"demo/wxpay_utility" // 引用微信支付工具库,参考 https://pay.weixin.qq.com/doc/v3/partner/4015119446
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
config, err := wxpay_utility.CreateMchConfig(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
|
||||
request := &QueryByOutRefundNoRequest{
|
||||
OutRefundNo: wxpay_utility.String("1217752501201407033233368018"),
|
||||
SubMchid: wxpay_utility.String("1900000109"),
|
||||
}
|
||||
|
||||
response, err := QueryByOutRefundNo(config, request)
|
||||
if err != nil {
|
||||
fmt.Printf("请求失败: %+v\n", err)
|
||||
// TODO: 请求失败,根据状态码执行不同的处理
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
fmt.Printf("请求成功: %+v\n", response)
|
||||
}
|
||||
|
||||
func QueryByOutRefundNo(config *wxpay_utility.MchConfig, request *QueryByOutRefundNoRequest) (response *Refund, err error) {
|
||||
const (
|
||||
host = "https://api.mch.weixin.qq.com"
|
||||
method = "GET"
|
||||
path = "/v3/refund/domestic/refunds/{out_refund_no}"
|
||||
)
|
||||
|
||||
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_refund_no}", url.PathEscape(*request.OutRefundNo), -1)
|
||||
query := reqUrl.Query()
|
||||
if request.SubMchid != nil {
|
||||
query.Add("sub_mchid", *request.SubMchid)
|
||||
}
|
||||
reqUrl.RawQuery = query.Encode()
|
||||
httpRequest, err := http.NewRequest(method, reqUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
authorization, err := wxpay_utility.BuildAuthorization(config.MchId(), config.CertificateSerialNo(), config.PrivateKey(), method, reqUrl.RequestURI(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBody, err := wxpay_utility.ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
// 2XX 成功,验证应答签名
|
||||
err = wxpay_utility.ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response := &Refund{}
|
||||
if err := json.Unmarshal(respBody, response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return response, nil
|
||||
} else {
|
||||
return nil, wxpay_utility.NewApiException(
|
||||
httpResponse.StatusCode,
|
||||
httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type QueryByOutRefundNoRequest struct {
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
}
|
||||
|
||||
func (o *QueryByOutRefundNoRequest) MarshalJSON() ([]byte, error) {
|
||||
type Alias QueryByOutRefundNoRequest
|
||||
a := &struct {
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
SubMchid *string `json:"sub_mchid,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
// 序列化时移除非 Body 字段
|
||||
OutRefundNo: nil,
|
||||
SubMchid: nil,
|
||||
Alias: (*Alias)(o),
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
type Refund struct {
|
||||
RefundId *string `json:"refund_id,omitempty"`
|
||||
OutRefundNo *string `json:"out_refund_no,omitempty"`
|
||||
TransactionId *string `json:"transaction_id,omitempty"`
|
||||
OutTradeNo *string `json:"out_trade_no,omitempty"`
|
||||
Channel *Channel `json:"channel,omitempty"`
|
||||
UserReceivedAccount *string `json:"user_received_account,omitempty"`
|
||||
SuccessTime *time.Time `json:"success_time,omitempty"`
|
||||
CreateTime *time.Time `json:"create_time,omitempty"`
|
||||
Status *Status `json:"status,omitempty"`
|
||||
FundsAccount *FundsAccount `json:"funds_account,omitempty"`
|
||||
Amount *Amount `json:"amount,omitempty"`
|
||||
PromotionDetail []Promotion `json:"promotion_detail,omitempty"`
|
||||
RefundAccount *RefundAccount `json:"refund_account,omitempty"`
|
||||
}
|
||||
|
||||
type Channel string
|
||||
|
||||
func (e Channel) Ptr() *Channel {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
CHANNEL_ORIGINAL Channel = "ORIGINAL"
|
||||
CHANNEL_BALANCE Channel = "BALANCE"
|
||||
CHANNEL_OTHER_BALANCE Channel = "OTHER_BALANCE"
|
||||
CHANNEL_OTHER_BANKCARD Channel = "OTHER_BANKCARD"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
func (e Status) Ptr() *Status {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
STATUS_SUCCESS Status = "SUCCESS"
|
||||
STATUS_CLOSED Status = "CLOSED"
|
||||
STATUS_PROCESSING Status = "PROCESSING"
|
||||
STATUS_ABNORMAL Status = "ABNORMAL"
|
||||
)
|
||||
|
||||
type FundsAccount string
|
||||
|
||||
func (e FundsAccount) Ptr() *FundsAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
FUNDSACCOUNT_UNSETTLED FundsAccount = "UNSETTLED"
|
||||
FUNDSACCOUNT_AVAILABLE FundsAccount = "AVAILABLE"
|
||||
FUNDSACCOUNT_UNAVAILABLE FundsAccount = "UNAVAILABLE"
|
||||
FUNDSACCOUNT_OPERATION FundsAccount = "OPERATION"
|
||||
FUNDSACCOUNT_BASIC FundsAccount = "BASIC"
|
||||
FUNDSACCOUNT_ECNY_BASIC FundsAccount = "ECNY_BASIC"
|
||||
)
|
||||
|
||||
type Amount struct {
|
||||
Total *int64 `json:"total,omitempty"`
|
||||
Refund *int64 `json:"refund,omitempty"`
|
||||
From []FundsFromItem `json:"from,omitempty"`
|
||||
PayerTotal *int64 `json:"payer_total,omitempty"`
|
||||
PayerRefund *int64 `json:"payer_refund,omitempty"`
|
||||
SettlementRefund *int64 `json:"settlement_refund,omitempty"`
|
||||
SettlementTotal *int64 `json:"settlement_total,omitempty"`
|
||||
DiscountRefund *int64 `json:"discount_refund,omitempty"`
|
||||
Currency *string `json:"currency,omitempty"`
|
||||
RefundFee *int64 `json:"refund_fee,omitempty"`
|
||||
Advance *int64 `json:"advance,omitempty"`
|
||||
}
|
||||
|
||||
type Promotion struct {
|
||||
PromotionId *string `json:"promotion_id,omitempty"`
|
||||
Scope *PromotionScope `json:"scope,omitempty"`
|
||||
Type *PromotionType `json:"type,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
GoodsDetail []GoodsDetail `json:"goods_detail,omitempty"`
|
||||
}
|
||||
|
||||
type RefundAccount string
|
||||
|
||||
func (e RefundAccount) Ptr() *RefundAccount {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
REFUNDACCOUNT_REFUND_SOURCE_PARTNER_ADVANCE RefundAccount = "REFUND_SOURCE_PARTNER_ADVANCE"
|
||||
REFUNDACCOUNT_REFUND_SOURCE_SUB_MERCHANT RefundAccount = "REFUND_SOURCE_SUB_MERCHANT"
|
||||
REFUNDACCOUNT_REFUND_SOURCE_SUB_MERCHANT_ADVANCE RefundAccount = "REFUND_SOURCE_SUB_MERCHANT_ADVANCE"
|
||||
)
|
||||
|
||||
type FundsFromItem struct {
|
||||
Account *Account `json:"account,omitempty"`
|
||||
Amount *int64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type PromotionScope string
|
||||
|
||||
func (e PromotionScope) Ptr() *PromotionScope {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONSCOPE_GLOBAL PromotionScope = "GLOBAL"
|
||||
PROMOTIONSCOPE_SINGLE PromotionScope = "SINGLE"
|
||||
)
|
||||
|
||||
type PromotionType string
|
||||
|
||||
func (e PromotionType) Ptr() *PromotionType {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
PROMOTIONTYPE_COUPON PromotionType = "COUPON"
|
||||
PROMOTIONTYPE_DISCOUNT PromotionType = "DISCOUNT"
|
||||
)
|
||||
|
||||
type GoodsDetail struct {
|
||||
MerchantGoodsId *string `json:"merchant_goods_id,omitempty"`
|
||||
WechatpayGoodsId *string `json:"wechatpay_goods_id,omitempty"`
|
||||
GoodsName *string `json:"goods_name,omitempty"`
|
||||
UnitPrice *int64 `json:"unit_price,omitempty"`
|
||||
RefundAmount *int64 `json:"refund_amount,omitempty"`
|
||||
RefundQuantity *int64 `json:"refund_quantity,omitempty"`
|
||||
}
|
||||
|
||||
type Account string
|
||||
|
||||
func (e Account) Ptr() *Account {
|
||||
return &e
|
||||
}
|
||||
|
||||
const (
|
||||
ACCOUNT_AVAILABLE Account = "AVAILABLE"
|
||||
ACCOUNT_UNAVAILABLE Account = "UNAVAILABLE"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 退款结果回调通知(服务商 - Go)
|
||||
|
||||
> 内容与 [`Java/5-回调通知/退款结果回调通知说明.md`](../../Java/5-回调通知/退款结果回调通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
|
||||
|
||||
> 源文档:[退款结果通知](https://pay.weixin.qq.com/doc/v3/partner/4012586138.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
服务商代特约商户调用「申请退款」接口受理后,微信支付分异步处理实际退款,退款进入终态时回推本通知。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "REFUND.SUCCESS",
|
||||
"summary": "退款成功",
|
||||
"resource": {
|
||||
"original_type": "refund",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用服务商 APIv3 密钥解密>",
|
||||
"associated_data": "refund",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## event_type 一览
|
||||
|
||||
| event_type | 含义 | 处理建议 |
|
||||
|------------|------|---------|
|
||||
| `REFUND.SUCCESS` | 退款成功 | 更新业务退款单 |
|
||||
| `REFUND.CLOSED` | 退款被关闭 | 检查 `error_msg` 与 `refund_status` |
|
||||
| `REFUND.ABNORMAL` | 退款异常 | 人工介入或调用「异常退款」接口补救 |
|
||||
|
||||
## 解密后关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `sp_mchid` / `sub_mchid` | 服务商号、特约商户号 |
|
||||
| `out_refund_no` | 商户退款单号(幂等键) |
|
||||
| `refund_id` | 微信侧退款单号 |
|
||||
| `out_order_no` | 关联支付分订单 |
|
||||
| `transaction_id` | 关联的支付单号 |
|
||||
| `amount.refund` | 实际退款金额(分) |
|
||||
| `refund_status` | `SUCCESS` / `CLOSED` / `PROCESSING` / `ABNORMAL` |
|
||||
|
||||
## 服务商处理要求
|
||||
|
||||
- 解密 / 验签均使用 **服务商**密钥与公钥。
|
||||
- 路由按 `sub_mchid` → 业务幂等按 `out_refund_no`。
|
||||
- 多次部分退款累加 `amount.refund`,确保不超过 `total_amount`。
|
||||
- 应答:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`。
|
||||
@@ -0,0 +1,60 @@
|
||||
# 确认订单回调通知(服务商 - Go)
|
||||
|
||||
> 内容与 [`Java/5-回调通知/确认订单回调通知说明.md`](../../Java/5-回调通知/确认订单回调通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
|
||||
|
||||
> 源文档:[确认订单回调通知](https://pay.weixin.qq.com/doc/v3/partner/4012586137.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
> 通用签名规则:[../../../接入指南/签名与验签规则.md](../../../接入指南/签名与验签规则.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
特约商户的用户在「确认订单」页面点击同意后,微信支付分以 **POST** 方式向 **服务商在创建订单时填写的 `notify_url`** 推送本通知。所有特约商户共用同一个 `notify_url`,服务商需以解密后报文中的 `sub_mchid` 为路由依据。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_CONFIRM",
|
||||
"summary": "支付分订单用户已确认",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用服务商 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到 ServiceOrderEntity>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段(解密后)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `sp_mchid` | 服务商商户号 |
|
||||
| `sp_appid` | 服务商 appid |
|
||||
| `sub_mchid` | **特约商户号**——服务商按此字段路由到对应特约商户的业务系统 |
|
||||
| `sub_appid` | 特约商户 appid(如有) |
|
||||
| `out_order_no` | 服务商生成的商户订单号,幂等键 |
|
||||
| `service_id` | 在该子商户上申请的支付分服务 ID |
|
||||
| `state` | `DOING`(用户已确认) |
|
||||
| `openid` | 用户在 sub_appid(无 sub_appid 时为 sp_appid)下的 openid |
|
||||
|
||||
## 服务商处理要求
|
||||
|
||||
1. **验签**:使用 **服务商 API 证书私钥对应的公钥** 验签(不是子商户的);微信支付公钥同样使用服务商体系下的公钥。
|
||||
2. **解密**:使用 **服务商 APIv3 密钥** 解密;不要使用子商户的密钥。
|
||||
3. **路由**:先按 `sub_mchid` 路由到对应特约商户的业务系统;按 `out_order_no + event_type` 幂等。
|
||||
4. **状态机**:将订单标记为 `DOING`,登记 `openid` / `order_id`,可开始提供服务。
|
||||
5. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`;业务异常返回 5xx 触发重试。
|
||||
|
||||
## 与商户模式的差异
|
||||
|
||||
| 对比项 | 商户模式 | 服务商模式 |
|
||||
|--------|----------|-----------|
|
||||
| 解密密钥 | 商户 APIv3 密钥 | **服务商** APIv3 密钥(不能用子商户的) |
|
||||
| 验签证书 | 商户接入的微信支付公钥 | **服务商**接入的微信支付公钥 |
|
||||
| 路由字段 | `mchid` | `sub_mchid` |
|
||||
| openid 归属 | `appid` | `sub_appid` 或 `sp_appid` |
|
||||
@@ -0,0 +1,53 @@
|
||||
# 支付成功回调通知(服务商 - Go)
|
||||
|
||||
> 内容与 [`Java/5-回调通知/支付成功回调通知说明.md`](../../Java/5-回调通知/支付成功回调通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
|
||||
|
||||
> 源文档:[支付成功回调通知](https://pay.weixin.qq.com/doc/v3/partner/4012586136.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
完结订单后,若订单 `need_collection = true`,微信支付分异步发起代扣,并在收款进展变化时回推本通知。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_PAID",
|
||||
"summary": "用户支付成功",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用服务商 APIv3 密钥解密>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 解密后关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `sp_mchid` / `sub_mchid` | 服务商号、特约商户号 |
|
||||
| `out_order_no` | 商户订单号 |
|
||||
| `state` | `DONE` |
|
||||
| `collection.state` | 终态多为 `USER_PAID` |
|
||||
| `collection.paid_amount` | 已收款金额(分),需累计 |
|
||||
| `collection.details[]` | 每笔扣款明细:`amount` / `paid_type` / `paid_time` / `transaction_id` |
|
||||
| `transaction_id` | **特约商户**收款的支付单号;用于资金对账与分账请求 |
|
||||
|
||||
## 服务商处理要求
|
||||
|
||||
1. **路由 + 幂等**:以 `sub_mchid` 路由 → 以 `out_order_no` 幂等。
|
||||
2. **状态机**:终态前可能多次回推(多笔代扣),需累加 `paid_amount` 写入流水表,避免覆盖。
|
||||
3. **资金归属**:扣款资金直接进入特约商户账户;服务商若有分润需求,请走「服务商分账」(基于此 `transaction_id`)。
|
||||
4. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`。
|
||||
|
||||
## 与商户模式的差异
|
||||
|
||||
- 资金最终进入 `sub_mchid` 账户(不是 `sp_mchid`)。
|
||||
- 分账请求需以 `sub_mchid` + `transaction_id` 调用「服务商分账」相关接口。
|
||||
@@ -0,0 +1,71 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Host = "https://api.mch.weixin.qq.com"
|
||||
|
||||
// SendGet 发送 GET 请求并返回已验签的应答 Body
|
||||
func SendGet(config *MchConfig, uri string) ([]byte, error) {
|
||||
return sendRequest(config, "GET", uri, nil)
|
||||
}
|
||||
|
||||
// SendPost 发送 POST 请求并返回已验签的应答 Body
|
||||
func SendPost(config *MchConfig, uri string, reqBody []byte) ([]byte, error) {
|
||||
return sendRequest(config, "POST", uri, reqBody)
|
||||
}
|
||||
|
||||
func sendRequest(config *MchConfig, method string, uri string, reqBody []byte) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if reqBody != nil {
|
||||
bodyReader = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest(method, Host+uri, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
|
||||
authorization, err := BuildAuthorization(config.MchId(), config.CertificateSerialNo(),
|
||||
config.PrivateKey(), method, uri, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
if reqBody != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody, err := ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tjfoc/gmsm/sm3"
|
||||
)
|
||||
|
||||
type MchConfig struct {
|
||||
mchId string
|
||||
certificateSerialNo string
|
||||
privateKeyFilePath string
|
||||
wechatPayPublicKeyId string
|
||||
wechatPayPublicKeyFilePath string
|
||||
privateKey *rsa.PrivateKey
|
||||
wechatPayPublicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) MchId() string {
|
||||
return c.mchId
|
||||
}
|
||||
|
||||
func (c *MchConfig) CertificateSerialNo() string {
|
||||
return c.certificateSerialNo
|
||||
}
|
||||
|
||||
func (c *MchConfig) PrivateKey() *rsa.PrivateKey {
|
||||
return c.privateKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKeyId() string {
|
||||
return c.wechatPayPublicKeyId
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKey() *rsa.PublicKey {
|
||||
return c.wechatPayPublicKey
|
||||
}
|
||||
|
||||
func CreateMchConfig(
|
||||
mchId string,
|
||||
certificateSerialNo string,
|
||||
privateKeyFilePath string,
|
||||
wechatPayPublicKeyId string,
|
||||
wechatPayPublicKeyFilePath string,
|
||||
) (*MchConfig, error) {
|
||||
mchConfig := &MchConfig{
|
||||
mchId: mchId,
|
||||
certificateSerialNo: certificateSerialNo,
|
||||
privateKeyFilePath: privateKeyFilePath,
|
||||
wechatPayPublicKeyId: wechatPayPublicKeyId,
|
||||
wechatPayPublicKeyFilePath: wechatPayPublicKeyFilePath,
|
||||
}
|
||||
privateKey, err := LoadPrivateKeyWithPath(mchConfig.privateKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.privateKey = privateKey
|
||||
wechatPayPublicKey, err := LoadPublicKeyWithPath(mchConfig.wechatPayPublicKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.wechatPayPublicKey = wechatPayPublicKey
|
||||
return mchConfig, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKey(privateKeyStr string) (privateKey *rsa.PrivateKey, err error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode private key err")
|
||||
}
|
||||
if block.Type != "PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PRVATE KEY")
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key err:%s", err.Error())
|
||||
}
|
||||
privateKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not a RSA private key")
|
||||
}
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func LoadPublicKey(publicKeyStr string) (publicKey *rsa.PublicKey, err error) {
|
||||
block, _ := pem.Decode([]byte(publicKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("decode public key error")
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PUBLIC KEY")
|
||||
}
|
||||
key, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse public key err:%s", err.Error())
|
||||
}
|
||||
publicKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not rsa public key", publicKeyStr)
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKeyWithPath(path string) (privateKey *rsa.PrivateKey, err error) {
|
||||
privateKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPrivateKey(string(privateKeyBytes))
|
||||
}
|
||||
|
||||
func LoadPublicKeyWithPath(path string) (publicKey *rsa.PublicKey, err error) {
|
||||
publicKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPublicKey(string(publicKeyBytes))
|
||||
}
|
||||
|
||||
func EncryptOAEPWithPublicKey(message string, publicKey *rsa.PublicKey) (ciphertext string, err error) {
|
||||
if publicKey == nil {
|
||||
return "", fmt.Errorf("you should input *rsa.PublicKey")
|
||||
}
|
||||
ciphertextByte, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, publicKey, []byte(message), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt message with public key err:%s", err.Error())
|
||||
}
|
||||
ciphertext = base64.StdEncoding.EncodeToString(ciphertextByte)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func DecryptAES256GCM(aesKey, associatedData, nonce, ciphertext string) (plaintext string, err error) {
|
||||
decodedCiphertext, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := aes.NewCipher([]byte(aesKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataBytes, err := gcm.Open(nil, []byte(nonce), decodedCiphertext, []byte(associatedData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(dataBytes), nil
|
||||
}
|
||||
|
||||
func SignSHA256WithRSA(source string, privateKey *rsa.PrivateKey) (signature string, err error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key should not be nil")
|
||||
}
|
||||
h := crypto.Hash.New(crypto.SHA256)
|
||||
_, err = h.Write([]byte(source))
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
hashed := h.Sum(nil)
|
||||
signatureByte, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signatureByte), nil
|
||||
}
|
||||
|
||||
func VerifySHA256WithRSA(source string, signature string, publicKey *rsa.PublicKey) error {
|
||||
if publicKey == nil {
|
||||
return fmt.Errorf("public key should not be nil")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify failed: signature is not base64 encoded")
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(source))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify signature with public key error:%s", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateNonce() (string, error) {
|
||||
const (
|
||||
NonceSymbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
NonceLength = 32
|
||||
)
|
||||
|
||||
bytes := make([]byte, NonceLength)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
symbolsByteLength := byte(len(NonceSymbols))
|
||||
for i, b := range bytes {
|
||||
bytes[i] = NonceSymbols[b%symbolsByteLength]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func BuildAuthorization(
|
||||
mchid string,
|
||||
certificateSerialNo string,
|
||||
privateKey *rsa.PrivateKey,
|
||||
method string,
|
||||
canonicalURL string,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
const (
|
||||
SignatureMessageFormat = "%s\n%s\n%d\n%s\n%s\n"
|
||||
HeaderAuthorizationFormat = "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\""
|
||||
)
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp := time.Now().Unix()
|
||||
message := fmt.Sprintf(SignatureMessageFormat, method, canonicalURL, timestamp, nonce, body)
|
||||
signature, err := SignSHA256WithRSA(message, privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorization := fmt.Sprintf(
|
||||
HeaderAuthorizationFormat,
|
||||
mchid, nonce, timestamp, certificateSerialNo, signature,
|
||||
)
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
func ExtractResponseBody(response *http.Response) ([]byte, error) {
|
||||
if response.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body err:[%s]", err.Error())
|
||||
}
|
||||
response.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
|
||||
const (
|
||||
WechatPayTimestamp = "Wechatpay-Timestamp"
|
||||
WechatPayNonce = "Wechatpay-Nonce"
|
||||
WechatPaySignature = "Wechatpay-Signature"
|
||||
WechatPaySerial = "Wechatpay-Serial"
|
||||
RequestID = "Request-Id"
|
||||
)
|
||||
|
||||
func validateWechatPaySignature(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
timestampStr := headers.Get(WechatPayTimestamp)
|
||||
serialNo := headers.Get(WechatPaySerial)
|
||||
signature := headers.Get(WechatPaySignature)
|
||||
nonce := headers.Get(WechatPayNonce)
|
||||
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
if time.Now().Sub(time.Unix(timestamp, 0)) > 5*time.Minute {
|
||||
return fmt.Errorf("timestamp expired: %d", timestamp)
|
||||
}
|
||||
|
||||
if serialNo != wechatpayPublicKeyId {
|
||||
return fmt.Errorf(
|
||||
"serial-no mismatch: got %s, expected %s",
|
||||
serialNo,
|
||||
wechatpayPublicKeyId,
|
||||
)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s\n%s\n%s\n", timestampStr, nonce, body)
|
||||
if err := VerifySHA256WithRSA(message, signature, wechatpayPublicKey); err != nil {
|
||||
return fmt.Errorf("invalid signature: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateResponse(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate response err: %w, RequestID: %s", err, headers.Get(RequestID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate notification err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
OriginalType string `json:"original_type"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime *time.Time `json:"create_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Resource *Resource `json:"resource"`
|
||||
Summary string `json:"summary"`
|
||||
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
func (c *Notification) validate() error {
|
||||
if c.Resource == nil {
|
||||
return errors.New("resource is nil")
|
||||
}
|
||||
|
||||
if c.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
||||
return fmt.Errorf("unsupported algorithm: %s", c.Resource.Algorithm)
|
||||
}
|
||||
|
||||
if c.Resource.Ciphertext == "" {
|
||||
return errors.New("ciphertext is empty")
|
||||
}
|
||||
|
||||
if c.Resource.AssociatedData == "" {
|
||||
return errors.New("associated_data is empty")
|
||||
}
|
||||
|
||||
if c.Resource.Nonce == "" {
|
||||
return errors.New("nonce is empty")
|
||||
}
|
||||
|
||||
if c.Resource.OriginalType == "" {
|
||||
return fmt.Errorf("original_type is empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Notification) decrypt(apiv3Key string) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("notification format err: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := DecryptAES256GCM(
|
||||
apiv3Key,
|
||||
c.Resource.AssociatedData,
|
||||
c.Resource.Nonce,
|
||||
c.Resource.Ciphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
c.Plaintext = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
apiv3Key string,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) (*Notification, error) {
|
||||
if err := validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notification := &Notification{}
|
||||
if err := json.Unmarshal(body, notification); err != nil {
|
||||
return nil, fmt.Errorf("parse notification err: %w", err)
|
||||
}
|
||||
|
||||
if err := notification.decrypt(apiv3Key); err != nil {
|
||||
return nil, fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
type ApiException struct {
|
||||
statusCode int
|
||||
header http.Header
|
||||
body []byte
|
||||
errorCode string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (c *ApiException) Error() string {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(fmt.Sprintf("api error:[StatusCode: %d, Body: %s", c.statusCode, string(c.body)))
|
||||
if len(c.header) > 0 {
|
||||
buf.WriteString(" Header: ")
|
||||
for key, value := range c.header {
|
||||
buf.WriteString(fmt.Sprintf("\n - %v=%v", key, value))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("]")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (c *ApiException) StatusCode() int {
|
||||
return c.statusCode
|
||||
}
|
||||
|
||||
func (c *ApiException) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *ApiException) Body() []byte {
|
||||
return c.body
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorCode() string {
|
||||
return c.errorCode
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorMessage() string {
|
||||
return c.errorMessage
|
||||
}
|
||||
|
||||
func NewApiException(statusCode int, header http.Header, body []byte) error {
|
||||
ret := &ApiException{
|
||||
statusCode: statusCode,
|
||||
header: header,
|
||||
body: body,
|
||||
}
|
||||
|
||||
bodyObject := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &bodyObject); err == nil {
|
||||
if val, ok := bodyObject["code"]; ok {
|
||||
ret.errorCode = val.(string)
|
||||
}
|
||||
if val, ok := bodyObject["message"]; ok {
|
||||
ret.errorMessage = val.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func Time(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func String(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func Bytes(b []byte) *[]byte {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Bool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Float64(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Float32(f float32) *float32 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Int64(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func Int32(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func generateHashFromStream(reader io.Reader, hashFunc func() hash.Hash, algorithmName string) (string, error) {
|
||||
hash := hashFunc()
|
||||
if _, err := io.Copy(hash, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for %s: %w", algorithmName, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func GenerateSHA256FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha256.New, "SHA256")
|
||||
}
|
||||
|
||||
func GenerateSHA1FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha1.New, "SHA1")
|
||||
}
|
||||
|
||||
func GenerateSM3FromStream(reader io.Reader) (string, error) {
|
||||
h := sm3.New()
|
||||
if _, err := io.Copy(h, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for SM3: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 取消订单
|
||||
*/
|
||||
public class CancelPartnerServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/partner/serviceorder/{out_order_no}/cancel";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
CancelPartnerServiceOrder client = new CancelPartnerServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CancelPartnerServiceOrderRequest request = new CancelPartnerServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.subMchid = "1900000109";
|
||||
request.reason = "用户投诉";
|
||||
try {
|
||||
client.run(request);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(CancelPartnerServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CancelPartnerServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CancelPartnerServiceOrderRequest {
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 完结订单
|
||||
*/
|
||||
public class CompletePartnerServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/partner/serviceorder/{out_order_no}/complete";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
CompletePartnerServiceOrder client = new CompletePartnerServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CompletePartnerServiceOrderRequest request = new CompletePartnerServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.subMchid = "1900000109";
|
||||
request.postPayments = new ArrayList<>();
|
||||
{
|
||||
Payment postPaymentsItem = new Payment();
|
||||
postPaymentsItem.name = "就餐费用";
|
||||
postPaymentsItem.amount = 40000L;
|
||||
postPaymentsItem.description = "就餐人均100元";
|
||||
postPaymentsItem.count = 4L;
|
||||
request.postPayments.add(postPaymentsItem);
|
||||
};
|
||||
request.postDiscounts = new ArrayList<>();
|
||||
{
|
||||
ServiceOrderCoupon postDiscountsItem = new ServiceOrderCoupon();
|
||||
postDiscountsItem.name = "满20减1元";
|
||||
postDiscountsItem.description = "不与其他优惠叠加";
|
||||
postDiscountsItem.amount = 100L;
|
||||
postDiscountsItem.count = 2L;
|
||||
request.postDiscounts.add(postDiscountsItem);
|
||||
};
|
||||
request.totalAmount = 50000L;
|
||||
request.timeRange = new TimeRange();
|
||||
request.timeRange.startTime = "20091225091010";
|
||||
request.timeRange.endTime = "20091225121010";
|
||||
request.timeRange.startTimeRemark = "备注1";
|
||||
request.timeRange.endTimeRemark = "备注2";
|
||||
request.location = new Location();
|
||||
request.location.startLocation = "嗨客时尚主题展餐厅";
|
||||
request.location.endLocation = "嗨客时尚主题展餐厅";
|
||||
request.profitSharing = false;
|
||||
request.completeTime = "2019-11-11T16:24:05+08:00";
|
||||
request.goodsTag = "goods_tag";
|
||||
request.device = new Device();
|
||||
request.device.startDeviceId = "HG123456";
|
||||
request.device.endDeviceId = "HG123456";
|
||||
request.device.materielNo = "example_materiel_no";
|
||||
try {
|
||||
client.run(request);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(CompletePartnerServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CompletePartnerServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CompletePartnerServiceOrderRequest {
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments = new ArrayList<Payment>();
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("profit_sharing")
|
||||
public Boolean profitSharing;
|
||||
|
||||
@SerializedName("complete_time")
|
||||
public String completeTime;
|
||||
|
||||
@SerializedName("goods_tag")
|
||||
public String goodsTag;
|
||||
|
||||
@SerializedName("device")
|
||||
public Device device;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Device {
|
||||
@SerializedName("start_device_id")
|
||||
public String startDeviceId;
|
||||
|
||||
@SerializedName("end_device_id")
|
||||
public String endDeviceId;
|
||||
|
||||
@SerializedName("materiel_no")
|
||||
public String materielNo;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 创建订单
|
||||
*/
|
||||
public class CreatePartnerServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/partner/serviceorder";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
CreatePartnerServiceOrder client = new CreatePartnerServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreatePartnerServiceOrderRequest request = new CreatePartnerServiceOrderRequest();
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.appid = "wxd678efh567hg6787";
|
||||
request.subMchid = "1900000109";
|
||||
request.subAppid = "wxd678efh567hg6999";
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.serviceIntroduction = "XX充电宝";
|
||||
request.postPayments = new ArrayList<>();
|
||||
{
|
||||
Payment postPaymentsItem = new Payment();
|
||||
postPaymentsItem.name = "就餐费用";
|
||||
postPaymentsItem.amount = 40000L;
|
||||
postPaymentsItem.description = "就餐人均100元";
|
||||
postPaymentsItem.count = 4L;
|
||||
request.postPayments.add(postPaymentsItem);
|
||||
};
|
||||
request.postDiscounts = new ArrayList<>();
|
||||
{
|
||||
ServiceOrderCoupon postDiscountsItem = new ServiceOrderCoupon();
|
||||
postDiscountsItem.name = "满20减1元";
|
||||
postDiscountsItem.description = "不与其他优惠叠加";
|
||||
postDiscountsItem.amount = 100L;
|
||||
postDiscountsItem.count = 2L;
|
||||
request.postDiscounts.add(postDiscountsItem);
|
||||
};
|
||||
request.riskFund = new RiskFund();
|
||||
request.riskFund.name = "DEPOSIT";
|
||||
request.riskFund.amount = 10000L;
|
||||
request.riskFund.description = "就餐的预估费用";
|
||||
request.timeRange = new TimeRange();
|
||||
request.timeRange.startTime = "20091225091010";
|
||||
request.timeRange.endTime = "20091225121010";
|
||||
request.timeRange.startTimeRemark = "备注1";
|
||||
request.timeRange.endTimeRemark = "备注2";
|
||||
request.location = new Location();
|
||||
request.location.startLocation = "嗨客时尚主题展餐厅";
|
||||
request.location.endLocation = "嗨客时尚主题展餐厅";
|
||||
request.needUserConfirm = false;
|
||||
request.notifyUrl = "https://api.test.com";
|
||||
request.attach = "Easdfowealsdkjfnlaksjdlfkwqoi&wl3l2sald";
|
||||
request.device = new Device();
|
||||
request.device.startDeviceId = "HG123456";
|
||||
request.device.endDeviceId = "HG123456";
|
||||
request.device.materielNo = "example_materiel_no";
|
||||
try {
|
||||
CreatePartnerServiceOrderResponse response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public CreatePartnerServiceOrderResponse run(CreatePartnerServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, CreatePartnerServiceOrderResponse.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public CreatePartnerServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CreatePartnerServiceOrderRequest {
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("sub_appid")
|
||||
public String subAppid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("need_user_confirm")
|
||||
public Boolean needUserConfirm;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("device")
|
||||
public Device device;
|
||||
}
|
||||
|
||||
public static class CreatePartnerServiceOrderResponse {
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("sub_appid")
|
||||
public String subAppid;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
|
||||
@SerializedName("package")
|
||||
public String _package;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Device {
|
||||
@SerializedName("start_device_id")
|
||||
public String startDeviceId;
|
||||
|
||||
@SerializedName("end_device_id")
|
||||
public String endDeviceId;
|
||||
|
||||
@SerializedName("materiel_no")
|
||||
public String materielNo;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 修改订单金额
|
||||
*/
|
||||
public class ModifyPartnerServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/partner/serviceorder/{out_order_no}/modify";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
ModifyPartnerServiceOrder client = new ModifyPartnerServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
ModifyPartnerServiceOrderRequest request = new ModifyPartnerServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.subMchid = "1900000109";
|
||||
request.postPayments = new ArrayList<>();
|
||||
{
|
||||
Payment postPaymentsItem = new Payment();
|
||||
postPaymentsItem.name = "就餐费用";
|
||||
postPaymentsItem.amount = 40000L;
|
||||
postPaymentsItem.description = "就餐人均100元";
|
||||
postPaymentsItem.count = 4L;
|
||||
request.postPayments.add(postPaymentsItem);
|
||||
};
|
||||
request.postDiscounts = new ArrayList<>();
|
||||
{
|
||||
ServiceOrderCoupon postDiscountsItem = new ServiceOrderCoupon();
|
||||
postDiscountsItem.name = "满20减1元";
|
||||
postDiscountsItem.description = "不与其他优惠叠加";
|
||||
postDiscountsItem.amount = 100L;
|
||||
postDiscountsItem.count = 2L;
|
||||
request.postDiscounts.add(postDiscountsItem);
|
||||
};
|
||||
request.totalAmount = 50000L;
|
||||
request.reason = "用户投诉";
|
||||
request.device = new Device();
|
||||
request.device.startDeviceId = "HG123456";
|
||||
request.device.endDeviceId = "HG123456";
|
||||
request.device.materielNo = "example_materiel_no";
|
||||
try {
|
||||
client.run(request);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(ModifyPartnerServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public ModifyPartnerServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class ModifyPartnerServiceOrderRequest {
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public List<Payment> postPayments = new ArrayList<Payment>();
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
|
||||
@SerializedName("device")
|
||||
public Device device;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class Device {
|
||||
@SerializedName("start_device_id")
|
||||
public String startDeviceId;
|
||||
|
||||
@SerializedName("end_device_id")
|
||||
public String endDeviceId;
|
||||
|
||||
@SerializedName("materiel_no")
|
||||
public String materielNo;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询订单
|
||||
*/
|
||||
public class GetPartnerServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/payscore/partner/serviceorder";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
GetPartnerServiceOrder client = new GetPartnerServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
GetPartnerServiceOrderRequest request = new GetPartnerServiceOrderRequest();
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.subMchid = "1900000109";
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.queryId = "15646546545165651651";
|
||||
try {
|
||||
ServiceOrderEntity response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public ServiceOrderEntity run(GetPartnerServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("service_id", request.serviceId);
|
||||
args.put("sub_mchid", request.subMchid);
|
||||
args.put("out_order_no", request.outOrderNo);
|
||||
args.put("query_id", request.queryId);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, ServiceOrderEntity.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public GetPartnerServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class GetPartnerServiceOrderRequest {
|
||||
@SerializedName("service_id")
|
||||
@Expose(serialize = false)
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
@Expose(serialize = false)
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("query_id")
|
||||
@Expose(serialize = false)
|
||||
public String queryId;
|
||||
}
|
||||
|
||||
public static class ServiceOrderEntity {
|
||||
@SerializedName("out_order_no")
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("appid")
|
||||
public String appid;
|
||||
|
||||
@SerializedName("mchid")
|
||||
public String mchid;
|
||||
|
||||
@SerializedName("sub_appid")
|
||||
public String subAppid;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("service_introduction")
|
||||
public String serviceIntroduction;
|
||||
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("state_description")
|
||||
public String stateDescription;
|
||||
|
||||
@SerializedName("post_payments")
|
||||
public Payment postPayments;
|
||||
|
||||
@SerializedName("post_discounts")
|
||||
public List<ServiceOrderCoupon> postDiscounts;
|
||||
|
||||
@SerializedName("risk_fund")
|
||||
public RiskFund riskFund;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("need_collection")
|
||||
public Boolean needCollection;
|
||||
|
||||
@SerializedName("collection")
|
||||
public Collection collection;
|
||||
|
||||
@SerializedName("time_range")
|
||||
public TimeRange timeRange;
|
||||
|
||||
@SerializedName("location")
|
||||
public Location location;
|
||||
|
||||
@SerializedName("attach")
|
||||
public String attach;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("openid")
|
||||
public String openid;
|
||||
|
||||
@SerializedName("sub_openid")
|
||||
public String subOpenid;
|
||||
|
||||
@SerializedName("order_id")
|
||||
public String orderId;
|
||||
}
|
||||
|
||||
public static class Payment {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class ServiceOrderCoupon {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("count")
|
||||
public Long count;
|
||||
}
|
||||
|
||||
public static class RiskFund {
|
||||
@SerializedName("name")
|
||||
public String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("description")
|
||||
public String description;
|
||||
}
|
||||
|
||||
public static class Collection {
|
||||
@SerializedName("state")
|
||||
public String state;
|
||||
|
||||
@SerializedName("total_amount")
|
||||
public Long totalAmount;
|
||||
|
||||
@SerializedName("paying_amount")
|
||||
public Long payingAmount;
|
||||
|
||||
@SerializedName("paid_amount")
|
||||
public Long paidAmount;
|
||||
|
||||
@SerializedName("details")
|
||||
public List<Detail> details;
|
||||
}
|
||||
|
||||
public static class TimeRange {
|
||||
@SerializedName("start_time")
|
||||
public String startTime;
|
||||
|
||||
@SerializedName("end_time")
|
||||
public String endTime;
|
||||
|
||||
@SerializedName("start_time_remark")
|
||||
public String startTimeRemark;
|
||||
|
||||
@SerializedName("end_time_remark")
|
||||
public String endTimeRemark;
|
||||
}
|
||||
|
||||
public static class Location {
|
||||
@SerializedName("start_location")
|
||||
public String startLocation;
|
||||
|
||||
@SerializedName("end_location")
|
||||
public String endLocation;
|
||||
}
|
||||
|
||||
public static class Detail {
|
||||
@SerializedName("seq")
|
||||
public Long seq;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("paid_type")
|
||||
public String paidType;
|
||||
|
||||
@SerializedName("paid_time")
|
||||
public String paidTime;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 同步订单信息
|
||||
*/
|
||||
public class SyncPartnerServiceOrder {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/payscore/partner/serviceorder/{out_order_no}/sync";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
SyncPartnerServiceOrder client = new SyncPartnerServiceOrder(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
SyncPartnerServiceOrderRequest request = new SyncPartnerServiceOrderRequest();
|
||||
request.outOrderNo = "1234323JKHDFE1243252";
|
||||
request.serviceId = "2002000000000558128851361561536";
|
||||
request.subMchid = "1900000109";
|
||||
request.type = "Order_Paid";
|
||||
request.detail = new SyncDetail();
|
||||
request.detail.paidTime = "20091225091210";
|
||||
try {
|
||||
client.run(request);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public void run(SyncPartnerServiceOrderRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_order_no}", WXPayUtility.urlEncode(request.outOrderNo));
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
return;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public SyncPartnerServiceOrder(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class SyncPartnerServiceOrderRequest {
|
||||
@SerializedName("service_id")
|
||||
public String serviceId;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("out_order_no")
|
||||
@Expose(serialize = false)
|
||||
public String outOrderNo;
|
||||
|
||||
@SerializedName("type")
|
||||
public String type;
|
||||
|
||||
@SerializedName("detail")
|
||||
public SyncDetail detail;
|
||||
}
|
||||
|
||||
public static class SyncDetail {
|
||||
@SerializedName("paid_time")
|
||||
public String paidTime;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 退款申请
|
||||
*/
|
||||
public class Create {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "POST";
|
||||
private static String PATH = "/v3/refund/domestic/refunds";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
Create client = new Create(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
CreateRequest request = new CreateRequest();
|
||||
request.subMchid = "1900000109";
|
||||
request.transactionId = "1217752501201407033233368018";
|
||||
request.outTradeNo = "1217752501201407033233368018";
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
request.reason = "商品已售完";
|
||||
request.notifyUrl = "https://weixin.qq.com";
|
||||
request.fundsAccount = ReqFundsAccount.AVAILABLE;
|
||||
request.amount = new AmountReq();
|
||||
request.amount.refund = 888L;
|
||||
request.amount.from = new ArrayList<>();
|
||||
{
|
||||
FundsFromItem fromItem = new FundsFromItem();
|
||||
fromItem.account = Account.AVAILABLE;
|
||||
fromItem.amount = 444L;
|
||||
request.amount.from.add(fromItem);
|
||||
};
|
||||
request.amount.total = 888L;
|
||||
request.amount.currency = "CNY";
|
||||
request.goodsDetail = new ArrayList<>();
|
||||
{
|
||||
GoodsDetail goodsDetailItem = new GoodsDetail();
|
||||
goodsDetailItem.merchantGoodsId = "1217752501201407033233368018";
|
||||
goodsDetailItem.wechatpayGoodsId = "1001";
|
||||
goodsDetailItem.goodsName = "iPhone6s 16G";
|
||||
goodsDetailItem.unitPrice = 528800L;
|
||||
goodsDetailItem.refundAmount = 528800L;
|
||||
goodsDetailItem.refundQuantity = 1L;
|
||||
request.goodsDetail.add(goodsDetailItem);
|
||||
};
|
||||
request.refundAccount = RefundAccount.REFUND_SOURCE_SUB_MERCHANT;
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(CreateRequest request) {
|
||||
String uri = PATH;
|
||||
String reqBody = WXPayUtility.toJson(request);
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo,privateKey, METHOD, uri, reqBody));
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody requestBody = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(METHOD, requestBody);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public Create(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class CreateRequest {
|
||||
@SerializedName("sub_mchid")
|
||||
public String subMchid;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("reason")
|
||||
public String reason;
|
||||
|
||||
@SerializedName("notify_url")
|
||||
public String notifyUrl;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public ReqFundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public AmountReq amount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
|
||||
@SerializedName("refund_account")
|
||||
public RefundAccount refundAccount;
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
@SerializedName("refund_account")
|
||||
public RefundAccount refundAccount;
|
||||
}
|
||||
|
||||
public enum ReqFundsAccount {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED
|
||||
}
|
||||
|
||||
public static class AmountReq {
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
public enum RefundAccount {
|
||||
@SerializedName("REFUND_SOURCE_PARTNER_ADVANCE")
|
||||
REFUND_SOURCE_PARTNER_ADVANCE,
|
||||
@SerializedName("REFUND_SOURCE_SUB_MERCHANT")
|
||||
REFUND_SOURCE_SUB_MERCHANT,
|
||||
@SerializedName("REFUND_SOURCE_SUB_MERCHANT_ADVANCE")
|
||||
REFUND_SOURCE_SUB_MERCHANT_ADVANCE
|
||||
}
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
|
||||
@SerializedName("advance")
|
||||
public Long advance;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("COUPON")
|
||||
COUPON,
|
||||
@SerializedName("DISCOUNT")
|
||||
DISCOUNT
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.java.demo;
|
||||
|
||||
import com.java.utils.WXPayUtility; // 引用微信支付工具库,参考:https://pay.weixin.qq.com/doc/v3/partner/4014985777
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 查询单笔退款(通过商户退款单号)
|
||||
*/
|
||||
public class QueryByOutRefundNo {
|
||||
private static String HOST = "https://api.mch.weixin.qq.com";
|
||||
private static String METHOD = "GET";
|
||||
private static String PATH = "/v3/refund/domestic/refunds/{out_refund_no}";
|
||||
|
||||
public static void main(String[] args) {
|
||||
// TODO: 请准备商户开发必要参数,参考:https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
QueryByOutRefundNo client = new QueryByOutRefundNo(
|
||||
"19xxxxxxxx", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/partner/4013080340
|
||||
"1DDE55AD98Exxxxxxxxxx", // 商户API证书序列号,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013058924
|
||||
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径,本地文件路径
|
||||
"PUB_KEY_ID_xxxxxxxxxxxxx", // 微信支付公钥ID,如何获取请参考 https://pay.weixin.qq.com/doc/v3/partner/4013038589
|
||||
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
|
||||
);
|
||||
|
||||
QueryByOutRefundNoRequest request = new QueryByOutRefundNoRequest();
|
||||
request.outRefundNo = "1217752501201407033233368018";
|
||||
request.subMchid = "1900000109";
|
||||
try {
|
||||
Refund response = client.run(request);
|
||||
// TODO: 请求成功,继续业务逻辑
|
||||
System.out.println(response);
|
||||
} catch (WXPayUtility.ApiException e) {
|
||||
// TODO: 请求失败,根据状态码执行不同的逻辑
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
public Refund run(QueryByOutRefundNoRequest request) {
|
||||
String uri = PATH;
|
||||
uri = uri.replace("{out_refund_no}", WXPayUtility.urlEncode(request.outRefundNo));
|
||||
Map<String, Object> args = new HashMap<>();
|
||||
args.put("sub_mchid", request.subMchid);
|
||||
String queryString = WXPayUtility.urlEncode(args);
|
||||
if (!queryString.isEmpty()) {
|
||||
uri = uri + "?" + queryString;
|
||||
}
|
||||
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(mchid, certificateSerialNo, privateKey, METHOD, uri, null));
|
||||
reqBuilder.method(METHOD, null);
|
||||
Request httpRequest = reqBuilder.build();
|
||||
|
||||
// 发送HTTP请求
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(httpRequest).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
// 2XX 成功,验证应答签名
|
||||
WXPayUtility.validateResponse(this.wechatPayPublicKeyId, this.wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
|
||||
// 从HTTP应答报文构建返回数据
|
||||
return WXPayUtility.fromJson(respBody, Refund.class);
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public QueryByOutRefundNo(String mchid, String certificateSerialNo, String privateKeyFilePath, String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
public static class QueryByOutRefundNoRequest {
|
||||
@SerializedName("out_refund_no")
|
||||
@Expose(serialize = false)
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("sub_mchid")
|
||||
@Expose(serialize = false)
|
||||
public String subMchid;
|
||||
}
|
||||
|
||||
public static class Refund {
|
||||
@SerializedName("refund_id")
|
||||
public String refundId;
|
||||
|
||||
@SerializedName("out_refund_no")
|
||||
public String outRefundNo;
|
||||
|
||||
@SerializedName("transaction_id")
|
||||
public String transactionId;
|
||||
|
||||
@SerializedName("out_trade_no")
|
||||
public String outTradeNo;
|
||||
|
||||
@SerializedName("channel")
|
||||
public Channel channel;
|
||||
|
||||
@SerializedName("user_received_account")
|
||||
public String userReceivedAccount;
|
||||
|
||||
@SerializedName("success_time")
|
||||
public String successTime;
|
||||
|
||||
@SerializedName("create_time")
|
||||
public String createTime;
|
||||
|
||||
@SerializedName("status")
|
||||
public Status status;
|
||||
|
||||
@SerializedName("funds_account")
|
||||
public FundsAccount fundsAccount;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Amount amount;
|
||||
|
||||
@SerializedName("promotion_detail")
|
||||
public List<Promotion> promotionDetail;
|
||||
|
||||
@SerializedName("refund_account")
|
||||
public RefundAccount refundAccount;
|
||||
}
|
||||
|
||||
public enum Channel {
|
||||
@SerializedName("ORIGINAL")
|
||||
ORIGINAL,
|
||||
@SerializedName("BALANCE")
|
||||
BALANCE,
|
||||
@SerializedName("OTHER_BALANCE")
|
||||
OTHER_BALANCE,
|
||||
@SerializedName("OTHER_BANKCARD")
|
||||
OTHER_BANKCARD
|
||||
}
|
||||
|
||||
public enum Status {
|
||||
@SerializedName("SUCCESS")
|
||||
SUCCESS,
|
||||
@SerializedName("CLOSED")
|
||||
CLOSED,
|
||||
@SerializedName("PROCESSING")
|
||||
PROCESSING,
|
||||
@SerializedName("ABNORMAL")
|
||||
ABNORMAL
|
||||
}
|
||||
|
||||
public enum FundsAccount {
|
||||
@SerializedName("UNSETTLED")
|
||||
UNSETTLED,
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE,
|
||||
@SerializedName("OPERATION")
|
||||
OPERATION,
|
||||
@SerializedName("BASIC")
|
||||
BASIC,
|
||||
@SerializedName("ECNY_BASIC")
|
||||
ECNY_BASIC
|
||||
}
|
||||
|
||||
public static class Amount {
|
||||
@SerializedName("total")
|
||||
public Long total;
|
||||
|
||||
@SerializedName("refund")
|
||||
public Long refund;
|
||||
|
||||
@SerializedName("from")
|
||||
public List<FundsFromItem> from;
|
||||
|
||||
@SerializedName("payer_total")
|
||||
public Long payerTotal;
|
||||
|
||||
@SerializedName("payer_refund")
|
||||
public Long payerRefund;
|
||||
|
||||
@SerializedName("settlement_refund")
|
||||
public Long settlementRefund;
|
||||
|
||||
@SerializedName("settlement_total")
|
||||
public Long settlementTotal;
|
||||
|
||||
@SerializedName("discount_refund")
|
||||
public Long discountRefund;
|
||||
|
||||
@SerializedName("currency")
|
||||
public String currency;
|
||||
|
||||
@SerializedName("refund_fee")
|
||||
public Long refundFee;
|
||||
|
||||
@SerializedName("advance")
|
||||
public Long advance;
|
||||
}
|
||||
|
||||
public static class Promotion {
|
||||
@SerializedName("promotion_id")
|
||||
public String promotionId;
|
||||
|
||||
@SerializedName("scope")
|
||||
public PromotionScope scope;
|
||||
|
||||
@SerializedName("type")
|
||||
public PromotionType type;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("goods_detail")
|
||||
public List<GoodsDetail> goodsDetail;
|
||||
}
|
||||
|
||||
public enum RefundAccount {
|
||||
@SerializedName("REFUND_SOURCE_PARTNER_ADVANCE")
|
||||
REFUND_SOURCE_PARTNER_ADVANCE,
|
||||
@SerializedName("REFUND_SOURCE_SUB_MERCHANT")
|
||||
REFUND_SOURCE_SUB_MERCHANT,
|
||||
@SerializedName("REFUND_SOURCE_SUB_MERCHANT_ADVANCE")
|
||||
REFUND_SOURCE_SUB_MERCHANT_ADVANCE
|
||||
}
|
||||
|
||||
public static class FundsFromItem {
|
||||
@SerializedName("account")
|
||||
public Account account;
|
||||
|
||||
@SerializedName("amount")
|
||||
public Long amount;
|
||||
}
|
||||
|
||||
public enum PromotionScope {
|
||||
@SerializedName("GLOBAL")
|
||||
GLOBAL,
|
||||
@SerializedName("SINGLE")
|
||||
SINGLE
|
||||
}
|
||||
|
||||
public enum PromotionType {
|
||||
@SerializedName("COUPON")
|
||||
COUPON,
|
||||
@SerializedName("DISCOUNT")
|
||||
DISCOUNT
|
||||
}
|
||||
|
||||
public static class GoodsDetail {
|
||||
@SerializedName("merchant_goods_id")
|
||||
public String merchantGoodsId;
|
||||
|
||||
@SerializedName("wechatpay_goods_id")
|
||||
public String wechatpayGoodsId;
|
||||
|
||||
@SerializedName("goods_name")
|
||||
public String goodsName;
|
||||
|
||||
@SerializedName("unit_price")
|
||||
public Long unitPrice;
|
||||
|
||||
@SerializedName("refund_amount")
|
||||
public Long refundAmount;
|
||||
|
||||
@SerializedName("refund_quantity")
|
||||
public Long refundQuantity;
|
||||
}
|
||||
|
||||
public enum Account {
|
||||
@SerializedName("AVAILABLE")
|
||||
AVAILABLE,
|
||||
@SerializedName("UNAVAILABLE")
|
||||
UNAVAILABLE
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# JSAPI / 小程序调起支付分确认订单页(服务商)
|
||||
|
||||
> 源文档:[JSAPI调起支付分确认订单页](https://pay.weixin.qq.com/doc/v3/partner/4012607505.md)
|
||||
> 小程序入口:[wx.openBusinessView](https://pay.weixin.qq.com/doc/v3/partner/4012607510.md)
|
||||
> APP 端入口:[Android](https://pay.weixin.qq.com/doc/v3/partner/4012607507.md) / [iOS](https://pay.weixin.qq.com/doc/v3/partner/4012607508.md) / [鸿蒙](https://pay.weixin.qq.com/doc/v3/partner/4015271745.md)
|
||||
|
||||
## 接入说明
|
||||
|
||||
1. 服务商后端调用「创建支付分订单(服务商)」([CreatePayScoreOrder.java](../1-订单管理/CreatePayScoreOrder.java) / [create_payscore_order.go](../../Go/1-订单管理/create_payscore_order.go)),请求体包含 `sub_mchid`,并将 `need_user_confirm = true`。
|
||||
2. 接口返回 `package` 字段,由服务商后端组装后下发给前端。
|
||||
3. 前端通过 `WeixinJSBridge.invoke('openBusinessView', ...)` 或 `wx.openBusinessView(...)` 拉起确认订单页。
|
||||
4. 用户确认后,微信回调到 **服务商** 的 `notify_url`,服务商按 `sub_mchid` 路由到特约商户业务(参考 [5-回调通知/确认订单回调通知说明.md](../5-回调通知/确认订单回调通知说明.md))。
|
||||
|
||||
## 公众号 H5 示例代码
|
||||
|
||||
```javascript
|
||||
function onBridgeReady() {
|
||||
WeixinJSBridge.invoke(
|
||||
'openBusinessView',
|
||||
{
|
||||
businessType: 'wxpayScoreUse',
|
||||
queryString: '<package_in_create_response>'
|
||||
},
|
||||
function (res) {
|
||||
if (res.err_msg === 'open_business_view:ok') {
|
||||
// 用户已点击同意,业务成功仍以"确认订单回调通知"或"查询支付分订单"为准
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
if (typeof WeixinJSBridge === 'undefined') {
|
||||
document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
|
||||
} else {
|
||||
onBridgeReady();
|
||||
}
|
||||
```
|
||||
|
||||
## 小程序示例代码
|
||||
|
||||
```javascript
|
||||
wx.openBusinessView({
|
||||
businessType: 'wxpayScoreUse',
|
||||
extraData: {
|
||||
// 服务商场景下 package 字段含 sp_mchid + sub_mchid + service_id 等,前端需按 package URL Decode 后逐项填入
|
||||
sp_mchid: 'sp_mchid_from_package',
|
||||
sub_mchid: 'sub_mchid_from_package',
|
||||
service_id: 'service_id_from_package',
|
||||
out_order_no:'out_order_no_from_package',
|
||||
timestamp: 'timestamp_from_package',
|
||||
nonce_str: 'nonce_str_from_package',
|
||||
sign_type: 'HMAC-SHA256',
|
||||
signature: 'signature_from_package'
|
||||
},
|
||||
success(res) { /* ... */ },
|
||||
fail(err) { /* ... */ }
|
||||
});
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 项 | 要求 |
|
||||
|----|------|
|
||||
| `signature` 算法 | **服务商 APIv2 密钥 + HMAC-SHA256**(不是子商户的、也不是 APIv3 私钥) |
|
||||
| `appid` 一致性 | 拉起的 `appid` 必须等于 `CreateServiceOrder` 时的 `sub_appid`(无 sub_appid 时用 `sp_appid`) |
|
||||
| `package` | 必须取自后端创建订单应答;前端禁止自行拼接 |
|
||||
| 业务判定 | 前端 `success` 仅代表用户同意 UI;业务成功以回调 / 查单为准 |
|
||||
@@ -0,0 +1,25 @@
|
||||
# JSAPI / 小程序调起支付分订单详情页(服务商)
|
||||
|
||||
> 源文档:[JSAPI调起支付分订单详情页](https://pay.weixin.qq.com/doc/v3/partner/4012607518.md)
|
||||
> 小程序入口:[wx.openBusinessView](https://pay.weixin.qq.com/doc/v3/partner/4012607516.md)
|
||||
> APP 端入口:[Android](https://pay.weixin.qq.com/doc/v3/partner/4012607513.md) / [iOS](https://pay.weixin.qq.com/doc/v3/partner/4012607514.md) / [鸿蒙](https://pay.weixin.qq.com/doc/v3/partner/4015271776.md)
|
||||
|
||||
## 接入说明
|
||||
|
||||
服务商在用户使用过程或订单完结后,让用户在微信内查看支付分订单详情时,可调起「订单详情页」。`query_string` 由 **服务商后端** 拼接并使用 **服务商 APIv2 密钥 + HMAC-SHA256** 签名,详见 [签名与验签规则.md](../../../接入指南/签名与验签规则.md) 中「客户端拉起签名(V2)」小节。
|
||||
|
||||
公众号 H5 / 小程序 / APP 端代码与「确认订单页」相同,仅 `businessType` 改为 `wxpayScoreDetail`:
|
||||
|
||||
```javascript
|
||||
WeixinJSBridge.invoke(
|
||||
'openBusinessView',
|
||||
{ businessType: 'wxpayScoreDetail', queryString: '<query_string>' },
|
||||
function (res) { /* ... */ }
|
||||
);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `appid` 必须与「创建支付分订单(服务商)」时的 `sub_appid` 一致。
|
||||
- 详情页只读不可改:用户的修改 / 退款行为请走服务商后台对应接口。
|
||||
- 仅当订单已存在时可调起。
|
||||
@@ -0,0 +1,44 @@
|
||||
# APP 调起支付分(服务商)
|
||||
|
||||
> 源文档:
|
||||
> - Android 确认订单页:[4012607507](https://pay.weixin.qq.com/doc/v3/partner/4012607507.md)
|
||||
> - iOS 确认订单页:[4012607508](https://pay.weixin.qq.com/doc/v3/partner/4012607508.md)
|
||||
> - 鸿蒙 确认订单页:[4015271745](https://pay.weixin.qq.com/doc/v3/partner/4015271745.md)
|
||||
> - Android 订单详情页:[4012607513](https://pay.weixin.qq.com/doc/v3/partner/4012607513.md)
|
||||
> - iOS 订单详情页:[4012607514](https://pay.weixin.qq.com/doc/v3/partner/4012607514.md)
|
||||
> - 鸿蒙 订单详情页:[4015271776](https://pay.weixin.qq.com/doc/v3/partner/4015271776.md)
|
||||
|
||||
## 接入说明
|
||||
|
||||
APP 端调起的参数由 **服务商后端**根据创建订单应答的 `package` 字段拼接、并按 **服务商 APIv2 密钥 + HMAC-SHA256** 计算 `signature`,下发给 APP。
|
||||
|
||||
APP 端通过微信开放平台 SDK 的 `WXOpenBusinessView`(Android) / `WXOpenBusinessViewReq`(iOS) 调起。
|
||||
|
||||
## Android 关键代码
|
||||
|
||||
```java
|
||||
WXOpenBusinessView req = new WXOpenBusinessView();
|
||||
req.businessType = "wxpayScoreUse"; // 详情页用 wxpayScoreDetail
|
||||
req.query = "sp_mchid=xxx&sub_mchid=xxx&service_id=xxx&out_order_no=xxx×tamp=xxx&nonce_str=xxx&sign_type=HMAC-SHA256&signature=xxx";
|
||||
req.extInfo = "{\"miniProgramType\":0}";
|
||||
api.sendReq(req);
|
||||
```
|
||||
|
||||
## iOS 关键代码
|
||||
|
||||
```objective-c
|
||||
WXOpenBusinessViewReq *req = [[WXOpenBusinessViewReq alloc] init];
|
||||
req.businessType = @"wxpayScoreUse"; // 详情页用 wxpayScoreDetail
|
||||
req.query = @"sp_mchid=xxx&sub_mchid=xxx&service_id=xxx&out_order_no=xxx×tamp=xxx&nonce_str=xxx&sign_type=HMAC-SHA256&signature=xxx";
|
||||
req.extInfo = @"{\"miniProgramType\":0}";
|
||||
[WXApi sendReq:req completion:nil];
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
| 项 | 要求 |
|
||||
|---|---|
|
||||
| 微信版本 | Android ≥ 7.0.5、iOS ≥ 7.0.5 |
|
||||
| `appid` | APP 注册的 `appid` 必须等于服务端创建订单时的 `sub_appid`(无 sub_appid 时使用 `sp_appid`) |
|
||||
| 业务结果 | APP 回调 `errCode == 0` 仅代表用户操作完成,业务成功以「确认订单回调」或「查询支付分订单」为准 |
|
||||
| 鉴权失败 | `SIGN_ERROR` 多由"误用 APIv3 私钥代替 APIv2 密钥"导致,详见 [签名与验签规则.md](../../../接入指南/签名与验签规则.md) |
|
||||
@@ -0,0 +1,51 @@
|
||||
# 支付成功回调通知(服务商)
|
||||
|
||||
> 源文档:[支付成功回调通知](https://pay.weixin.qq.com/doc/v3/partner/4012586136.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
完结订单后,若订单 `need_collection = true`,微信支付分异步发起代扣,并在收款进展变化时回推本通知。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_PAID",
|
||||
"summary": "用户支付成功",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用服务商 APIv3 密钥解密>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 解密后关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `sp_mchid` / `sub_mchid` | 服务商号、特约商户号 |
|
||||
| `out_order_no` | 商户订单号 |
|
||||
| `state` | `DONE` |
|
||||
| `collection.state` | 终态多为 `USER_PAID` |
|
||||
| `collection.paid_amount` | 已收款金额(分),需累计 |
|
||||
| `collection.details[]` | 每笔扣款明细:`amount` / `paid_type` / `paid_time` / `transaction_id` |
|
||||
| `transaction_id` | **特约商户**收款的支付单号;用于资金对账与分账请求 |
|
||||
|
||||
## 服务商处理要求
|
||||
|
||||
1. **路由 + 幂等**:以 `sub_mchid` 路由 → 以 `out_order_no` 幂等。
|
||||
2. **状态机**:终态前可能多次回推(多笔代扣),需累加 `paid_amount` 写入流水表,避免覆盖。
|
||||
3. **资金归属**:扣款资金直接进入特约商户账户;服务商若有分润需求,请走「服务商分账」(基于此 `transaction_id`)。
|
||||
4. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`。
|
||||
|
||||
## 与商户模式的差异
|
||||
|
||||
- 资金最终进入 `sub_mchid` 账户(不是 `sp_mchid`)。
|
||||
- 分账请求需以 `sub_mchid` + `transaction_id` 调用「服务商分账」相关接口。
|
||||
@@ -0,0 +1,58 @@
|
||||
# 确认订单回调通知(服务商)
|
||||
|
||||
> 源文档:[确认订单回调通知](https://pay.weixin.qq.com/doc/v3/partner/4012586137.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
> 通用签名规则:[../../../接入指南/签名与验签规则.md](../../../接入指南/签名与验签规则.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
特约商户的用户在「确认订单」页面点击同意后,微信支付分以 **POST** 方式向 **服务商在创建订单时填写的 `notify_url`** 推送本通知。所有特约商户共用同一个 `notify_url`,服务商需以解密后报文中的 `sub_mchid` 为路由依据。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "PAYSCORE.USER_CONFIRM",
|
||||
"summary": "支付分订单用户已确认",
|
||||
"resource": {
|
||||
"original_type": "payscore",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用服务商 APIv3 密钥 + AEAD_AES_256_GCM 解密后得到 ServiceOrderEntity>",
|
||||
"associated_data": "transaction",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段(解密后)
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `sp_mchid` | 服务商商户号 |
|
||||
| `sp_appid` | 服务商 appid |
|
||||
| `sub_mchid` | **特约商户号**——服务商按此字段路由到对应特约商户的业务系统 |
|
||||
| `sub_appid` | 特约商户 appid(如有) |
|
||||
| `out_order_no` | 服务商生成的商户订单号,幂等键 |
|
||||
| `service_id` | 在该子商户上申请的支付分服务 ID |
|
||||
| `state` | `DOING`(用户已确认) |
|
||||
| `openid` | 用户在 sub_appid(无 sub_appid 时为 sp_appid)下的 openid |
|
||||
|
||||
## 服务商处理要求
|
||||
|
||||
1. **验签**:使用 **服务商 API 证书私钥对应的公钥** 验签(不是子商户的);微信支付公钥同样使用服务商体系下的公钥。
|
||||
2. **解密**:使用 **服务商 APIv3 密钥** 解密;不要使用子商户的密钥。
|
||||
3. **路由**:先按 `sub_mchid` 路由到对应特约商户的业务系统;按 `out_order_no + event_type` 幂等。
|
||||
4. **状态机**:将订单标记为 `DOING`,登记 `openid` / `order_id`,可开始提供服务。
|
||||
5. **应答**:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`;业务异常返回 5xx 触发重试。
|
||||
|
||||
## 与商户模式的差异
|
||||
|
||||
| 对比项 | 商户模式 | 服务商模式 |
|
||||
|--------|----------|-----------|
|
||||
| 解密密钥 | 商户 APIv3 密钥 | **服务商** APIv3 密钥(不能用子商户的) |
|
||||
| 验签证书 | 商户接入的微信支付公钥 | **服务商**接入的微信支付公钥 |
|
||||
| 路由字段 | `mchid` | `sub_mchid` |
|
||||
| openid 归属 | `appid` | `sub_appid` 或 `sp_appid` |
|
||||
@@ -0,0 +1,54 @@
|
||||
# 退款结果回调通知(服务商)
|
||||
|
||||
> 源文档:[退款结果通知](https://pay.weixin.qq.com/doc/v3/partner/4012586138.md)
|
||||
> 通用解密 / 验签 / 回包流程:[../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
|
||||
|
||||
## 触发场景
|
||||
|
||||
服务商代特约商户调用「申请退款」接口受理后,微信支付分异步处理实际退款,退款进入终态时回推本通知。
|
||||
|
||||
## 回调报文骨架
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "EV-2018022511223320873",
|
||||
"create_time": "2015-05-20T13:29:35+08:00",
|
||||
"resource_type": "encrypt-resource",
|
||||
"event_type": "REFUND.SUCCESS",
|
||||
"summary": "退款成功",
|
||||
"resource": {
|
||||
"original_type": "refund",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "<密文,使用服务商 APIv3 密钥解密>",
|
||||
"associated_data": "refund",
|
||||
"nonce": "<随机串>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## event_type 一览
|
||||
|
||||
| event_type | 含义 | 处理建议 |
|
||||
|------------|------|---------|
|
||||
| `REFUND.SUCCESS` | 退款成功 | 更新业务退款单 |
|
||||
| `REFUND.CLOSED` | 退款被关闭 | 检查 `error_msg` 与 `refund_status` |
|
||||
| `REFUND.ABNORMAL` | 退款异常 | 人工介入或调用「异常退款」接口补救 |
|
||||
|
||||
## 解密后关键字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `sp_mchid` / `sub_mchid` | 服务商号、特约商户号 |
|
||||
| `out_refund_no` | 商户退款单号(幂等键) |
|
||||
| `refund_id` | 微信侧退款单号 |
|
||||
| `out_order_no` | 关联支付分订单 |
|
||||
| `transaction_id` | 关联的支付单号 |
|
||||
| `amount.refund` | 实际退款金额(分) |
|
||||
| `refund_status` | `SUCCESS` / `CLOSED` / `PROCESSING` / `ABNORMAL` |
|
||||
|
||||
## 服务商处理要求
|
||||
|
||||
- 解密 / 验签均使用 **服务商**密钥与公钥。
|
||||
- 路由按 `sub_mchid` → 业务幂等按 `out_refund_no`。
|
||||
- 多次部分退款累加 `amount.refund`,确保不超过 `total_amount`。
|
||||
- 应答:成功返回 `200 + {"code":"SUCCESS","message":"成功"}`。
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.java.utils;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
/**
|
||||
* 微信支付 HTTP 客户端,封装了请求签名、发送、应答验签的完整流程。
|
||||
* 依赖 WXPayUtility 提供的签名、验签、序列化等基础能力。
|
||||
*/
|
||||
public class WXPayClient {
|
||||
private static final String HOST = "https://api.mch.weixin.qq.com";
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public WXPayClient(String mchid, String certificateSerialNo, String privateKeyFilePath,
|
||||
String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 GET 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendGet(String uri) {
|
||||
return sendRequest("GET", uri, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendPost(String uri, String reqBody) {
|
||||
return sendRequest("POST", uri, reqBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公钥加密敏感信息
|
||||
*/
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
private String sendRequest(String method, String uri, String reqBody) {
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(
|
||||
mchid, certificateSerialNo, privateKey, method, uri, reqBody));
|
||||
|
||||
if (reqBody != null) {
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(method, body);
|
||||
} else {
|
||||
reqBuilder.method(method, null);
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
return respBody;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.java.utils;
|
||||
|
||||
import com.google.gson.ExclusionStrategy;
|
||||
import com.google.gson.FieldAttributes;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
import okhttp3.Headers;
|
||||
import okhttp3.Response;
|
||||
import okio.BufferedSource;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.security.MessageDigest;
|
||||
import java.io.InputStream;
|
||||
import org.bouncycastle.crypto.digests.SM3Digest;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import java.security.Security;
|
||||
|
||||
public class WXPayUtility {
|
||||
private static final Gson gson = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.addSerializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.addDeserializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.deserialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.create();
|
||||
private static final char[] SYMBOLS =
|
||||
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
|
||||
public static String toJson(Object object) {
|
||||
return gson.toJson(object);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
|
||||
return gson.fromJson(json, classOfT);
|
||||
}
|
||||
|
||||
private static String readKeyStringFromPath(String keyPath) {
|
||||
try {
|
||||
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(
|
||||
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
|
||||
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePublic(
|
||||
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromPath(String keyPath) {
|
||||
return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static String createNonce(int length) {
|
||||
char[] buf = new char[length];
|
||||
for (int i = 0; i < length; ++i) {
|
||||
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
|
||||
}
|
||||
return new String(buf);
|
||||
}
|
||||
|
||||
public static String encrypt(PublicKey publicKey, String plaintext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Plaintext is too long", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
|
||||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Ciphertext decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
|
||||
byte[] ciphertext) {
|
||||
final String transformation = "AES/GCM/NoPadding";
|
||||
final String algorithm = "AES";
|
||||
final int tagLengthBit = 128;
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(key, algorithm),
|
||||
new GCMParameterSpec(tagLengthBit, nonce));
|
||||
if (associatedData != null) {
|
||||
cipher.updateAAD(associatedData);
|
||||
}
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (InvalidKeyException
|
||||
| InvalidAlgorithmParameterException
|
||||
| BadPaddingException
|
||||
| IllegalBlockSizeException
|
||||
| NoSuchAlgorithmException
|
||||
| NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
|
||||
transformation), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sign(String message, String algorithm, PrivateKey privateKey) {
|
||||
byte[] sign;
|
||||
try {
|
||||
Signature signature = Signature.getInstance(algorithm);
|
||||
signature.initSign(privateKey);
|
||||
signature.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
sign = signature.sign();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("An error occurred during the sign process.", e);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(sign);
|
||||
}
|
||||
|
||||
public static boolean verify(String message, String signature, String algorithm,
|
||||
PublicKey publicKey) {
|
||||
try {
|
||||
Signature sign = Signature.getInstance(algorithm);
|
||||
sign.initVerify(publicKey);
|
||||
sign.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
return sign.verify(Base64.getDecoder().decode(signature));
|
||||
} catch (SignatureException e) {
|
||||
return false;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("verify uses an illegal publickey.", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String buildAuthorization(String mchid, String certificateSerialNo,
|
||||
PrivateKey privateKey,
|
||||
String method, String uri, String body) {
|
||||
String nonce = createNonce(32);
|
||||
long timestamp = Instant.now().getEpochSecond();
|
||||
|
||||
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
|
||||
body == null ? "" : body);
|
||||
|
||||
String signature = sign(message, "SHA256withRSA", privateKey);
|
||||
|
||||
return String.format(
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
|
||||
"timestamp=\"%d\",serial_no=\"%s\"",
|
||||
mchid, nonce, signature, timestamp, certificateSerialNo);
|
||||
}
|
||||
|
||||
private static String calculateHash(InputStream inputStream, String algorithm) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(algorithm + " algorithm not available", e);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-256");
|
||||
}
|
||||
|
||||
public static String sha1(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-1");
|
||||
}
|
||||
|
||||
public static String sm3(InputStream inputStream) {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
try {
|
||||
SM3Digest digest = new SM3Digest();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hashBytes, 0);
|
||||
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(String content) {
|
||||
try {
|
||||
return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(Map<String, Object> params) {
|
||||
if (params == null || params.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (Entry<String, Object> entry : params.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof List) {
|
||||
List<?> list = (List<?>) entry.getValue();
|
||||
for (Object temp : list) {
|
||||
appendParam(result, key, temp);
|
||||
}
|
||||
} else {
|
||||
appendParam(result, key, value);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder result, String key, Object value) {
|
||||
if (result.length() > 0) {
|
||||
result.append("&");
|
||||
}
|
||||
|
||||
String valueString;
|
||||
if (value instanceof String || value instanceof Number ||
|
||||
value instanceof Boolean || value instanceof Enum) {
|
||||
valueString = value.toString();
|
||||
} else {
|
||||
valueString = toJson(value);
|
||||
}
|
||||
|
||||
result.append(key)
|
||||
.append("=")
|
||||
.append(urlEncode(valueString));
|
||||
}
|
||||
|
||||
public static String extractBody(Response response) {
|
||||
if (response.body() == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedSource source = response.body().source();
|
||||
return source.readUtf8();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(String.format("An error occurred during reading response body. " +
|
||||
"Status: %d", response.code()), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
|
||||
Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
String requestId = headers.get("Request-ID");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
|
||||
"%s", wechatpayPublicKeyId, serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed,the WechatPay signature is incorrect.%n"
|
||||
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers.get("Request-ID"), headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateNotification(String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
|
||||
"Remote: %s",
|
||||
wechatpayPublicKeyId,
|
||||
serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, WechatPay signature is incorrect.\n"
|
||||
+ "responseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
|
||||
Notification notification = gson.fromJson(body, Notification.class);
|
||||
notification.decrypt(apiv3Key);
|
||||
return notification;
|
||||
}
|
||||
|
||||
public static class ApiException extends RuntimeException {
|
||||
private static final long serialVersionUID = 2261086748874802175L;
|
||||
|
||||
private final int statusCode;
|
||||
private final String body;
|
||||
private final Headers headers;
|
||||
private final String errorCode;
|
||||
private final String errorMessage;
|
||||
|
||||
public ApiException(int statusCode, String body, Headers headers) {
|
||||
super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
|
||||
body, headers));
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
|
||||
if (body != null && !body.isEmpty()) {
|
||||
JsonElement code;
|
||||
JsonElement message;
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
|
||||
code = jsonObject.get("code");
|
||||
message = jsonObject.get("message");
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
code = null;
|
||||
message = null;
|
||||
}
|
||||
this.errorCode = code == null ? null : code.getAsString();
|
||||
this.errorMessage = message == null ? null : message.getAsString();
|
||||
} else {
|
||||
this.errorCode = null;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public Headers getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Notification {
|
||||
@SerializedName("id")
|
||||
private String id;
|
||||
@SerializedName("create_time")
|
||||
private String createTime;
|
||||
@SerializedName("event_type")
|
||||
private String eventType;
|
||||
@SerializedName("resource_type")
|
||||
private String resourceType;
|
||||
@SerializedName("summary")
|
||||
private String summary;
|
||||
@SerializedName("resource")
|
||||
private Resource resource;
|
||||
private String plaintext;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public String getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public Resource getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public String getPlaintext() {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (resource == null) {
|
||||
throw new IllegalArgumentException("Missing required field `resource` in notification");
|
||||
}
|
||||
resource.validate();
|
||||
}
|
||||
|
||||
private void decrypt(String apiv3Key) {
|
||||
validate();
|
||||
|
||||
plaintext = aesAeadDecrypt(
|
||||
apiv3Key.getBytes(StandardCharsets.UTF_8),
|
||||
resource.associatedData.getBytes(StandardCharsets.UTF_8),
|
||||
resource.nonce.getBytes(StandardCharsets.UTF_8),
|
||||
Base64.getDecoder().decode(resource.ciphertext)
|
||||
);
|
||||
}
|
||||
|
||||
public static class Resource {
|
||||
@SerializedName("algorithm")
|
||||
private String algorithm;
|
||||
|
||||
@SerializedName("ciphertext")
|
||||
private String ciphertext;
|
||||
|
||||
@SerializedName("associated_data")
|
||||
private String associatedData;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("original_type")
|
||||
private String originalType;
|
||||
|
||||
public String getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String getCiphertext() {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
public String getAssociatedData() {
|
||||
return associatedData;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getOriginalType() {
|
||||
return originalType;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (algorithm == null || algorithm.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
|
||||
throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
|
||||
"Notification.Resource", algorithm));
|
||||
}
|
||||
|
||||
if (ciphertext == null || ciphertext.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (associatedData == null || associatedData.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `associatedData` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
|
||||
if (nonce == null || nonce.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (originalType == null || originalType.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `originalType` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getContentTypeByFileName(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = "";
|
||||
int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
|
||||
extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Map<String, String> contentTypeMap = new HashMap<>();
|
||||
contentTypeMap.put("png", "image/png");
|
||||
contentTypeMap.put("jpg", "image/jpeg");
|
||||
contentTypeMap.put("jpeg", "image/jpeg");
|
||||
contentTypeMap.put("gif", "image/gif");
|
||||
contentTypeMap.put("bmp", "image/bmp");
|
||||
contentTypeMap.put("webp", "image/webp");
|
||||
contentTypeMap.put("svg", "image/svg+xml");
|
||||
contentTypeMap.put("ico", "image/x-icon");
|
||||
contentTypeMap.put("pdf", "application/pdf");
|
||||
contentTypeMap.put("doc", "application/msword");
|
||||
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
contentTypeMap.put("xls", "application/vnd.ms-excel");
|
||||
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
|
||||
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
contentTypeMap.put("txt", "text/plain");
|
||||
contentTypeMap.put("html", "text/html");
|
||||
contentTypeMap.put("css", "text/css");
|
||||
contentTypeMap.put("js", "application/javascript");
|
||||
contentTypeMap.put("json", "application/json");
|
||||
contentTypeMap.put("xml", "application/xml");
|
||||
contentTypeMap.put("csv", "text/csv");
|
||||
contentTypeMap.put("mp3", "audio/mpeg");
|
||||
contentTypeMap.put("wav", "audio/wav");
|
||||
contentTypeMap.put("mp4", "video/mp4");
|
||||
contentTypeMap.put("avi", "video/x-msvideo");
|
||||
contentTypeMap.put("mov", "video/quicktime");
|
||||
contentTypeMap.put("zip", "application/zip");
|
||||
contentTypeMap.put("rar", "application/x-rar-compressed");
|
||||
contentTypeMap.put("7z", "application/x-7z-compressed");
|
||||
|
||||
return contentTypeMap.getOrDefault(extension, "application/octet-stream");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 服务商模式接口索引
|
||||
|
||||
> 根据用户确认的开发语言加载对应文件,Java/Go 目录结构一致。
|
||||
> 服务商视角的服务端接口路径均为 `/v3/payscore/partner/...`,请求体必须携带 `sub_mchid`(特约商户号);签名 / 解密均使用**服务商**(`sp_mchid`)的 APIv3 密钥与微信支付公钥,不要混用子商户的密钥。
|
||||
|
||||
## 命名约定
|
||||
|
||||
- 分组目录:`{编号}-{业务名}/`,编号从 `1` 起(`1-订单管理/`、`2-退款/`、`3-小程序拉起/`、`4-APP拉起/`、`5-回调通知/`、`6-SDK工具类/`)
|
||||
- Java 代码文件:大驼峰 `.java`(如 `CreatePayScoreOrder.java`)
|
||||
- Go 代码文件:蛇形 `.go`(如 `create_payscore_order.go`)
|
||||
- 回调通知 `.md`:内容语言无关,**Java/ 与 Go/ 各放一份**——Java/ 用中文命名(如 `确认订单回调通知说明.md`),Go/ 用蛇形拼音(如 `user_confirm_callback.md`),内容完全一致
|
||||
- 客户端拉起 `.md`:语言无关的集成说明,统一放在 Java/ 下即可(无需 Go 副本)
|
||||
|
||||
---
|
||||
|
||||
## 业务接口
|
||||
|
||||
> 每个业务分组一张表,列含义如下:
|
||||
> - **服务端 API**(如下单 / 查单 / 退款):`Java` / `Go` 列分别为对应语言的可执行代码文件路径
|
||||
> - **回调通知**:`Java` / `Go` 列分别指向**同一份**报文说明 `.md`(语言无关,按目录约定各放一份方便项目查找)
|
||||
> - **客户端拉起**(小程序 / APP):跨语言通用的 `.md` 集成说明,仅列 `Java` 一列即可
|
||||
|
||||
### 1-订单管理(服务端 API)
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|---|---|---|---|
|
||||
| 创建支付分订单 | POST /v3/payscore/partner/serviceorder | `Java/1-订单管理/CreatePayScoreOrder.java` | `Go/1-订单管理/create_payscore_order.go` |
|
||||
| 查询支付分订单 | GET /v3/payscore/partner/serviceorder | `Java/1-订单管理/QueryPayScoreOrder.java` | `Go/1-订单管理/query_payscore_order.go` |
|
||||
| 取消支付分订单 | POST /v3/payscore/partner/serviceorder/{out_order_no}/cancel | `Java/1-订单管理/CancelPayScoreOrder.java` | `Go/1-订单管理/cancel_payscore_order.go` |
|
||||
| 完结支付分订单 | POST /v3/payscore/partner/serviceorder/{out_order_no}/complete | `Java/1-订单管理/CompletePayScoreOrder.java` | `Go/1-订单管理/complete_payscore_order.go` |
|
||||
| 修改订单金额 | POST /v3/payscore/partner/serviceorder/{out_order_no}/modify | `Java/1-订单管理/ModifyPayScoreOrder.java` | `Go/1-订单管理/modify_payscore_order.go` |
|
||||
| 同步订单状态 | POST /v3/payscore/partner/serviceorder/{out_order_no}/sync | `Java/1-订单管理/SyncPayScoreOrder.java` | `Go/1-订单管理/sync_payscore_order.go` |
|
||||
|
||||
> 端到端业务流程、`sub_mchid` 路由约定、双向授权流程详见 [📄 开发参数与业务规则.md](../接入指南/开发参数与业务规则.md)。
|
||||
|
||||
### 2-退款(服务端 API)
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|---|---|---|---|
|
||||
| 申请退款 | POST /v3/refund/domestic/refunds | `Java/2-退款/CreatePayScoreRefund.java` | `Go/2-退款/create_payscore_refund.go` |
|
||||
| 查询退款 | GET /v3/refund/domestic/refunds/{out_refund_no}?sub_mchid=... | `Java/2-退款/QueryPayScoreRefund.java` | `Go/2-退款/query_payscore_refund.go` |
|
||||
|
||||
> 退款必须用 `transaction_id`(不是 `out_order_no`),请求体必须携带 `sub_mchid`。
|
||||
|
||||
### 3-小程序拉起(客户端集成)
|
||||
|
||||
| 业务 | 接口 | Java |
|
||||
|---|---|---|
|
||||
| JSAPI / 小程序 拉起确认订单页 | `WeixinJSBridge.invoke('openBusinessView', businessType='wxpayScoreUse')` / `wx.openBusinessView` | `Java/3-小程序拉起/JsapiInvokeConfirm.md` |
|
||||
| JSAPI / 小程序 拉起订单详情页 | `WeixinJSBridge.invoke('openBusinessView', businessType='wxpayScoreDetail')` / `wx.openBusinessView` | `Java/3-小程序拉起/JsapiInvokeDetail.md` |
|
||||
|
||||
> `signature` 必须使用 **服务商 APIv2 密钥 + HMAC-SHA256**(不是子商户的密钥、也不是 APIv3 私钥)。`appid` 必须等于创单时的 `sub_appid`(无 sub_appid 时用 `sp_appid`)。详见 [📄 签名与验签规则.md](../接入指南/签名与验签规则.md)。
|
||||
|
||||
### 4-APP拉起(Android / iOS / 鸿蒙)
|
||||
|
||||
| 业务 | 客户端入口 | Java |
|
||||
|---|---|---|
|
||||
| APP 拉起确认订单页 / 订单详情页 | Android `WXOpenBusinessView` / iOS `WXOpenBusinessViewReq` | `Java/4-APP拉起/AppInvoke.md` |
|
||||
|
||||
### 5-回调通知(异步事件,无可执行代码)
|
||||
|
||||
> 服务商所有特约商户共用同一个 `notify_url`;回调内 `sub_mchid` 是路由依据。
|
||||
|
||||
| 业务 | 接口 | Java | Go |
|
||||
|---|---|---|---|
|
||||
| 用户确认订单回调通知(`PAYSCORE.USER_CONFIRM`) | 回调报文格式与处理要求 | `Java/5-回调通知/确认订单回调通知说明.md` | `Go/5-回调通知/user_confirm_callback.md` |
|
||||
| 支付成功回调通知(`PAYSCORE.USER_PAID`) | 回调报文格式与处理要求 | `Java/5-回调通知/支付成功回调通知说明.md` | `Go/5-回调通知/user_paid_callback.md` |
|
||||
| 退款结果回调通知(`REFUND.SUCCESS` / `REFUND.CLOSED` / `REFUND.ABNORMAL`) | 回调报文格式与处理要求 | `Java/5-回调通知/退款结果回调通知说明.md` | `Go/5-回调通知/refund_result_callback.md` |
|
||||
|
||||
> 通用解密 / 验签 / 回包流程参考 [📄 回调处理.md](../接入指南/回调处理.md)。
|
||||
|
||||
---
|
||||
|
||||
## 6-SDK 工具类(所有接口的公共依赖)
|
||||
|
||||
> 服务商角色与商户角色统一使用 `WXPayUtility` 系列;签名方案为 `WECHATPAY2-SHA256-RSA2048`。差异仅在请求体(带 `sub_mchid`)与回调路由(按 `sub_mchid`)层面,签名工具完全一致。
|
||||
>
|
||||
> ‼️ 详见 [📄 签名与验签规则.md](../接入指南/签名与验签规则.md)。
|
||||
|
||||
| 语言 | 文件 | 说明 |
|
||||
|---|---|---|
|
||||
| Java | `Java/6-SDK工具类/WXPayUtility.java` | 签名、验签、加解密 |
|
||||
| Java | `Java/6-SDK工具类/WXPayClient.java` | HTTP 客户端,封装请求签名 → 发送 → 验签 |
|
||||
| Go | `Go/6-SDK工具类/wxpay_utility.go` | 签名、验签、加解密 |
|
||||
| Go | `Go/6-SDK工具类/wxpay_client.go` | HTTP 客户端,封装请求签名 → 发送 → 验签 |
|
||||
487
.codex/skills/wechatpay-payscore/references/2-服务商/问题排查/排障手册.md
Normal file
487
.codex/skills/wechatpay-payscore/references/2-服务商/问题排查/排障手册.md
Normal file
@@ -0,0 +1,487 @@
|
||||
# 服务商模式排障手册
|
||||
|
||||
> 本文档是本角色 + 本产品排障的**唯一入口**。另一接入模式见对应角色目录下同名文件。
|
||||
>
|
||||
> ‼️ **使用规则**:用户报告任何问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 对账差异等),**先加载本文档**按下方流程匹配,不要先翻其他文档或猜原因。
|
||||
>
|
||||
> ‼️ **语气**:像有经验的技术支持,自然对话解释原因和方案,不要冷冰冰罗列文档目录。
|
||||
|
||||
## 排障流程
|
||||
|
||||
1. **能给 Request-Id?** → 走「一、错误码 TOP 20」:取 Request-Id 末尾 `-` 后的数字(如 `...CF05-268578704` → `268578704`)在速查表匹配,命中后用「错误码详细排查」对应段落回复。
|
||||
2. **不能给 / 未命中 TOP 20?** → 走「二、常见问题」:按现象(HTTP / 回调 / 签名 / 退款 / 角色特有 / 业务规则 / 通用配置)定位子节。
|
||||
3. **两条都没命中?** → 用末尾「排障信息收集清单」回收信息后再判断。
|
||||
|
||||
---
|
||||
|
||||
## 一、错误码 TOP 20(Request-Id 场景)
|
||||
|
||||
> 来源:本产品真实工单 / 客服系统统计的高频错误码。错误码本身在商户 / 服务商通用,但服务商场景额外关注 `sub_mchid` 路由、双向授权等环节。
|
||||
|
||||
### 1.1 TOP 20 速查表
|
||||
|
||||
| 错误码 | 错误信息 | 分类 |
|
||||
|:------:|---------|:----:|
|
||||
| 271317510 | 查询单据不存在 | 订单查询 |
|
||||
| 271316574 | 支付分扣款失败 | 扣款 |
|
||||
| 271302262 | 当前订单状态不合法 | 订单状态 |
|
||||
| 268435464 | 参数超出取值范围 | 参数校验 |
|
||||
| 271316657 | 仅服务订单支付状态为待支付时,才可使用此能力 | 订单状态 |
|
||||
| 271316598 | 订单重入参数校验失败 | 订单重入 |
|
||||
| 271302144 | mch_id 不存在 | 服务商配置 |
|
||||
| 271302149 | mch_id 和 appid 未绑定 | 服务商配置 |
|
||||
| 268435472 | 请求过于频繁 | 频控 |
|
||||
| 271300099 | 综合评估未通过 | 风控 |
|
||||
| 271302333 | 当前订单状态不合法(已取消) | 订单状态 |
|
||||
| 271299627 | 存在待处理的超时未支付订单 | 业务规则 |
|
||||
| 271302332 | 当前订单状态不合法(确认状态) | 订单状态 |
|
||||
| 268503786 | 当前无权限进行此操作 | 权限 / 授权 |
|
||||
| 271316592 | 当前订单状态不满足撤销条件 | 订单状态 |
|
||||
| 270924332 | Authorization 不合法 | 签名 |
|
||||
| 271316754 | 单据正在扣款中,请稍后重试 | 扣款 |
|
||||
| 271316656 | 总金额超过此服务的服务风险金额 | 业务规则 |
|
||||
| 271316637 | 真实结束时间小于预计开始时间 | 参数校验 |
|
||||
| 268560785 | 已开启青少年模式支付限额,暂无法使用支付分先用后付服务 | 用户侧限制 |
|
||||
|
||||
### 1.2 错误码详细排查
|
||||
|
||||
#### 271317510 — 查询单据不存在
|
||||
**常见原因**:
|
||||
- 查询时未传 `sub_mchid` 或传错了 `sub_mchid`,请求被路由到错误的子商户上下文(**服务商最高频原因**)
|
||||
- 用 `out_order_no` 在错误的 `(sub_mchid, out_order_no)` 组合下查询
|
||||
- 多 sub_mchid 共用同一回调地址,回调路由按 sub_mchid 分发错乱后,业务侧用错商户号查询
|
||||
- 创单失败但业务侧把 `out_order_no` 当"已创建"入库后又用它查询
|
||||
|
||||
**🔧 脚本确认**:先确认请求体里的 `sub_mchid` 与创单时一致;如本地多个候选 sub_mchid,逐一扫描"查询订单"接口确认归属。
|
||||
|
||||
**💡 推荐集成**:服务商业务必须将 `sub_mchid + out_order_no` 作为订单唯一键入库,所有查询 / 完结 / 退款都要按此组合路由,否则极易串单。
|
||||
|
||||
---
|
||||
|
||||
#### 271316574 — 支付分扣款失败
|
||||
**常见原因**:
|
||||
- 用户支付分账户余额不足或绑定账户扣款失败
|
||||
- 用户已主动关闭微信支付分免密授权
|
||||
- 完结金额异常(远高于风险金或与 `post_payments` 不一致触发风控)
|
||||
|
||||
> ⚠️ 与本错误码无关的常见误区:①完结时漏传 `profit_sharing=true` 只会导致后续不能调「请求分账」,**不影响扣款**;②分账发生在扣款成功之后,分账金额算错或分账失败不会回滚扣款,也不会触发 271316574;③ `sub_mchid` 传错通常被前置的鉴权 / 路由错误拦截(4xx),不会以 271316574 形式出现,定位思路要走「sub_mchid 与 service_id 绑定关系」而非"扣款失败"。
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"看 `state` / `state_description` / `collection.state`:
|
||||
- `DOING + USER_PAYING` → 扣款重试中,业务侧不要再调完结
|
||||
- `DOING + MCH_COMPLETE` → 已完结但收款中
|
||||
- `DONE + USER_PAID` → 实际已收款,本次失败可忽略
|
||||
|
||||
**💡 推荐集成**:建议接入"支付成功回调通知"(payscore-paid)作为收款判定的最终依据,不能仅凭完结接口的同步返回判定;回调里务必按 `sub_mchid` 路由。
|
||||
|
||||
---
|
||||
|
||||
#### 271302262 / 271302333 / 271302332 — 当前订单状态不合法
|
||||
**常见原因**:
|
||||
- **271302262**(通用):完结接口在 `state ≠ DOING` 或 `state_description ∉ {USER_CONFIRM, MCH_COMPLETE}` 时调用
|
||||
- **271302333**:订单已被取消(CLOSED),却仍调取消 / 完结 / 修改金额接口
|
||||
- **271302332**:订单还停留在 `CREATED + USER_CONFIRM` 等待用户确认阶段,就发起完结 / 修改金额 / 取消,必须等到 `DOING` 才能操作
|
||||
|
||||
**🔧 脚本确认**:先调"查询订单"(带正确 `sub_mchid`)拿到当前 `state` + `state_description` + `collection.state`,三者结合判断:
|
||||
- `CREATED` → 等用户确认(或调"取消"释放)
|
||||
- `DOING` → 可"完结" / "修改金额" / "同步"
|
||||
- `DONE` → 终态,不可再操作
|
||||
- `CLOSED` → 已关闭,重发新单
|
||||
|
||||
**💡 推荐集成**:建议在子商户业务系统本地维护订单状态缓存,通过"确认订单回调"(payscore-user-confirm)把状态从 CREATED 推进到 DOING,避免每次都查询。
|
||||
|
||||
---
|
||||
|
||||
#### 268435464 — 参数超出取值范围
|
||||
**常见原因**:
|
||||
- 金额字段单位错误(误传"元"而非"分",或传成浮点数)
|
||||
- `time_range.start_time` / `end_time` 不是 RFC3339 `yyyy-MM-ddTHH:mm:ss+08:00` 格式
|
||||
- 字符串字段超长(如 `description` 超过 32 字)
|
||||
- 数组型字段(`post_payments` / `post_discounts`)单元素金额为负或为 0
|
||||
- 服务商场景 `sub_appid` / `sub_openid` 非法(`sub_openid` 必须是 `sub_appid` 下的标识)
|
||||
|
||||
**🔧 脚本确认**:拿到完整请求 body 与 [API 字段规范](https://pay.weixin.qq.com/doc/v3/partner/4012586139.md) 逐项对照。重点:金额是否整数(单位分)、时间格式、`sub_openid` 与 `sub_appid` 是否同源。
|
||||
|
||||
---
|
||||
|
||||
#### 271316657 — 仅服务订单支付状态为待支付时,才可使用此能力
|
||||
**常见原因**:
|
||||
- 调"修改订单金额"或"取消订单"时,订单的 `collection.state` 已不是 `WAIT_PAY`
|
||||
- 用户已自助通过"支付分订单详情页"提前支付,业务侧仍按"待支付"逻辑触发后续动作
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"看 `collection.state`,仅 `WAIT_PAY` 才可执行金额变更 / 取消。
|
||||
|
||||
---
|
||||
|
||||
#### 271316598 — 订单重入参数校验失败
|
||||
**常见原因**:
|
||||
- 同一 `(sub_mchid, out_order_no)` 第二次创单时,关键参数(金额 / `service_id` / `sub_appid` / 用户标识 / `risk_fund.amount`)与第一次不一致
|
||||
- 创单超时但实际成功,业务侧重试时改了报文
|
||||
- 服务商把同一 `out_order_no` 用到不同 `sub_mchid` 下(属于不同订单,但触发了系统内部的二级幂等校验)
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"用原 `(sub_mchid, out_order_no)` 查到首次创单的真实参数,比对差异:① 完全一致 → 原创单已成功,无需重试;② 不一致 → 用新的 `out_order_no` 重新创单。
|
||||
|
||||
**💡 推荐集成**:服务商务必将 `sub_mchid` 纳入幂等键设计,订单号建议带 `sub_mchid` 前缀避免跨子商户冲突。
|
||||
|
||||
---
|
||||
|
||||
#### 271302144 — mch_id 不存在
|
||||
**常见原因**:
|
||||
- 配置文件里把 `sub_mchid` 误填到了 `mchid` 位置,或反过来
|
||||
- 服务商主商户号写错(前后空格、复制误差)
|
||||
- 沙箱、灰度、正式环境的服务商主商户号串了
|
||||
|
||||
**🔧 脚本确认**:把发起请求的 `mchid` 与服务商平台「账户中心 → 服务商信息」核对一致;`sub_mchid` 与"特约商户管理"列表核对。
|
||||
|
||||
---
|
||||
|
||||
#### 271302149 — mch_id 和 appid 未绑定
|
||||
**常见原因**:
|
||||
- 服务商主 `appid` 未与服务商主 `mchid` 完成绑定
|
||||
- `sub_appid` 未与 `sub_mchid` 完成绑定(最常见,子商户进件后忘记绑 sub_appid)
|
||||
- 服务商商户号 / 子商户号 / appid / sub_appid 四者未在 `service_id` 报备清单内
|
||||
|
||||
**🔧 脚本确认**:服务商平台 → 产品中心 → 特约商户管理 → 找到目标 `sub_mchid` → "AppID 账号管理" 检查 `sub_appid` 是否已绑定;同时确认 `service_id` 报备清单里是否有当前组合。
|
||||
|
||||
**💡 推荐集成**:参考 [管理商户号绑定的 APPID 账号](https://pay.weixin.qq.com/doc/v3/partner/4016329059.md) 完成绑定。
|
||||
|
||||
---
|
||||
|
||||
#### 268435472 — 请求过于频繁
|
||||
**常见原因**:
|
||||
- 同一 `(sub_mchid, out_order_no)` 在短时间内(< 1s)重复调用同一接口
|
||||
- 服务商批量代调多个子商户接口未做限流
|
||||
- 异常重试无指数退避
|
||||
|
||||
**🔧 脚本确认**:检查重试机制是否做了指数退避(建议初值 200ms,最多 3 次);服务商批量场景建议按 `sub_mchid` 维度做并发分桶。
|
||||
|
||||
---
|
||||
|
||||
#### 271300099 — 综合评估未通过
|
||||
**常见原因**:风控不通过,触发条件可能为:
|
||||
- 用户进行中的支付分订单 > 3 笔
|
||||
- 用户实名稳定性 / 信用记录不达标
|
||||
- 创单 `risk_fund.amount` 偏高,超出该用户的可承受额度
|
||||
- 用户近期累计的"超时未支付订单"过多
|
||||
|
||||
**🔧 处理建议**:业务侧无法直接干预。可建议子商户:① 提示用户先完结进行中订单;② 按子商户业务特点调小 `risk_fund.amount`;③ 自动降级为押金 / 普通预付。**前端不要展示"风控不通过"原文**,建议引导文案为"暂不支持先用后付,请选择其他方式"。
|
||||
|
||||
---
|
||||
|
||||
#### 271299627 — 存在待处理的超时未支付订单
|
||||
**常见原因**:用户名下累计的"超时未支付"支付分订单过多,平台拦截新订单创建。
|
||||
|
||||
**🔧 处理建议**:提示用户先在微信"我 → 服务 → 钱包 → 支付分 → 订单"中处理掉欠款订单后再下单。业务侧不可绕开。
|
||||
|
||||
---
|
||||
|
||||
#### 268503786 — 当前无权限进行此操作
|
||||
**常见原因**(**服务商场景重点**):
|
||||
- 服务商主商户号未开通微信支付分服务商产品
|
||||
- 服务商与子商户的"双向授权"未完成(**最高频**)
|
||||
- `sub_mchid` 不在 `service_id` 报备清单内
|
||||
- 测试期,调用的 `sub_mchid` 不在 service_id "测试号配置"白名单
|
||||
- 调用了角色不匹配的接口(用商户接口调了服务商专属能力)
|
||||
|
||||
**🔧 处理建议**:
|
||||
1. 服务商平台 → 产品中心 → 特约商户授权产品 → 服务商微信支付分 → 找到 `sub_mchid` → 点"申请"
|
||||
2. 子商户登录商户平台 → 产品中心 → 我的授权产品 → 找到"服务商微信支付分" → 点"授权"
|
||||
3. 联系微信支付行业运营按 [服务 ID 新增绑定流程](https://pay.weixin.qq.com/doc/v3/partner/4012624851.md) 把 `sub_mchid` 加到 service_id 报备清单
|
||||
4. 测试期把测试 sub_mchid / 微信号加入 service_id 测试白名单
|
||||
|
||||
---
|
||||
|
||||
#### 271316592 — 当前订单状态不满足撤销条件
|
||||
**常见原因**:
|
||||
- 子商户已调过「完结订单」,订单进入 `DOING + state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,扣款进行中)后再调取消
|
||||
- 订单已 `DONE` / `REVOKED` / `EXPIRED` 终态后再调取消
|
||||
- 订单已 `CLOSED` 后重复取消
|
||||
|
||||
**🔧 脚本确认**:调"查询订单"(带正确 `sub_mchid`)看 `state` + `state_description` + `collection.state` 三元组:
|
||||
- `CREATED` → ✅ 可取消(商户主动)
|
||||
- `DOING + USER_CONFIRM` → ✅ 可取消(用户已确认但子商户尚未完结)
|
||||
- `DOING + MCH_COMPLETE`(`collection.state=USER_PAYING`)→ ❌ 已进入扣款,改走"修改金额"或等待回调
|
||||
- `DONE` / `REVOKED` / `EXPIRED` → ❌ 终态,无需也不可取消
|
||||
|
||||
---
|
||||
|
||||
#### 270924332 — Authorization 不合法
|
||||
**常见原因**(**服务商场景重点**):
|
||||
- **APIv3 请求误用了子商户 API 证书签名**(必须用**服务商主商户**的 API 证书私钥签名)
|
||||
- 签名串拼接错误(HTTP 方法 / URL path / 时间戳 / 随机串 / body 顺序错了,body 为空时缺末尾 `\n`)
|
||||
- 服务商私钥与上送的 `serial_no` 不匹配(换证书时只换了 serial_no 没同步换私钥)
|
||||
- Authorization 头格式错误(缺 `WECHATPAY2-SHA256-RSA2048` 前缀、字段间缺逗号、字段未带英文双引号)
|
||||
- 调起小程序场景误把 APIv3 签名当作 APIv2 签名(**调起 sign 必须用服务商 APIv2 密钥 + HMAC-SHA256/MD5**)
|
||||
|
||||
**🔧 脚本确认**:建议直接切官方 SDK(`wechatpay-java` / `wechatpay-go` / `wechatpay-php` 等),并在初始化时确保使用的是**服务商主商户**的私钥与 serial_no。
|
||||
|
||||
**💡 推荐集成**:建议加载本 skill 的「签名与验签规则.md」对照排查;特别注意服务商场景下 APIv3 请求签名与小程序拉起 sign 是两套独立密钥体系。
|
||||
|
||||
---
|
||||
|
||||
#### 271316754 — 单据正在扣款中,请稍后重试
|
||||
**常见原因**:
|
||||
- 业务侧并发触发了多次完结接口
|
||||
- 上次完结请求在微信端排队中,业务侧重试过早(< 5 秒)
|
||||
- 没等扣款回调就再次发起金额修改 / 完结
|
||||
- 服务商批量代理触发并发完结,多个子商户落到同一队列
|
||||
|
||||
**🔧 处理建议**:等待 5-10 秒后查"查询订单"判断 `collection.state`:
|
||||
- `USER_PAYING` → 扣款仍在进行,再等等
|
||||
- `USER_PAID` → 已成功,无需重试
|
||||
- `WAIT_PAY` → 上次扣款失败,可重发
|
||||
|
||||
---
|
||||
|
||||
#### 271316656 — 总金额超过此服务的服务风险金额
|
||||
**常见原因**:
|
||||
- 创单 `risk_fund.amount` 大于该 `service_id` 在行业准入时核定的"风险金额上限"
|
||||
- 完结时 `total_amount` 远高于创单 `risk_fund.amount`
|
||||
- 服务商未按子商户的不同业务场景区分 service_id,所有子商户共用同一上限不够用
|
||||
|
||||
**🔧 处理建议**:
|
||||
1. 联系微信支付行业运营确认该 service_id 的风险金额上限
|
||||
2. 子商户分层时,按业务体量在创单时动态设置 `risk_fund.amount`
|
||||
3. 若上限确实不够用,可按子商户类型申请独立 service_id 或提额
|
||||
|
||||
---
|
||||
|
||||
#### 271316637 — 真实结束时间小于预计开始时间
|
||||
**常见原因**:完结订单时传的 `time_range.end_time` 早于创单时的 `time_range.start_time`。常见诱因:
|
||||
- 业务侧时区错误(+0800 / UTC 混用)
|
||||
- 完结时只传 `end_time` 未同时透传 `start_time`,与创单值产生跨天误判
|
||||
- 用户提前结束服务但 `end_time` 计算时减错了时长
|
||||
|
||||
**🔧 处理建议**:完结时**同时透传 `start_time` 和 `end_time`**,并保证 `end_time ≥ start_time`;时间字段必须 RFC3339 含时区。
|
||||
|
||||
---
|
||||
|
||||
#### 268560785 — 已开启青少年模式支付限额,暂无法使用支付分先用后付服务
|
||||
**常见原因**:用户在微信内开启了"青少年模式"或"支付限额管理",未授权使用支付分。
|
||||
|
||||
**🔧 处理建议**:业务侧不可绕开。前端可提示"该用户当前无法使用先用后付,请选择其他付款方式",并自动降级到普通押金 / 预付流程。
|
||||
|
||||
---
|
||||
|
||||
## 二、常见问题(无 Request-Id 场景)
|
||||
|
||||
> 来源:本产品官方「常见问题」文档 + 通用接入经验沉淀。
|
||||
|
||||
### 2.1 HTTP 错误(401 / 400 / 403)
|
||||
|
||||
| 状态码 | 含义 | 常见原因 | 排查要点 |
|
||||
|:----:|------|---------|---------|
|
||||
| 401 | 签名验证失败 | 私钥与证书不匹配;serial_no 填错;签名串拼接有误(换行符 / URL / body 为空时缺末尾换行);时间戳偏差过大 | 检查 Authorization 头格式;确认私钥正确加载;建议用官方 SDK |
|
||||
| 400 | 请求参数错误 | 必填参数缺失;金额单位是分不是元;时间格式不符 RFC 3339;JSON 层级错误 | 对照 API 文档逐项检查;金额单位是**分**;时间格式 `yyyy-MM-ddTHH:mm:ss+08:00` |
|
||||
| 403 | 权限不足 | 未开通对应支付产品;IP 不在白名单;商户号状态异常 | 商户平台 → 产品中心确认开通状态 |
|
||||
|
||||
### 2.2 回调问题
|
||||
|
||||
**收不到回调排查清单**(按优先级):① 地址不可达(URL 错 / 域名解析失败 / localhost / 服务未启动)→ ② URL 前后有空格致 DNS 失败 → ③ 防火墙拦截(未对回调 IP 段开白名单,见下方 IP)→ ④ 登录态拦截(notify_url 须从鉴权中间件中排除)→ ⑤ 响应非 200(如 FAIL / 404,重试后放弃)→ ⑥ 处理超时(须 5 秒内应答)→ ⑦ 域名未 ICP 备案 → ⑧ 商户号用错(实际收到了但用另一个 mchid 查单导致"订单不存在")。
|
||||
|
||||
**回调行为 Q&A**:
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 怎么确认微信发了回调? | 微信不提供回调日志查询,检查自身服务器访问日志 + 查单接口确认 |
|
||||
| 2 | 回调会重复收到吗? | 会,未正确响应时微信会重试,业务必须做幂等 |
|
||||
| 3 | 回调延迟正常吗? | 数秒到数十秒均属正常。建议回调 + 主动查单双保险 |
|
||||
| 4 | 能直接将回调当最终结果吗? | 不能,回调不保证送达,需结合查单接口确认 |
|
||||
| 5 | 商户平台能查回调状态吗? | 不支持,需调用查单接口 |
|
||||
| 6 | 回调怎么测试? | 无测试接口,需生产环境真实业务验证 |
|
||||
|
||||
**回调解密与验签**:
|
||||
|
||||
| # | 报错 | 原因 | 解法 |
|
||||
|---|------|------|------|
|
||||
| 1 | `cipher: message authentication failed` / `AEADBadTagException` | APIv3 密钥错误(最常见:密钥重置后代码未同步)或密文被截断 | 检查代码中的 APIv3 密钥与商户平台一致 |
|
||||
| 2 | "证书序列号不一致" | 用商户证书做了验签(应用平台证书)或平台证书过期 | 改用平台证书并确保未过期 |
|
||||
| 3 | `Last unit does not have enough valid bits` | 签名探测流量 | 检查 `Wechatpay-Signature` 是否以 `WECHATPAY/SIGNTEST/` 开头,是则返回非 2xx |
|
||||
| 4 | 签名参数顺序错误 | 参数个数 / 顺序 / 大小写不对或末尾缺 `\n` | 严格按文档顺序拼接,末尾必须有 `\n` |
|
||||
|
||||
**回调 IP 白名单**:
|
||||
|
||||
| 出口位置 | IP 网段 |
|
||||
|---------|---------|
|
||||
| 上海电信 / 联通 / CAP | `101.226.103.0/25` / `140.207.54.0/25` / `121.51.58.128/25` |
|
||||
| 深圳电信 / 联通 / CAP | `183.3.234.0/25` / `58.251.80.0/25` / `121.51.30.128/25` |
|
||||
| 香港 / 广州腾讯云 | `203.205.219.128/25` / `81.71.199.64`、`81.71.198.25`、`81.71.199.59` |
|
||||
|
||||
退款 / 分账通知 IP:`175.24.214.208`、`175.24.211.24`、`175.24.213.135`、`109.244.180.23`、`114.132.203.119`、`43.139.43.69`
|
||||
|
||||
### 2.3 签名与证书
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 证书序列号怎么获取? | 服务商平台 → 账户中心 → API安全 → API 证书 → 管理证书 |
|
||||
| 2 | 一个商户号能设多个 API 证书吗? | 可以 |
|
||||
| 3 | 平台证书过期怎么换? | 参考 https://pay.weixin.qq.com/doc/v3/merchant/4012068829 ,建议代码实现自动轮换 |
|
||||
| 4 | 换 serial_no 后报签名错误? | 证书编号与私钥一一对应,更新 serial_no 时必须同步换私钥文件 |
|
||||
| 5 | V2 签名失败怎么排查? | 逐项对比签名原串:① 字段按 ASCII 字典序;② 大小写一致;③ 无多余空格或遗漏字段。本产品**调起小程序 sign 强依赖服务商 APIv2 密钥**,常被忽略 |
|
||||
| 6 | V2 签名方式? | MD5 或 HMAC-SHA256(不是 V3 的 SHA256-RSA2048) |
|
||||
| 7 | API 只能通过域名访问吗? | 是,不支持 IP 直连 |
|
||||
| 8 | APIv2 密钥改后验签失败? | 密钥重置后代码中的密钥必须同步更新 |
|
||||
| 9 | 服务商接口能用子商户证书签名吗? | **不能**。APIv3 请求必须用**服务商**的 API 证书私钥签名,混用子商户证书会触发 401 SIGN_ERROR |
|
||||
|
||||
### 2.4 退款常见问题
|
||||
|
||||
> 产品专属退款规则(不可退期限、最小金额等)见 2.6。
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 余额不足 | 子商户账户可用余额不足,充值后重试 |
|
||||
| 2 | 重复退款 | `out_refund_no` 已使用,换一个或查询原单状态 |
|
||||
| 3 | 订单未支付 | 只有支付 / 扣款成功的订单才能退款 |
|
||||
| 4 | 支付和退款能跨 V2/V3 吗? | 可以,但不建议 |
|
||||
| 5 | 退款没到账 / 状态异常 | 先查退款状态:`PROCESSING`=处理中(1-3 工作日);`SUCCESS`=已成功;`ABNORMAL`=异常退款,走异常流程;`CLOSED`=退款关闭,用新单号重发。代码见对应模式接口索引 |
|
||||
| 6 | 已分账的订单退款金额不足 | 已分账的订单需先「分账回退」再退款,否则金额可能不足 |
|
||||
|
||||
### 2.5 本角色特有问题
|
||||
|
||||
> 来源:[微信支付分常见问题(服务商)](https://pay.weixin.qq.com/doc/v3/partner/4012586139.md) + [服务 ID 新增绑定邮件流程](https://pay.weixin.qq.com/doc/v3/partner/4012624851.md)
|
||||
|
||||
| 报错信息 | 原因 | 解决 |
|
||||
|---------|------|------|
|
||||
| `NO_AUTH 请检查sub_mchid是否授权mchid微信支付分产品权限` | 双向授权未完成 | ① 服务商平台 → 产品中心 → 特约商户授权产品 → 服务商微信支付分 → 特约商户列表 → 找到 sub_mchid → 点"申请"<br/>② 子商户登录商户平台 → 产品中心 → 我的授权产品 → 找到"服务商微信支付分" → 点"授权" |
|
||||
| `NO_AUTH mchid与sub_mchid之间Mma绑定关系不存在` | 子商户尚未进件成为该服务商的子商户 | 走 [特约商户进件 API](https://pay.weixin.qq.com/doc/v3/partner/4012761122.md) 或服务商平台手工添加 |
|
||||
| `NO_AUTH ... sub_appid之间Mma绑定关系不存在` | sub_appid 未与 sub_mchid 绑定 | 参考 [管理商户号绑定的 APPID 账号](https://pay.weixin.qq.com/doc/v3/partner/4016329059.md) 完成绑定 |
|
||||
| `NO_AUTH 商户暂无权限使用此服务` | 服务商商户号 / 子商户号 / appid / sub_appid 不在 service_id 配置中 | 邮件申请增量绑定,参考 [服务 ID 新增绑定流程](https://pay.weixin.qq.com/doc/v3/partner/4012624851.md) |
|
||||
| 401 SIGN_ERROR(用了子商户证书) | APIv3 请求签名误用子商户 API 证书 | 改用**服务商** API 证书私钥签名 |
|
||||
| 串单(A 子商户的订单被 B 子商户业务处理) | 所有 sub_mchid 共用同一 notify_url,回调路由未按 `sub_mchid` 区分 | 解密后必须按 `sub_mchid` 路由到对应子商户业务系统,幂等去重也要按 `sub_mchid + out_order_no + event_type` 三层 |
|
||||
| 退款"订单不存在" | 用 `out_order_no` 退款且未带 `sub_mchid` | 退款必须用 `transaction_id` 且请求体携带 `sub_mchid` |
|
||||
| 拉起小程序"商户签名校验失败" | 用了 APIv3 密钥生成 sign,应使用服务商主商户的 APIv2 密钥 | 改用 **服务商 APIv2 密钥** 按 HMAC-SHA256 / MD5 生成签名 |
|
||||
| `NO_AUTH` 提示子商户号已注销 | 子商户号已注销但仍被绑定调用 | 注销的商户号不能继续使用支付分,更换为正常使用的子商户号 |
|
||||
| 用了「需确认订单」的 service_id 调了「免确认订单」接口(反之) | 接口与 service_id 类型混淆 | 严格按 service_id 类型选用接口:[需确认订单服务商版](https://pay.weixin.qq.com/doc/v3/partner/4012585943) / [免确认订单服务商版](https://pay.weixin.qq.com/doc/v3/partner/4012586013) |
|
||||
| 新增 sub_mchid / sub_appid 后立即报 `NO_AUTH 商户暂无权限` | 新增子商户 / appid 后未联系运营做 service_id 增量绑定 | 邮件申请增量绑定,参考 [服务 ID 新增绑定流程](https://pay.weixin.qq.com/doc/v3/partner/4012624851.md) |
|
||||
| `sub_appid` 必传场景下报 `NO_AUTH` | sub_appid 未与 service_id 配置绑定 | 若不强依赖该 sub_appid 可去掉重试;若必传则联系运营绑定后再调用 |
|
||||
|
||||
### 2.6 业务规则 Q&A
|
||||
|
||||
> 来源:[微信支付分常见问题(服务商)](https://pay.weixin.qq.com/doc/v3/partner/4012586139.md)
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | "暂无法使用此服务,微信支付分逐步开放中"是什么意思? | 服务尚未上线且当前用户不在测试白名单。服务商平台 → 微信支付分 → 测试号配置添加测试微信号 |
|
||||
| 2 | "综合评估未通过"是什么原因? | 用户风控未通过:进行中订单过多 / 信用记录 / 实名稳定性 / 风险金过高。业务侧无法干预;可建议用户先完结进行中订单 |
|
||||
| 3 | "同一实名身份下进行中订单过多" | 单个用户进行中订单 > 3 笔。提示用户先处理 |
|
||||
| 4 | `INVALID_REQUEST 当前订单状态不合法` 是什么时候报? | 完结接口仅在 `state=DOING` 且 `state_description ∈ {USER_CONFIRM, MCH_COMPLETE}` 时可调。先调查询订单确认状态 |
|
||||
| 5 | `PARAM_ERROR 最终总金额计算非法` 是什么意思? | 完结时未严格满足 `total_amount = Σpost_payments.amount - Σpost_discounts.amount`。本地按公式校验后再发请求 |
|
||||
| 6 | "创建订单的订单风险金额超过此服务的服务风险金额" | `risk_fund.amount` 超过 service_id 风险金额上限。找运营确认上限并调小金额 |
|
||||
| 7 | 完结报"真实结束时间小于预计开始时间" | 完结的 `time_range.end_time` 早于创单的 `start_time`。同时传 `start_time` / `end_time` 或确保 `end_time` 晚于创单 `start_time` |
|
||||
| 8 | `post_payments` 商品信息未在订单详情显示 | 未严格按行业字段规范传参。参考服务商版 [post_payments 字段说明](https://pay.weixin.qq.com/doc/v3/partner/4013163663.md) |
|
||||
| 9 | 订单状态怎么判断? | 必须 `state` + `state_description` + `collection.state` **三者结合**,不能只看 `state` |
|
||||
| 10 | 订单 30 天未确认会怎样? | CREATED 状态 30 天未变动自动 EXPIRED。建议对 CREATED 订单做定时查询兜底 |
|
||||
| 11 | 修改订单金额能上调吗? | 不能,**只能下调** |
|
||||
| 12 | 用户走其他渠道支付了怎么办? | 调"同步订单状态"接口,订单变 DONE,避免重复扣款 |
|
||||
| 13 | 分账什么时候调? | 完结时传 `profit_sharing=true`,**扣款成功后**才能调用"请求分账" |
|
||||
| 14 | 资金落到哪个账户? | 默认全部结算到 sub_mchid 账户,服务商通过分账分配 |
|
||||
| 15 | 服务商订单详情页 / 录屏验收不通过怎么办? | UI 必须满足 [支付分合作品牌线上应用规范](https://pay.weixin.qq.com/doc/v3/partner/4012586152.md);`post_payments` 严格按行业传参 |
|
||||
| 16 | 新增子商户后立即报 NO_AUTH | 新增 sub_mchid / sub_appid 后未联系运营做 service_id 增量绑定。邮件申请增量绑定后才生效 |
|
||||
|
||||
### 2.7 通用接入配置
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | V2 和 V3 可以同时用吗? | 可以,密钥体系独立互不影响 |
|
||||
| 2 | 微信支付有测试环境吗? | **没有**,所有调试需在生产环境进行 |
|
||||
| 3 | 同一错误为什么返回不同错误码? | 存在参数校验优先级,多参数错误时可能先返回 `PARAM_ERROR` |
|
||||
| 4 | 接口地址能在浏览器直接打开吗? | **不能**,需程序调用并携带证书,建议用 Postman 调试 |
|
||||
| 5 | 防火墙拦截(如医院场景)怎么办? | 微信服务端 IP 动态更新,建议以**域名白名单**配置防火墙 |
|
||||
|
||||
---
|
||||
|
||||
## 三、真实工单 Q&A 沉淀(服务商模式)
|
||||
|
||||
> 来源:服务商真实工单沉淀,按主题归类高频问题与官方答复口径,可作为客服 / 一线开发的参考标准答案。**服务商场景的所有问题都先确认 `sub_mchid` 是否准确**。
|
||||
|
||||
### 3.1 service_id 与 sub_mchid 绑定
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 新增子商户后立刻报 `NO_AUTH 商户暂无权限使用此服务` | 新增的 sub_mchid / sub_appid 必须**邮件申请** service_id 增量绑定后才生效。流程见 [服务 ID 新增绑定指引](https://pay.weixin.qq.com/doc/v3/partner/4012624851.md) |
|
||||
| 2 | `sub_appid` 不传可以正常调用,传了就报 `NO_AUTH` | sub_appid 未与 service_id 绑定。如非必传可去掉;必传则联系微信支付行业运营在 service_id 上做 sub_appid 增量绑定 |
|
||||
| 3 | 请求里用错 service_id 类型(需确认 vs 免确认) | 服务商接口分两套:[需确认订单](https://pay.weixin.qq.com/doc/v3/partner/4012585943) / [免确认订单](https://pay.weixin.qq.com/doc/v3/partner/4012586013)。两者参数结构不同,必须按 service_id 配置时申请的类型选用 |
|
||||
| 4 | 子商户号已注销,还能继续用吗? | 不能。注销的商户号不能继续绑定 / 使用支付分,需更换正常使用的 sub_mchid 重新绑定 |
|
||||
| 5 | 双向授权流程谁先做? | 服务商先在「服务商平台 → 产品中心 → 特约商户授权产品 → 服务商微信支付分」找到 sub_mchid 点"申请";之后子商户登录商户平台 → 我的授权产品 → 找到"服务商微信支付分"点"授权"。两步完成后才能调用接口 |
|
||||
|
||||
### 3.2 扣款相关(服务商场景)
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 用户扣款长时间不到账 | 大概率是用户侧原因:绑定的支付方式余额不足、银行卡状态异常、微信账户被风控限制等。**官方话术**:引导用户在「微信 → 我 → 钱包 → 支付分 → 待支付」中**主动支付**试试 |
|
||||
| 2 | "银行卡可用余额不足" | 用户绑定的所有扣款方式都余额不足。引导用户充值或换卡后自助拉起支付 |
|
||||
| 3 | 扣款时报"实际结束时间不能晚于使用完结接口的时间" | `time_range.end_time` 不能晚于完结请求当前时间。修正为当前时间或更早即可 |
|
||||
| 4 | 资金到账后自动落到哪个账户? | 默认全部结算到 `sub_mchid` 子商户账户。服务商如需抽佣需通过分账接口分配 |
|
||||
| 5 | 已分账订单退款时金额不足 | 已分账资金需先调「分账回退」把分账金额回退到 sub_mchid 后再退款,否则金额不足 |
|
||||
|
||||
### 3.3 取消订单与状态流转
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 已经登记扣款但用户还没支付,能取消吗? | 看订单当前状态(取消调用必须带 `sub_mchid`):`state=CREATED`(已创建未确认)→ ✅ 可取消;`state=DOING` 且 `state_description=USER_CONFIRM`(用户已确认但子商户**未调完结**)→ ✅ 可取消;`state=DOING` 且 `state_description=MCH_COMPLETE`(`collection.state=USER_PAYING`,**子商户已调完结**、扣款进行中)→ ❌ 不能取消,改走「修改金额」或等待支付结果回调 |
|
||||
| 2 | 哪些状态不能取消? | `DOING + MCH_COMPLETE`(已进入扣款流程,只能改金额或等回调)、`DONE`(已扣款完成)、`REVOKED`(已取消)、`EXPIRED`(已失效,CREATED 30 天未变动)四种状态不可取消;强行调用会得到 `271316592 当前订单状态不满足撤销条件` |
|
||||
| 3 | 同步状态返回"收款结果不明,请稍后重试" | 微信端扣款仍在进行中。等几分钟后用「查询订单」看 `collection.state`,`USER_PAID` 即为成功 |
|
||||
| 4 | 怎么准确判断订单是否已扣款成功? | 必须看「查询订单」返回的 `state` + `state_description` + `collection.state` 三元组,仅 `DONE + USER_PAID` 才是真正完成 |
|
||||
| 5 | 退款查不到订单 / 报"订单不存在" | 退款必须用 `transaction_id`(来自支付成功回调或查询订单)+ 请求体携带 `sub_mchid`,不能用 `out_order_no` |
|
||||
|
||||
### 3.4 风控 / 综合评估
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | "综合评估未通过"的真实含义 | 平台风控拦截。受用户进行中订单数、信用记录、实名稳定性、`risk_fund.amount` 等多因素影响。**子商户与服务商均无法获知具体原因**,也无法直接干预 |
|
||||
| 2 | 商户能拿到用户分数吗? | **不能**。支付分小程序底层逻辑不返回具体分数也不返回"分数过低"标识 |
|
||||
| 3 | 拉起报"评估不通过" | 正常风控拦截。前端引导文案建议「暂不支持先用后付,请选择其他付款方式」并自动降级 |
|
||||
| 4 | "校验用户登录态失败,请稍后重试" | 用户微信登录态过期或异常。引导用户重启微信或重新登录后再试 |
|
||||
| 5 | 用户其他渠道能用,本商户报评估未通过 | 平台风控会结合**当前商户场景**评估。建议子商户引导用户保持良好实名认证 + 消费行为后重试;商户侧可适当下调 `risk_fund.amount` |
|
||||
| 6 | 对恶意不支付用户能制约吗? | 没有商户侧手段。但平台会通过实名身份做关联:用户换微信号但实名相同,平台仍会限制 |
|
||||
|
||||
### 3.5 风险金 / 金额规则
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 创单报"创建订单的订单风险金额超过此服务的服务风险金额" | `risk_fund.amount` 超过 service_id 风险金额上限。联系微信支付行业运营确认上限并适配;或临时下调 |
|
||||
| 2 | 完结报"总金额超过此服务的服务风险金额" | 完结金额超过 service_id 服务风险金额。引导用户在支付分待支付列表里**主动拉起支付**,或线下补足差额 |
|
||||
| 3 | 用户消费金额超过风险金,订单收不到钱 | 联系用户线下补差,或引导用户主动通过支付分待支付列表完成支付 |
|
||||
| 4 | `post_payments[].amount` 类型映射失败 | `amount` 必须是 64 位无符号整数(单位分),不能为浮点 / 负数 / 字符串。逐字段对齐文档 |
|
||||
| 5 | `post_payments` 字段哪些必传? | 参考服务商版 [post_payments 字段说明](https://pay.weixin.qq.com/doc/v3/partner/4013163663.md),不同行业要求不同;不能仅传 `amount` 不传业务字段,否则订单详情页商品信息不显示 |
|
||||
|
||||
### 3.6 拉起小程序 / APP / 鸿蒙
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 调起小程序"商户请求错误" / "签名校验失败" | 拉起 sign 必须用**服务商主商户 APIv2 密钥**(HMAC-SHA256 / MD5),不能用 APIv3 密钥,也不能用子商户 APIv2 密钥 |
|
||||
| 2 | 调起报"4108 商户请求错误" | 创单时使用的 `appid` / `sub_appid` 必须与拉起场景(小程序 / APP / 公众号)的实际 appid 一致 |
|
||||
| 3 | 鸿蒙端拉起"第三方应用信息校验失败,bundleId 错误" | 微信开放平台后台配置的鸿蒙应用 Bundle ID / App Identifier 必须与项目实际签名信息匹配。Bundle ID = `AppScope/app.json5` 的 `bundleName`;App Identifier 通过 `bundleManager.getBundleInfoForSelf` 获取 |
|
||||
| 4 | 用户拉起前能预判能否使用支付分吗? | **不能**。商户无法获取分数也无法预判风控结果。前端建议直接拉起 `wx.openBusinessView`,根据返回结果决定后续流程 |
|
||||
|
||||
### 3.7 解约 / 解除授权
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 用户能自行解除授权吗? | 可以。用户可通过「微信 → 我 → 服务 → 钱包 → 支付分 → 我的服务」主动解除。商户应通过「查询用户授权记录」接口同步状态变更 |
|
||||
| 2 | 解约报"商户解除授权的授权码不是这个用户签约的授权" | 传入的 `authorization_code` 不是该用户在本子商户下签约时返回的那一个。重新查询用户授权记录拿正确 `authorization_code` |
|
||||
| 3 | 解约报"存在未完成订单" | 用户在本子商户下还有进行中(≤ 3 笔)+ 待支付(≤ 1 笔)订单。引导用户处理掉未完结订单后再解约 |
|
||||
| 4 | 用户解除授权后历史订单还能扣款吗? | 已存在的服务中订单仍可正常扣款;新订单创建会返回 `NO_AUTH`,需用户重新授权 |
|
||||
|
||||
### 3.8 回调与查单
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 怎么确认微信发了回调? | 微信不提供回调日志查询。检查自己服务器访问日志 + 调「查询订单」接口确认 |
|
||||
| 2 | 创单到回调用了几十秒,正常吗? | 数秒到数十秒均正常 |
|
||||
| 3 | 完结后没收到回调,但用户已扣款 | 回调可能在你日志保留期之外。用「查询订单」(带 `sub_mchid`)确认 `collection.state` 是否 `USER_PAID`,回调与查单做双保险 |
|
||||
| 4 | 待支付订单为什么收不到支付成功回调? | 待支付订单还没扣款成功,自然不会有支付成功回调,符合预期 |
|
||||
| 5 | 多 sub_mchid 共用同一回调地址,怎么避免串单? | 解密后必须按 `sub_mchid` 路由到对应子商户业务系统;幂等去重也按 `sub_mchid + out_order_no + event_type` 三层 |
|
||||
|
||||
### 3.9 证书与公钥模式
|
||||
|
||||
| # | 问题 | 答案 |
|
||||
|---|------|------|
|
||||
| 1 | 平台证书 vs 微信支付公钥,必须切吗? | 不必。两种模式服务商可任选其一 |
|
||||
| 2 | 微信支付公钥模式上线会有问题吗? | 不会。微信支付公钥模式无需担心证书过期;切换至平台证书时需在到期前完成更新 |
|
||||
| 3 | 切换流程参考? | [如何从微信支付公钥切换成平台证书指引](https://pay.weixin.qq.com/doc/v3/merchant/4015419357) |
|
||||
|
||||
---
|
||||
|
||||
## 排障信息收集清单
|
||||
|
||||
两条路径都未命中时,请用户提供:接入模式、出错环节(创单 / 完结 / 修改金额 / 同步 / 退款 / 回调 / 对账 / 分账)、HTTP 状态码 + 完整响应体、Request-Id(含尾段错误码)、服务商号 sp_mchid + 子商户号 sub_mchid(如涉及 service_id 请一并提供)+ 业务单号 `out_order_no` + 请求时间。
|
||||
Reference in New Issue
Block a user