Add WeChat Pay local skills

This commit is contained in:
2026-05-15 03:35:30 +08:00
parent 2eded08bc7
commit 6672867c6f
535 changed files with 114971 additions and 0 deletions

View 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 交流群二维码

View File

@@ -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/小程序)→ 用户主动支付待支付订单

View 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`

View File

@@ -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)。

View File

@@ -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 | **回调金额与本地订单比对** | 支付成功回调处理 | 回调中的支付金额未与本地订单金额比对,可能放过被篡改的伪造请求 | 🔴 致命 |

View 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. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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":"成功"}`

View File

@@ -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` 多次到达必须只生效一次。
## 测试要点
- 主动调用「查询支付分订单」接口与回调入库结果做交叉校验,避免遗漏。
- 模拟回调延迟 / 重试场景(处理超时不影响业务)。

View File

@@ -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 + 错误描述触发重试。

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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=...&timestamp=...&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` 相同 |

View File

@@ -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}&timestamp={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`)时可调起;未创建会报错。

View File

@@ -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&timestamp=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&timestamp=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) |

View File

@@ -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 + 错误描述触发重试。

View File

@@ -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` 多次到达必须只生效一次。
## 测试要点
- 主动调用「查询支付分订单」接口与回调入库结果做交叉校验,避免遗漏。
- 模拟回调延迟 / 重试场景(处理超时不影响业务)。

View File

@@ -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":"成功"}`

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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 客户端,封装请求签名 → 发送 → 验签 |

View File

@@ -0,0 +1,454 @@
# 商户模式排障手册
> 本文档是本角色 + 本产品排障的**唯一入口**。另一接入模式见对应角色目录下同名文件。
>
> ‼️ **使用规则**:用户报告任何问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 对账差异等),**先加载本文档**按下方流程匹配,不要先翻其他文档或猜原因。
>
> ‼️ **语气**:像有经验的技术支持,自然对话解释原因和方案,不要冷冰冰罗列文档目录。
## 排障流程
1. **能给 Request-Id** → 走「一、错误码 TOP 20」取 Request-Id 末尾 `-` 后的数字(如 `...CF05-268578704``268578704`)在速查表匹配,命中后用「错误码详细排查」对应段落回复。
2. **不能给 / 未命中 TOP 20** → 走「二、常见问题」按现象HTTP / 回调 / 签名 / 退款 / 角色特有 / 业务规则 / 通用配置)定位子节。
3. **两条都没命中?** → 用末尾「排障信息收集清单」回收信息后再判断。
---
## 一、错误码 TOP 20Request-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 3339JSON 层级错误 | 对照 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` + 请求时间。

View File

@@ -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`,扣款成功后调用"请求分账"

View 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`

View File

@@ -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)。

View File

@@ -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 一致** | 拉起代码与创单参数对照 | 创单 appidsp_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 隔离命名空间,存在跨子商户冲突可能 | 🟡 推荐 |

View 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. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"
)

View File

@@ -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"
)

View File

@@ -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":"成功"}`

View File

@@ -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` |

View File

@@ -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` 调用「服务商分账」相关接口。

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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业务成功以回调 / 查单为准 |

View File

@@ -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` 一致。
- 详情页只读不可改:用户的修改 / 退款行为请走服务商后台对应接口。
- 仅当订单已存在时可调起。

View File

@@ -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&timestamp=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&timestamp=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) |

View File

@@ -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` 调用「服务商分账」相关接口。

View File

@@ -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` |

View File

@@ -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":"成功"}`

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -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 客户端,封装请求签名 → 发送 → 验签 |

View File

@@ -0,0 +1,487 @@
# 服务商模式排障手册
> 本文档是本角色 + 本产品排障的**唯一入口**。另一接入模式见对应角色目录下同名文件。
>
> ‼️ **使用规则**:用户报告任何问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 对账差异等),**先加载本文档**按下方流程匹配,不要先翻其他文档或猜原因。
>
> ‼️ **语气**:像有经验的技术支持,自然对话解释原因和方案,不要冷冰冰罗列文档目录。
## 排障流程
1. **能给 Request-Id** → 走「一、错误码 TOP 20」取 Request-Id 末尾 `-` 后的数字(如 `...CF05-268578704``268578704`)在速查表匹配,命中后用「错误码详细排查」对应段落回复。
2. **不能给 / 未命中 TOP 20** → 走「二、常见问题」按现象HTTP / 回调 / 签名 / 退款 / 角色特有 / 业务规则 / 通用配置)定位子节。
3. **两条都没命中?** → 用末尾「排障信息收集清单」回收信息后再判断。
---
## 一、错误码 TOP 20Request-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 3339JSON 层级错误 | 对照 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` + 请求时间。