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,105 @@
# 商户模式产品介绍
> 来源:[商户产品介绍](https://pay.weixin.qq.com/doc/v3/merchant/4016824672.md) + [医保自费混合收款下单](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) + 各 API 文档
## 一、产品概览
**移动医保支付** 是基于「微信医保电子凭证」的线上医保结算解决方案。用户在微信激活医保电子凭证后,可在医院/药店的小程序、公众号 H5 中直接完成挂号、门诊缴费、住院预交、互联网医院诊疗、药店购药等场景的「医保 + 自费」一键支付,无需前往线下窗口排队。
> ‼️ 本技能覆盖 **移动医保支付 2.0**(接口前缀 `/v3/med-ins/`。1.0 版本(接口前缀 `/v3/medicalinsurance/`)不在本技能覆盖范围。请医疗行业商户尽量升级到 2.0 版本。
商户接入医保支付可以:
- 解决传统就医「三长一短」(挂号排队长、缴费排队长、取药排队长,就诊时间短)
- 一次下单,向医保局和微信支付**同时**收款(无需用户分别支付两次)
- 自动完成医保结算 + 自费收款 + 现金补充(运费等) + 现金减免(预交金等)
## 二、商户模式适用范围
> ‼️ 商户模式(即 **直连普通商户模式**= 医院/药店自有微信支付商户号 + 自有医保资质,**直接**与微信支付/医保局对接。已确定走商户模式的可直接读 [开发参数与业务规则](../接入指南/开发参数与业务规则.md) 和 [接口索引](../示例代码/接口索引.md)。
| 角色 | 适用情况 |
| ------------ | ------------------------------- |
| **直连普通商户模式** | 大型医院/连锁药店自身已具备微信支付商户号,且独立完成医保接入 |
> 不属于上述情况的(如医院由银行或第三方机构代为接入),应改走 **服务商模式**,详见 [服务商产品介绍](../../2-服务商/产品选型/产品介绍.md)。
## 三、典型使用场景(订单类型 `order_type`
| 订单类型枚举 | 中文 | 适用业务 |
| ------------------- | --------- | ------------ |
| `REG_PAY` | 挂号支付 | 门诊挂号缴费 |
| `DIAG_PAY` | 诊间支付 | 门诊就诊间缴费 |
| `IN_HOSP_PAY` | 住院费支付 | 住院预交、出院结算 |
| `PHARMACY_PAY` | 药店支付 | 定点零售药店购药 |
| `INSURANCE_PAY` | 保险费支付 | 商业健康险费用 |
| `INT_REG_PAY` | 互联网医院挂号支付 | 在线问诊预约 |
| `INT_RE_DIAG_PAY` | 互联网医院复诊支付 | 在线复诊缴费 |
| `INT_RX_PAY` | 互联网医院处方支付 | 互联网医院开具的处方药费 |
| `MED_PAY` | 药费支付 | 通用药品费用 |
| `COVID_EXAM_PAY` | 新冠检测费用 | 核酸检测 |
| `COVID_ANTIGEN_PAY` | 新冠抗原检测 | 抗原检测 |
> ‼️ `order_type` 必须严格匹配实际业务场景,**不可跨场景使用**(例如:挂号场景传 `DIAG_PAY` 会触发医保局业务校验失败)。
## 四、混合支付类型(`mix_pay_type`
医保自费混合下单一次接口可覆盖三种支付类型,由 `mix_pay_type` 决定字段约束:
| 混合支付类型 | 含义 | 必填字段 | 禁填字段 |
| -------------------- | ------ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `CASH_ONLY` | 纯自费 | `wechat_pay_cash_fee` (>0) + `prepay_id` | `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个医保金额字段 |
| `INSURANCE_ONLY` | 纯医保 | `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个医保金额字段 | `wechat_pay_cash_fee` / `prepay_id` |
| `CASH_AND_INSURANCE` | 医保自费混合 | 上述全部字段都必填 | — |
金额校验公式(任意一种类型都必须满足):
```text
total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ wechat_pay_cash_fee + cash_reduce_detail 之和
= med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ med_ins_cash_fee + cash_add_detail 之和
wechat_pay_cash_fee = med_ins_cash_fee + cash_add_detail 之和 - cash_reduce_detail 之和
```
> ‼️ 详细字段约束、金额公式与排错见 [开发参数与业务规则](../接入指南/开发参数与业务规则.md) 的「混合支付类型与金额公式」章节。
## 五、接入前提
### 5.1 资质与权限
1. **企业资质**:医院(公立/民营)、定点零售药店等具备医疗机构资质,并已与当地医保局签约
2. **微信支付商户号**:已开通微信支付,且商户号经营类目涵盖「医院/医疗服务/药店」
3. **医保城市接入**:所在城市已开通微信医保电子凭证支付能力(部分地区暂未开通)
4. **医保局对接**:医院 HIS 系统已完成与当地医保局后台的对接费用明细上传【6201】、医保下单、医保扣款等
5. **接入申请**:联系微信医保对接邮箱/对接群,由腾讯侧分配 **渠道号**`channel_no`)和具体城市的 **城市 ID**`city_id`
6. **APIv3 升级**:商户号需开通 APIv3 + 申请 APIv3 密钥 + 商户 API 证书 + 微信支付公钥(推荐)
> ‼️ 开发参数清单、获取步骤与字段约束统一收拢到 [开发参数与业务规则](../接入指南/开发参数与业务规则.md),本页不再重复列出。
### 5.2 与医保局的协同
医保支付不是单纯的微信支付接入,强制依赖医保局后台:
1. **费用明细上传**【6201】商户先把每笔费用明细传到医保局后台得到 `medOrgOrd`(即下单时的 `serial_no`
2. **医保预结算**:调用医保局接口,得到 `pay_order_id`(医保局支付单 ID`pay_auth_no`(医保局支付授权码)
3. **医保自费混合下单**:把上述参数 + 自费下单返回的 `prepay_id` 一并传给微信医保 `/v3/med-ins/orders` 接口
4. **用户授权 + 调起支付**:通过 JSAPI/小程序调起医保自费混合支付,用户在医保电子凭证页面输入支付密码
5. **回调通知**:微信医保通过 `MEDICAL_INSURANCE.SUCCESS` 事件通知商户
### 5.3 接入产物
成功接入后将具备以下能力:
1. 医保自费混合下单(`POST /v3/med-ins/orders`)→ JSAPI/小程序调起 → 接收"医保混合收款成功通知"
2. 主动查询订单状态(按 `mix_trade_no``out_trade_no`)作为回调兜底
3. 医保退款发生时,向微信医保发起「医保退款通知」(`POST /v3/med-ins/refunds/notify`
4. 自费部分独立退款走标准退款接口(详见 [📄 排障手册](../问题排查/排障手册.md)

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,207 @@
# 商户模式开发参数与业务规则
> 来源:[商户产品介绍](https://pay.weixin.qq.com/doc/v3/merchant/4016824672.md) + [医保自费混合收款下单](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) + [使用混合订单号查单](https://pay.weixin.qq.com/doc/v3/merchant/4016781479.md) + [医保退款通知](https://pay.weixin.qq.com/doc/v3/merchant/4016781561.md)
本文档覆盖本产品**接入前**需要准备的全部参数与产品特有的字段传参规范。业务全链路流程随各 API 示例代码注释展示,错误处置见 [📄 排障手册.md](../问题排查/排障手册.md)。
---
## 一、参数清单
| 参数 | 类型 | 用途 | 必备性 |
| ---- | ---- | ---- | ------ |
| `mchid` | string | 商户号,所有 API 必传 | 必备 |
| `appid` | string | 公众号 / 小程序 AppID必须已与 mchid 绑定) | 必备 |
| `med_inst_no` | string(32) | 医疗机构编码(医保局提供) | 必备 |
| `med_inst_name` | string(128) | 医疗机构名称 | 必备 |
| `city_id` | string(8) | 微信医保城市 ID腾讯对接群提供 | 必备 |
| `channel_no` | string(32) | 渠道号(腾讯工程师分配) | 可选 |
| **APIv3 密钥** | string(32) | 解密回调通知密文AEAD_AES_256_GCM | 必备 |
| 商户 API 证书(私钥 + 序列号) | PEM + string | APIv3 接口请求签名 | 必备 |
| **微信支付公钥 + 公钥 ID** | PEM + string | APIv3 响应/回调验签 + 敏感字段加密 | 强烈推荐 |
| 微信支付平台证书(旧式) | PEM + 序列号 | 与微信支付公钥二选一 | 可选 |
| 通知回调地址 `notify_url` | URL | 接收医保混合收款成功通知HTTPS + 已备案 + 公网可达 | 必备 |
> ‼️ 医保支付下单接口包含 **身份证姓名 + 身份证 MD5 摘要** 等敏感信息必须使用微信支付公钥推荐或平台证书加密。HTTP 请求头 `Wechatpay-Serial` 必须传入用于加密的公钥 ID 或平台证书序列号,未加密直接传明文会被服务端拒绝。
## 二、获取步骤
### 2.1 mchid商户号
1. 登录 [微信支付商户平台](https://pay.weixin.qq.com/)
2. 进入「账户中心 → 商户信息」即可看到 `mchid`10 位数字,如 `YOUR_MCHID`),也可在右上角点击商户简称下拉查看
3. ⚠️ 右上角直接显示的是「商户简称」(如"深圳腾大"这样的中文昵称),**不是** `mchid`,请勿混用
### 2.2 appid
| 应用类型 | 申请入口 |
| -------- | -------- |
| 公众号 / 小程序 | [微信公众平台](https://mp.weixin.qq.com/) |
申请到 appid 后必须在商户平台「产品中心 → AppID 账号管理」与 mchid 绑定。
> ‼️ **下单 / 调起支付必须使用同一个 appid**,且与 `openid` 来源一致,否则触发 `PARAM_ERROR: 请确认AppID与OpenID是否正确并确保OpenID是从对应的AppID下获取的`。
### 2.3 医疗机构编码 / 名称 / 城市 ID
- `med_inst_no` / `med_inst_name`由医保局在医疗机构对接时下发与医保局费用明细上传【6201】中的 `medOrgOrd` 等参数同步
- `city_id`:由腾讯医保对接侧下发,**与国标行政区划编码可能存在差异**,必须使用腾讯下发值
### 2.4 APIv3 密钥32 字节)
1. 登录商户平台 → 账户中心 → API 安全 → 设置 APIv3 密钥
2. 详细步骤:[APIv3 密钥设置方法](https://pay.weixin.qq.com/doc/v3/merchant/4012072195)
### 2.5 商户 API 证书
1. 登录商户平台 → 账户中心 → API 安全 → API 证书 → 申请并下载
2. 详细步骤:[商户 API 证书获取方法](https://pay.weixin.qq.com/doc/v3/merchant/4012072428)
3. 下载后得到 `apiclient_cert.pem`(公钥证书)+ `apiclient_key.pem`(私钥)+ 证书序列号
### 2.6 微信支付公钥(强烈推荐)
1. 登录商户平台 → 账户中心 → API 安全 → 微信支付公钥
2. 下载后得到公钥文件PEM+ 公钥 ID形如 `PUB_KEY_ID_xxxxxxxx`
3. 详细步骤:[如何获取微信支付公钥](https://pay.weixin.qq.com/doc/v3/merchant/4013038816.md)
> 医保支付下单需要对身份证姓名/身份证摘要做公钥加密,**强烈建议使用微信支付公钥**(永久有效,无需轮换),平台证书需要定期下载更新且即将退役。
### 2.7 微信支付平台证书(旧式,与微信支付公钥二选一)
1. 调用 [获取平台证书 API](https://pay.weixin.qq.com/doc/v3/merchant/4012551764.md) 自动下载
2. 平台证书会到期,需要定期更新
### 2.8 渠道号 channel_no可选
由腾讯工程师在对接时分配,标记本次接入的医保支付渠道信息,下单时通过 `channel_no` 字段传入。
### 2.9 notify_url 配置
医保支付的 `notify_url` 通过下单接口的 `callback_url` 字段直接传入,**每次下单都必须传**
- HTTPS不允许 HTTP
- 域名已 ICP 备案
- 公网可达,**不能用 127.0.0.1 / 192.168.x.x / localhost**
- 不能携带任何 query 参数
## 三、接入前自查清单
接入前请逐项确认(任一项不满足都会触发下单失败):
- [ ] mchid 已开通医保支付能力(联系腾讯医保对接侧确认)
- [ ] appid 已与 mchid 完成绑定
- [ ] APIv3 密钥已设置
- [ ] 商户 API 证书已下载并保存证书序列号
- [ ] 微信支付公钥(或平台证书)已下载,公钥 ID 已记录
- [ ] 医保局对接费用明细上传【6201】 + 医保预结算流程已跑通,可拿到 `medOrgOrd` / `pay_order_id` / `pay_auth_no`
- [ ] 腾讯医保对接侧已下发 `city_id` 和(可选的)`channel_no`
- [ ] notify_url 已配置且公网可达 + 已备案
- [ ] 敏感字段加密代码已就位(身份证姓名 + 身份证摘要)
---
## 四、混合支付类型与金额公式
下单接口的 `mix_pay_type` 决定后续字段约束,**误填会触发 `RULE_LIMIT` 错误**。三种类型对照:
| 类型 | 含义 | 自费字段 | 医保字段 |
| ---- | ---- | -------- | -------- |
| `CASH_ONLY` | 纯自费(医保走线下/未结算) | `wechat_pay_cash_fee > 0` + `prepay_id` 必填 | 4 个医保金额字段之和必须为 0`pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` **禁填** |
| `INSURANCE_ONLY` | 纯医保(无需用户再付现金) | `wechat_pay_cash_fee` 必须为 0`prepay_id` **禁填** | 4 个医保金额字段必填、`pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` 必填 |
| `CASH_AND_INSURANCE` | 医保自费混合 | `wechat_pay_cash_fee > 0` + `prepay_id` 必填 | 4 个医保金额字段必填(每项可为 0+ 4 个医保订单字段必填 |
金额公式(任意一种类型都必须同时满足):
```text
total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ wechat_pay_cash_fee + Σ cash_reduce_detail.cash_reduce_fee
= med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ med_ins_cash_fee + Σ cash_add_detail.cash_add_fee
wechat_pay_cash_fee = med_ins_cash_fee
+ Σ cash_add_detail.cash_add_fee
- Σ cash_reduce_detail.cash_reduce_fee
```
> ‼️ **金额单位为「分」,必须使用整数**int / long。误用 double / float 会因精度丢失触发 `RULE_LIMIT: 自费金额校验不通过`。
`cash_add_detail.cash_add_type` 枚举:
| 枚举 | 含义 |
| ---- | ---- |
| `DEFAULT_ADD_TYPE` | 默认显示「机构加收费用」 |
| `FREIGHT` | 运费 |
| `OTHER_MEDICAL_EXPENSES` | 其他医疗费用 |
`cash_reduce_detail.cash_reduce_type` 枚举:
| 枚举 | 含义 |
| ---- | ---- |
| `DEFAULT_REDUCE_TYPE` | 默认「机构优惠金额」 |
| `HOSPITAL_REDUCE` | 医院减免 |
| `PHARMACY_DISCOUNT` | 药店优惠 |
| `DISCOUNT` | 优惠金 |
| `PRE_PAYMENT` | 预缴金 |
| `DEPOSIT_DEDUCTION` | 押金抵扣 |
---
## 五、敏感字段加密规范
下单接口需要加密的字段:
| 字段 | 加密内容 |
| ---- | -------- |
| `payer.name` | 真实姓名(用户本人) |
| `payer.id_digest` | 用户本人身份证 MD5 摘要 |
| `relative.name` | 真实姓名(亲属,仅 `pay_for_relatives = true` 时) |
| `relative.id_digest` | 亲属身份证 MD5 摘要 |
**身份证摘要算法**
1. 身份证号字母统一转大写15 位身份证转换为 18 位
2. 计算 MD5结果为 32 位小写十六进制)
3. 例:`44030019000101123x``09eb26e839ff3a2e3980352ae45ef09e`
**加密**:使用 **微信支付公钥**`PUB_KEY_ID_xxxxxxxx`)或微信支付平台证书公钥,对原文(姓名 / 摘要 32 字符)做 RSA-OAEP 加密,结果 Base64 编码后放入字段。
**请求头**:必须设置 `Wechatpay-Serial` 为加密所用的公钥 ID 或平台证书序列号,否则服务端无法解密。
> 详细加密示例代码见 [示例代码/接口索引.md](../示例代码/接口索引.md) 中「下单」分组的 Java/Go 示例。
---
## 六、订单状态流转
| 字段 | 枚举 | 含义 |
| ---- | ---- | ---- |
| `mix_pay_status` | `MIX_PAY_CREATED` | 等待支付(用户尚未在医保电子凭证页面完成密码确认) |
| | `MIX_PAY_SUCCESS` | 支付成功(自费 + 医保两端均成功) |
| | `MIX_PAY_REFUND` | 自费和医保均已退款 |
| | `MIX_PAY_FAIL` | 支付失败 |
| `self_pay_status` | `SELF_PAY_CREATED/SUCCESS/REFUND/FAIL/NO_SELF_PAY` | 自费部分支付状态(含「订单不含自费部分」) |
| `med_ins_pay_status` | `MED_INS_PAY_CREATED/SUCCESS/REFUND/FAIL/NO_MED_INS_PAY` | 医保部分支付状态 |
> ‼️ 仅依赖回调有遗漏风险5 秒内未应答会重试30 秒后微信不再重试),**必须**对 `mix_pay_status = MIX_PAY_CREATED` 的订单做定时主动查询兜底(用 `GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}` 或 `GET /v3/med-ins/orders/out-trade-no/{out_trade_no}`)。
---
## 七、医保退款通知
当医保侧发生退款(医院在 HIS 中发起医保退款 → 医保局完成退款)后,**商户必须主动向微信医保发起 `POST /v3/med-ins/refunds/notify` 通知**,告知微信本次医保退款的金额构成与时间。
> ⚠️ 注意:「医保退款通知」**不是**微信发给商户的回调,而是**商户调用微信的接口**。命名易混淆。
请求体关键字段:
| 字段 | 含义 |
| ---- | ---- |
| `mix_trade_no`Query | 微信医保侧的混合订单号 |
| `med_refund_total_fee` | 医保退款总金额(分) |
| `med_refund_gov_fee` | 医保统筹退款金额 |
| `med_refund_self_fee` | 医保个账退款金额 |
| `med_refund_other_fee` | 医保其他退款金额 |
| `refund_time` | 医保退款成功时间RFC3339 |
| `out_refund_no` | 医疗机构退款单号;若同时退自费,需与 [自费退款申请](https://pay.weixin.qq.com/doc/v3/merchant/4013071036.md) 的 `out_refund_no` 保持一致 |
**自费部分退款**走标准 `POST /v3/refund/domestic/refunds`,不在本接口覆盖范围(需要先做自费退款申请,再用相同 `out_refund_no` 调用本接口同步医保退款结果)。

View File

@@ -0,0 +1,84 @@
# 商户模式接入质量检查
## 角色设定:金融支付系统技术专家
> ‼️ **本节角色、铁律和问题雷达是质检的全部驱动力,必须内化后再审代码。**
你是金融支付系统技术专家,全栈工程师出身,亲手写过从前端收银台到后端交易引擎的全链路代码。你主导过千万级用户规模的国民级支付系统架构设计,从零搭建过高并发交易平台。你熟悉主流支付平台的接入规范与安全体系,对 API 签名验签机制、异步回调通知处理、资金流对账有丰富的实战经验。你对代码质量有极强的直觉,尤其对资金链路上的异常处理缺失高度警觉。
你对支付系统的要求极高:接口交互必须有完善的异常处理和兜底方案,资金操作必须可追溯、可对账,所有外部输入必须经过校验才能进入业务逻辑。
## 铁律
**铁律一高可用99.9999%**
系统可用性要求 99.9999%(六个 9即每一百万次请求中最多允许一次失败。支付链路上不允许存在单点故障每一个外部调用都必须有超时、重试和降级方案。
检查直觉:调用微信支付 API 超时了,代码会自动重试还是直接报错?重试的时候会不会导致重复下单?微信的支付回调一直没来,系统有没有定时去主动查询订单状态?用户快速点了两次支付按钮,会不会创建两笔订单?
**铁律二:资金安全(一分钱都不能错)**
金额计算必须使用整数(单位:分),杜绝浮点精度丢失。每一笔资金变动(支付、退款、分账)都必须有据可查,系统必须在次日通过账单对账主动发现差异。
检查直觉:金额字段的类型是 int/long 还是 double/float用户申请退款时代码有没有累加历史退款金额并校验是否超过订单总额系统有没有每天自动拉取微信账单和本地订单做比对
**铁律三:零信任(不信任任何未经验证的外部数据)**
微信的回调通知、前端传入的参数、缓存中的数据,在进入业务逻辑前必须经过验证,未验证的输入一律视为不可信。
检查直觉:收到支付回调后,代码是先验签还是直接解析 body 处理业务?下单接口的金额是从后端数据库查的还是直接用前端传过来的值?回调通知中的支付金额有没有和本地订单金额做比对?私钥是通过环境变量加载的还是硬编码在代码里?
## 检查方法
1. **扫代码** — 快速扫描代码,按问题雷达定位高风险区域
2. **追链路** — 沿资金流完整走一遍:自费 JSAPI 下单 → 医保混合下单 → 小程序/JSAPI 调起 → 用户医保密码确认 → 医保混合收款回调 → 主动查单兜底 → 医保退款通知,任何断点都是事故点
3. **做预演** — 对每个关键节点问"如果这里故障了/超时了/被攻击了/来了两次,会怎样?"
**输出要求**:发现问题必须给出修复方向,不能只说"有风险";必须基于代码事实,不基于猜测;结果按 🔴🟡🟠 分级,致命问题置顶。
## 问题雷达
> **来源**:通用安全雷达(固定 4 项)+ 产品专属雷达(**重点从「开发指引」与「常见问题」提炼**,其他文档作为补充)。
>
> 以下仅列举常见的高风险问题,**不要只检查列出的项**。检查时应反向运用铁律:逐条铁律审视代码,发现未列出的同类问题。
### 通用安全雷达(所有产品必查)
> 4 项**独立判定**,每项必须给出"通过 / 未实现 / 不涉及"三选一的明确结论,**禁止合并多项为一条**。具体检查方法见 [签名与验签规则](./签名与验签规则.md)。
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
| --- | ------ | -------- | ---------------- | -------- |
| 1 | **HTTP 响应验签** | 发起请求并处理响应的代码OkHttp `execute()` / HttpClient `send()` 等) | 收到 2XX 响应后直接解析返回数据,中间无任何验签调用 | 🔴 致命 |
| 2 | **回调通知验签** | 处理 `MEDICAL_INSURANCE.SUCCESS` 回调的代码(含 `event_type` / `resource_type` / `encrypt-resource` 等字段) | 收到通知后**先解密或解析业务数据**,验签缺失或在解密之后 | 🔴 致命 |
| 3 | **幂等去重 + 并发锁** | 回调处理流程的入口 | 既无按"`mix_trade_no` + `event_type`"的去重查询也无加锁逻辑Redis 锁 / 行锁 / `synchronized` 等) | 🔴 致命 |
| 4 | **探测流量未做特殊跳过** | 验签代码分支 | 对签名值含 `WECHATPAY/SIGNTEST/` 前缀的请求做了特殊跳过/早返回 | 🟠 可选 |
### 产品专属雷达(移动医保支付 2.0
> **来源**[商户医保下单](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) + [产品介绍](https://pay.weixin.qq.com/doc/v3/merchant/4016824672.md) + [常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4017415831.md) + [小程序调起](https://pay.weixin.qq.com/doc/v3/merchant/4016781545.md) + [JSAPI 调起](https://pay.weixin.qq.com/doc/v3/merchant/4016781549.md) + [回调通知](https://pay.weixin.qq.com/doc/v3/merchant/4016781554.md) + [退款通知](https://pay.weixin.qq.com/doc/v3/merchant/4016781561.md)
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
| --- | ------ | -------- | ---------------- | -------- |
| 1 | **payer.name / payer.id_digest 加密** | 创单参数构造payer / relative | 直接传明文姓名或身份证 MD5或用了商户 API 证书私钥而不是**微信支付公钥** RSA-OAEP 加密 | 🔴 致命 |
| 2 | **id_digest 计算流程正确** | 创单参数预处理 | 身份证未先把字母转大写、15 位未转 18 位再 MD5或 MD5 输出未转小写就加密;或对完整身份证号直接加密未做 MD5 | 🔴 致命 |
| 3 | **Wechatpay-Serial 头按加密钥写入** | 创单 / 退款通知 HTTP Header | 用微信支付**公钥**加密敏感字段,但 Header 写的是平台**证书序列号**(应为 `PUB_KEY_ID_xxx`),导致解密失败 | 🔴 致命 |
| 4 | **金额公式硬校验total_fee** | 创单调用前 | 未在本地按公式 `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee` 校验,依赖微信侧返回 `RULE_LIMIT 订单总金额校验不通过`才发现 | 🔴 致命 |
| 5 | **金额公式硬校验wechat_pay_cash_fee** | 创单调用前 | 未在本地按公式 `wechat_pay_cash_fee = med_ins_cash_fee + Σcash_add_detail.fee - Σcash_reduce_detail.fee` 校验,依赖微信侧返回 `RULE_LIMIT 自费金额校验不通过`才发现 | 🔴 致命 |
| 6 | **mix_pay_type 与字段联动校验** | 创单参数构造 | `CASH_ONLY` 误传 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*` 字段;`INSURANCE_ONLY` 误传 `wechat_pay_cash_fee` / `prepay_id``CASH_AND_INSURANCE` 漏传 `prepay_id` 或医保字段 | 🔴 致命 |
| 7 | **prepay_id 与医保单 out_trade_no 一致** | 自费 JSAPI 下单 → 医保下单流程 | 两次下单使用了不同 `out_trade_no`,导致自费 prepay_id 与医保混合单不匹配触发 `prepay_id` 异常 | 🔴 致命 |
| 8 | **金额类型为 int/long** | 所有 amount 字段(`total_fee` / `med_ins_*` / `wechat_pay_cash_fee` / `cash_add_fee` / `cash_reduce_fee` | 用 `double` / `float` / `BigDecimal` 而非 `int` / `long`,单位不是"分" | 🔴 致命 |
| 9 | **MIX_PAY_CREATED 主动查单兜底** | 订单查询代码 | 仅依赖回调,没有对超时(如 30 秒/1 分钟)仍为 `MIX_PAY_CREATED` 的订单做定时查询;回调丢失即变成"扣款成功但业务不知"的资损 | 🔴 致命 |
| 10 | **mix_pay_status 三态联合判断** | 状态判断逻辑 | 仅判断 `mix_pay_status = MIX_PAY_SUCCESS` 不结合 `self_pay_status` / `med_ins_pay_status`,对 `INSURANCE_ONLY` 场景误判为支付失败 | 🟡 推荐 |
| 11 | **回调金额与本地订单比对** | 医保混合收款成功回调处理 | 回调中的 `total_fee` / `wechat_pay_cash_fee` / `med_ins_*_fee` 未与本地订单金额比对,可能放过被篡改的伪造请求 | 🔴 致命 |
| 12 | **回调 5 秒内应答** | 回调处理流程耗时 | 回调处理同步等待业务长耗时操作DB 慢查询、HIS 回写、第三方调用),导致 5 秒超时被微信认为失败重试 | 🔴 致命 |
| 13 | **callback_url 必须 HTTPS 且无 query** | 创单 `callback_url` 拼装 | 使用 HTTP / 内网 / 携带 query 参数 / 未备案域名 / 域名前后有空格 | 🔴 致命 |
| 14 | **notify_url 鉴权排除** | 网关 / 中间件鉴权配置 | `callback_url` 路径被登录态/Token 中间件拦截,导致回调被拒 | 🔴 致命 |
| 15 | **拉起 paySign 用商户 API 证书私钥RSA-SHA256** | 调起小程序 / JSAPI 的 sign 生成 | 误用 APIv3 密钥 / APIv2 密钥 / HMAC-SHA256 生成 `paySign`(医保支付的 `signType` 必须是 `RSA`,与微信支付分用 APIv2 密钥不同) | 🔴 致命 |
| 16 | **AppID 与 openid 配对** | 调起代码与创单参数对照 | 创单传了 `openid` 但调起用 `sub_appid`(或反向),触发 `PARAM_ERROR 请确认AppID与OpenID是否正确` | 🔴 致命 |
| 17 | **代亲属支付 relative 必填** | 创单参数构造 | `pay_for_relatives = true` 时未传 `relative.name` / `relative.id_digest` / `relative.card_type`,触发 `PARAM_ERROR 该订单属于代亲属支付,但缺少亲属信息` | 🟡 推荐 |
| 18 | **payer 与医保电子凭证绑卡信息一致** | 创单前用户态校验 | `payer.name` / `payer.id_digest` 与用户的医保电子凭证绑卡信息不匹配,触发 `INVALID_REQUEST 入参用户姓名/个人身份ID摘要和医保电子凭证绑卡xxx不匹配`;建议在前端先引导用户激活医保电子凭证 | 🟡 推荐 |
| 19 | **out_trade_no 唯一与字符集** | 创单参数构造 | `out_trade_no` 含非法字符或超过 64 字符;或在自费失败后**重发不同 `out_trade_no` 但同一 `serial_no`** 时未先取消前一笔 | 🟡 推荐 |
| 20 | **time 字段 RFC3339 +08:00** | `med_ins_order_create_time` / `paid_time` 等 | 时间格式不是 `yyyy-MM-DDTHH:mm:ss+08:00` 或时区错乱UTC 与本地混用),触发 `PARAM_ERROR 医保下单时间解析失败` | 🟡 推荐 |
| 21 | **city_id 与医保对接城市编码一致** | 创单参数构造 | `city_id` 沿用国标行政区划编码而非医保接入文档定义的编码,导致医保局结算失败 | 🟡 推荐 |
| 22 | **med_inst_no 与 serial_no 与 6201 流水一致** | 创单参数构造 | `med_inst_no` 与医保侧报备不一致;`serial_no` 与【6201】费用明细上传 `medOrgOrd` 不一致,导致医保局校验失败 | 🔴 致命 |
| 23 | **退款通知是商户主动 POST不是回调** | 退款流程实现 | 把 `POST /v3/med-ins/refunds/notify` 当作"接收微信回调"实现,遗漏了商户主动通知动作;正确语义是**商户向微信通知**医保侧已退款 | 🔴 致命 |
| 24 | **APIv3 密钥 / 私钥 / 公钥 ID 不可硬编码** | 配置加载 | 密钥 / 私钥 / 公钥 ID 写死在代码或 git 仓库的配置文件,未走环境变量 / KMS / Secrets Manager | 🔴 致命 |
| 25 | **商户 API 证书私钥保护** | 私钥加载与权限 | 私钥文件权限为 644 或更宽未做最小权限CRLF 转 LF 后未保留 PEM 头尾 | 🟡 推荐 |
| 26 | **医保电子凭证绑定预校验** | 调起前用户态判断 | 未在前端先引导用户激活医保电子凭证,导致下单时报 `INVALID_REQUEST 微信号未绑定医保电子凭证` | 🟠 可选 |
| 27 | **med_ins_test_env 上线后置 false** | 创单参数构造 | 联调期 `med_ins_test_env = true` 但上线后未关闭,导致正式环境下单到医保局测试环境,造成资损 / 医保局对账差异 | 🔴 致命 |
| 28 | **passthrough_request_content 透传字段安全** | 创单参数构造 | 把 `pay_auth_no` / `pay_ord_id` / `setl_latlnt` 重复塞进 `passthrough_request_content`(文档明确禁止),且未对来自 HIS 的 JSON 做长度≤2048与转义校验 | 🟡 推荐 |
| 29 | **重试机制带指数退避 + 幂等 out_trade_no** | 创单 / 查单代码 | 网络超时直接换 `out_trade_no` 重试,未先按原 `out_trade_no` 查单,导致重复下单 / 资损;或重试无指数退避触发 `RULE_LIMIT 请求次数超过限制` | 🔴 致命 |

View File

@@ -0,0 +1,213 @@
# 商户模式签名与验签规则
> 本文档为微信支付 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":"wxYOUR_APPID","mchid":"YOUR_MCHID","description":"Image形象店-深圳腾大-QQ公仔","out_trade_no":"1217752501201407033233368018","notify_url":"https://www.weixin.qq.com/wxpay/pay.php","amount":{"total":100,"currency":"CNY"},"payer":{"openid":"oYOUR_OPENID_EXAMPLE"}}\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="YOUR_MCHID",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` |
| **医保自费混合JSAPI / 小程序)** | 公众号 / 小程序 AppID | **`prepay_id=<value>`**(与 JSAPI 完全一致) | 在普通 JSAPI/小程序字段基础上,额外多一个 `mixTradeNo`**不参与 paySign 计算** |
签名串通用格式:
```
appId或小程序 appID\n
时间戳\n
随机字符串\n
prepay_id 或 prepay_id=<value>\n
```
JSAPI / 小程序 示例:
```
wxYOUR_APPID\n
1554208460\n
593BEC0C930BF1AFEB40B4A08C8FB242\n
prepay_id=wxYOUR_PREPAY_ID\n
```
### 医保自费混合支付JSAPI / 小程序)
**结论**:服务端 paySign **算法、4 行签名串、商户 API 证书私钥**与普通 JSAPI/小程序完全一致;只有前端调起方法、入参多一个 `mixTradeNo`、客户端版本要求不同。
`mixTradeNo` = 服务端调【医保自费混合收款下单 `POST /v3/med-ins/orders`】后应答的 `mix_trade_no`,透传给前端,**不入 paySign 签名**。
| 差异维度 | 普通 JSAPI / 小程序 | 医保自费混合 |
| ---- | ---- | ---- |
| JSAPI 调起 | `WeixinJSBridge.invoke('chooseWXPay', ...)` | `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)` |
| 小程序调起 | `wx.requestPayment(...)` | `wx.requestMedicalInsurancePay(...)` |
| `wx.config``jsApiList`(仅 JSAPI 需要) | `['chooseWXPay']` | `['requestMedicalInsurancePay']` |
| 入参字段 | `appid` + `timeStamp` / `nonceStr` / `package` / `signType` / `paySign` | 同左 + **`mixTradeNo`**`mix_pay_type=INSURANCE_ONLY`(纯医保)时除 `appid` + `mixTradeNo` 外 5 个支付字段可全省 |
| 客户端版本 | 普遍支持 | iOS / Android **≥ 8.0.44**;鸿蒙 **≥ 8.0.13** |
| 回调 errMsg | `chooseWXPay:ok` / `fail` / `cancel` | `requestMedicalInsurancePay:ok` / `fail`**无 cancel** |
入参示例:
```
WeixinJSBridge.invoke('requestMedicalInsurancePay', {
appid: 'wxYOUR_APPID',
timeStamp: '1414561699',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wxYOUR_PREPAY_ID',
signType: 'RSA',
paySign: '<与普通 JSAPI 同一算法算出的 4 行签名>',
mixTradeNo: '<服务端 mix_trade_no>'
});
```
**调起支付高频踩雷**
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. **医保自费混合调起**如适用paySign 签名串与普通 JSAPI 完全一致(`mixTradeNo` 不入签名);客户端方法用 `requestMedicalInsurancePay` 而非 `chooseWXPay`;纯医保单可省略 paySign 相关字段
11. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑

View File

@@ -0,0 +1,328 @@
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(
"YOUR_MCHID", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
"YOUR_CERT_SERIAL_NO", // 商户API证书序列号如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径本地文件路径
"YOUR_PUB_KEY_ID", // 微信支付公钥ID如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
"/path/to/wxp_pub.pem", // 微信支付公钥文件路径,本地文件路径
)
if err != nil {
fmt.Println(err)
return
}
// 加密敏感字段payer.name / payer.id_digest / relative.name / relative.id_digest
encryptedName, _ := wxpay_utility.EncryptOAEPWithPublicKey("张三", config.WechatPayPublicKey())
encryptedIdDigest, _ := wxpay_utility.EncryptOAEPWithPublicKey("09eb26e839ff3a2e3980352ae45ef09e", config.WechatPayPublicKey())
request := &CreateOrderRequest{
MixPayType: MIXPAYTYPE_CASH_AND_INSURANCE.Ptr(),
OrderType: ORDERTYPE_REG_PAY.Ptr(),
Appid: wxpay_utility.String("wxYOUR_APPID"),
Openid: wxpay_utility.String("oYOUR_OPENID_EXAMPLE"),
Payer: &PersonIdentification{
Name: wxpay_utility.String(encryptedName),
IdDigest: wxpay_utility.String(encryptedIdDigest),
CardType: USERCARDTYPE_ID_CARD.Ptr(),
},
PayForRelatives: wxpay_utility.Bool(false),
OutTradeNo: wxpay_utility.String("202204022005169952975171534816"),
SerialNo: wxpay_utility.String("1217752501201"),
PayOrderId: wxpay_utility.String("ORD530100202204022006350000021"),
PayAuthNo: wxpay_utility.String("AUTH530100202204022006310000034"),
GeoLocation: wxpay_utility.String("102.682296,25.054260"),
CityId: wxpay_utility.String("530100"),
MedInstName: wxpay_utility.String("北大医院"),
MedInstNo: wxpay_utility.String("1217752501201407033233368318"),
MedInsOrderCreateTime: wxpay_utility.String("2015-05-20T13:29:35+08:00"),
TotalFee: wxpay_utility.Int64(202000),
MedInsGovFee: wxpay_utility.Int64(100000),
MedInsSelfFee: wxpay_utility.Int64(45000),
MedInsOtherFee: wxpay_utility.Int64(5000),
MedInsCashFee: wxpay_utility.Int64(50000),
WechatPayCashFee: wxpay_utility.Int64(42000),
CashAddDetail: []CashAddEntity{{
CashAddFee: wxpay_utility.Int64(2000),
CashAddType: CASHADDTYPE_FREIGHT.Ptr(),
}},
CashReduceDetail: []CashReduceEntity{{
CashReduceFee: wxpay_utility.Int64(10000),
CashReduceType: CASHREDUCETYPE_DEFAULT_REDUCE_TYPE.Ptr(),
}},
CallbackUrl: wxpay_utility.String("https://www.weixin.qq.com/wxpay/pay.php"),
PrepayId: wxpay_utility.String("wxYOUR_PREPAY_ID"),
Attach: wxpay_utility.String("{}"),
MedInsTestEnv: wxpay_utility.Bool(false),
}
response, err := CreateOrder(config, request)
if err != nil {
fmt.Printf("请求失败: %+v\n", err)
// TODO: 请求失败,根据状态码执行不同的处理
return
}
// TODO: 请求成功继续业务逻辑response.MixTradeNo 用于后续调起支付/查询/退款通知)
fmt.Printf("请求成功: %+v\n", response)
}
func CreateOrder(config *wxpay_utility.MchConfig, request *CreateOrderRequest) (response *OrderEntity, err error) {
const (
host = "https://api.mch.weixin.qq.com"
method = "POST"
path = "/v3/med-ins/orders"
)
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 := &OrderEntity{}
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 CreateOrderRequest struct {
MixPayType *MixPayType `json:"mix_pay_type,omitempty"`
OrderType *OrderType `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
Openid *string `json:"openid,omitempty"`
Payer *PersonIdentification `json:"payer,omitempty"`
PayForRelatives *bool `json:"pay_for_relatives,omitempty"`
Relative *PersonIdentification `json:"relative,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
SerialNo *string `json:"serial_no,omitempty"`
PayOrderId *string `json:"pay_order_id,omitempty"`
PayAuthNo *string `json:"pay_auth_no,omitempty"`
GeoLocation *string `json:"geo_location,omitempty"`
CityId *string `json:"city_id,omitempty"`
MedInstName *string `json:"med_inst_name,omitempty"`
MedInstNo *string `json:"med_inst_no,omitempty"`
MedInsOrderCreateTime *string `json:"med_ins_order_create_time,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
CashAddDetail []CashAddEntity `json:"cash_add_detail,omitempty"`
CashReduceDetail []CashReduceEntity `json:"cash_reduce_detail,omitempty"`
CallbackUrl *string `json:"callback_url,omitempty"`
PrepayId *string `json:"prepay_id,omitempty"`
PassthroughRequestContent *string `json:"passthrough_request_content,omitempty"`
Extends *string `json:"extends,omitempty"`
Attach *string `json:"attach,omitempty"`
ChannelNo *string `json:"channel_no,omitempty"`
MedInsTestEnv *bool `json:"med_ins_test_env,omitempty"`
}
type OrderEntity struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MixPayStatus *MixPayStatus `json:"mix_pay_status,omitempty"`
SelfPayStatus *SelfPayStatus `json:"self_pay_status,omitempty"`
MedInsPayStatus *MedInsPayStatus `json:"med_ins_pay_status,omitempty"`
PaidTime *string `json:"paid_time,omitempty"`
PassthroughResponseContent *string `json:"passthrough_response_content,omitempty"`
MixPayType *MixPayType `json:"mix_pay_type,omitempty"`
OrderType *OrderType `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
Openid *string `json:"openid,omitempty"`
PayForRelatives *bool `json:"pay_for_relatives,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
SerialNo *string `json:"serial_no,omitempty"`
PayOrderId *string `json:"pay_order_id,omitempty"`
PayAuthNo *string `json:"pay_auth_no,omitempty"`
GeoLocation *string `json:"geo_location,omitempty"`
CityId *string `json:"city_id,omitempty"`
MedInstName *string `json:"med_inst_name,omitempty"`
MedInstNo *string `json:"med_inst_no,omitempty"`
MedInsOrderCreateTime *string `json:"med_ins_order_create_time,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
CashAddDetail []CashAddEntity `json:"cash_add_detail,omitempty"`
CashReduceDetail []CashReduceEntity `json:"cash_reduce_detail,omitempty"`
CallbackUrl *string `json:"callback_url,omitempty"`
PrepayId *string `json:"prepay_id,omitempty"`
PassthroughRequestContent *string `json:"passthrough_request_content,omitempty"`
Extends *string `json:"extends,omitempty"`
Attach *string `json:"attach,omitempty"`
ChannelNo *string `json:"channel_no,omitempty"`
MedInsTestEnv *bool `json:"med_ins_test_env,omitempty"`
}
type MixPayType string
func (e MixPayType) Ptr() *MixPayType { return &e }
const (
MIXPAYTYPE_CASH_ONLY MixPayType = "CASH_ONLY"
MIXPAYTYPE_INSURANCE_ONLY MixPayType = "INSURANCE_ONLY"
MIXPAYTYPE_CASH_AND_INSURANCE MixPayType = "CASH_AND_INSURANCE"
)
type OrderType string
func (e OrderType) Ptr() *OrderType { return &e }
const (
ORDERTYPE_REG_PAY OrderType = "REG_PAY"
ORDERTYPE_DIAG_PAY OrderType = "DIAG_PAY"
ORDERTYPE_COVID_EXAM_PAY OrderType = "COVID_EXAM_PAY"
ORDERTYPE_IN_HOSP_PAY OrderType = "IN_HOSP_PAY"
ORDERTYPE_PHARMACY_PAY OrderType = "PHARMACY_PAY"
ORDERTYPE_INSURANCE_PAY OrderType = "INSURANCE_PAY"
ORDERTYPE_INT_REG_PAY OrderType = "INT_REG_PAY"
ORDERTYPE_INT_RE_DIAG_PAY OrderType = "INT_RE_DIAG_PAY"
ORDERTYPE_INT_RX_PAY OrderType = "INT_RX_PAY"
ORDERTYPE_COVID_ANTIGEN_PAY OrderType = "COVID_ANTIGEN_PAY"
ORDERTYPE_MED_PAY OrderType = "MED_PAY"
)
type PersonIdentification struct {
Name *string `json:"name,omitempty"`
IdDigest *string `json:"id_digest,omitempty"`
CardType *UserCardType `json:"card_type,omitempty"`
}
type CashAddEntity struct {
CashAddFee *int64 `json:"cash_add_fee,omitempty"`
CashAddType *CashAddType `json:"cash_add_type,omitempty"`
}
type CashReduceEntity struct {
CashReduceFee *int64 `json:"cash_reduce_fee,omitempty"`
CashReduceType *CashReduceType `json:"cash_reduce_type,omitempty"`
}
type MixPayStatus string
func (e MixPayStatus) Ptr() *MixPayStatus { return &e }
const (
MIXPAYSTATUS_MIX_PAY_CREATED MixPayStatus = "MIX_PAY_CREATED"
MIXPAYSTATUS_MIX_PAY_SUCCESS MixPayStatus = "MIX_PAY_SUCCESS"
MIXPAYSTATUS_MIX_PAY_REFUND MixPayStatus = "MIX_PAY_REFUND"
MIXPAYSTATUS_MIX_PAY_FAIL MixPayStatus = "MIX_PAY_FAIL"
)
type SelfPayStatus string
func (e SelfPayStatus) Ptr() *SelfPayStatus { return &e }
const (
SELFPAYSTATUS_SELF_PAY_CREATED SelfPayStatus = "SELF_PAY_CREATED"
SELFPAYSTATUS_SELF_PAY_SUCCESS SelfPayStatus = "SELF_PAY_SUCCESS"
SELFPAYSTATUS_SELF_PAY_REFUND SelfPayStatus = "SELF_PAY_REFUND"
SELFPAYSTATUS_SELF_PAY_FAIL SelfPayStatus = "SELF_PAY_FAIL"
SELFPAYSTATUS_NO_SELF_PAY SelfPayStatus = "NO_SELF_PAY"
)
type MedInsPayStatus string
func (e MedInsPayStatus) Ptr() *MedInsPayStatus { return &e }
const (
MEDINSPAYSTATUS_MED_INS_PAY_CREATED MedInsPayStatus = "MED_INS_PAY_CREATED"
MEDINSPAYSTATUS_MED_INS_PAY_SUCCESS MedInsPayStatus = "MED_INS_PAY_SUCCESS"
MEDINSPAYSTATUS_MED_INS_PAY_REFUND MedInsPayStatus = "MED_INS_PAY_REFUND"
MEDINSPAYSTATUS_MED_INS_PAY_FAIL MedInsPayStatus = "MED_INS_PAY_FAIL"
MEDINSPAYSTATUS_NO_MED_INS_PAY MedInsPayStatus = "NO_MED_INS_PAY"
)
type UserCardType string
func (e UserCardType) Ptr() *UserCardType { return &e }
const (
USERCARDTYPE_ID_CARD UserCardType = "ID_CARD"
USERCARDTYPE_HOUSEHOLD_REGISTRATION UserCardType = "HOUSEHOLD_REGISTRATION"
USERCARDTYPE_FOREIGNER_PASSPORT UserCardType = "FOREIGNER_PASSPORT"
USERCARDTYPE_MAINLAND_TRAVEL_PERMIT_FOR_TW UserCardType = "MAINLAND_TRAVEL_PERMIT_FOR_TW"
USERCARDTYPE_MAINLAND_TRAVEL_PERMIT_FOR_MO UserCardType = "MAINLAND_TRAVEL_PERMIT_FOR_MO"
USERCARDTYPE_MAINLAND_TRAVEL_PERMIT_FOR_HK UserCardType = "MAINLAND_TRAVEL_PERMIT_FOR_HK"
USERCARDTYPE_FOREIGN_PERMANENT_RESIDENT UserCardType = "FOREIGN_PERMANENT_RESIDENT"
)
type CashAddType string
func (e CashAddType) Ptr() *CashAddType { return &e }
const (
CASHADDTYPE_DEFAULT_ADD_TYPE CashAddType = "DEFAULT_ADD_TYPE"
CASHADDTYPE_FREIGHT CashAddType = "FREIGHT"
CASHADDTYPE_OTHER_MEDICAL_EXPENSES CashAddType = "OTHER_MEDICAL_EXPENSES"
)
type CashReduceType string
func (e CashReduceType) Ptr() *CashReduceType { return &e }
const (
CASHREDUCETYPE_DEFAULT_REDUCE_TYPE CashReduceType = "DEFAULT_REDUCE_TYPE"
CASHREDUCETYPE_HOSPITAL_REDUCE CashReduceType = "HOSPITAL_REDUCE"
CASHREDUCETYPE_PHARMACY_DISCOUNT CashReduceType = "PHARMACY_DISCOUNT"
CASHREDUCETYPE_DISCOUNT CashReduceType = "DISCOUNT"
CASHREDUCETYPE_PRE_PAYMENT CashReduceType = "PRE_PAYMENT"
CASHREDUCETYPE_DEPOSIT_DEDUCTION CashReduceType = "DEPOSIT_DEDUCTION"
)

View File

@@ -0,0 +1,124 @@
package main
import (
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func main() {
config, err := wxpay_utility.CreateMchConfig(
"YOUR_MCHID",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem",
)
if err != nil {
fmt.Println(err)
return
}
request := &QueryRequest{
MixTradeNo: wxpay_utility.String("202204022005169952975171534816"),
}
response, err := QueryByMixTradeNo(config, request)
if err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Printf("请求成功: %+v\n", response)
}
func QueryByMixTradeNo(config *wxpay_utility.MchConfig, request *QueryRequest) (response *OrderEntity, err error) {
const (
host = "https://api.mch.weixin.qq.com"
method = "GET"
path = "/v3/med-ins/orders/mix-trade-no/{mix_trade_no}"
)
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
if err != nil {
return nil, err
}
reqUrl.Path = strings.Replace(reqUrl.Path, "{mix_trade_no}", url.PathEscape(*request.MixTradeNo), -1)
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 := &OrderEntity{}
if err := json.Unmarshal(respBody, response); err != nil {
return nil, err
}
return response, nil
}
return nil, wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type QueryRequest struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
}
func (o *QueryRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(struct{}{})
}
type OrderEntity struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MixPayStatus *string `json:"mix_pay_status,omitempty"`
SelfPayStatus *string `json:"self_pay_status,omitempty"`
MedInsPayStatus *string `json:"med_ins_pay_status,omitempty"`
PaidTime *string `json:"paid_time,omitempty"`
MixPayType *string `json:"mix_pay_type,omitempty"`
OrderType *string `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
Openid *string `json:"openid,omitempty"`
PayForRelatives *bool `json:"pay_for_relatives,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
SerialNo *string `json:"serial_no,omitempty"`
PayOrderId *string `json:"pay_order_id,omitempty"`
PayAuthNo *string `json:"pay_auth_no,omitempty"`
GeoLocation *string `json:"geo_location,omitempty"`
CityId *string `json:"city_id,omitempty"`
MedInstName *string `json:"med_inst_name,omitempty"`
MedInstNo *string `json:"med_inst_no,omitempty"`
MedInsOrderCreateTime *string `json:"med_ins_order_create_time,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
CallbackUrl *string `json:"callback_url,omitempty"`
PrepayId *string `json:"prepay_id,omitempty"`
Attach *string `json:"attach,omitempty"`
ChannelNo *string `json:"channel_no,omitempty"`
MedInsTestEnv *bool `json:"med_ins_test_env,omitempty"`
MedInsFailReason *string `json:"med_ins_fail_reason,omitempty"`
}

View File

@@ -0,0 +1,112 @@
package main
import (
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func main() {
config, err := wxpay_utility.CreateMchConfig(
"YOUR_MCHID",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem",
)
if err != nil {
fmt.Println(err)
return
}
request := &QueryRequest{
OutTradeNo: wxpay_utility.String("202204022005169952975171534816"),
}
response, err := QueryByOutTradeNo(config, request)
if err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Printf("请求成功: %+v\n", response)
}
func QueryByOutTradeNo(config *wxpay_utility.MchConfig, request *QueryRequest) (response *OrderEntity, err error) {
const (
host = "https://api.mch.weixin.qq.com"
method = "GET"
path = "/v3/med-ins/orders/out-trade-no/{out_trade_no}"
)
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
if err != nil {
return nil, err
}
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_trade_no}", url.PathEscape(*request.OutTradeNo), -1)
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 := &OrderEntity{}
if err := json.Unmarshal(respBody, response); err != nil {
return nil, err
}
return response, nil
}
return nil, wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type QueryRequest struct {
OutTradeNo *string `json:"out_trade_no,omitempty"`
}
func (o *QueryRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(struct{}{})
}
type OrderEntity struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MixPayStatus *string `json:"mix_pay_status,omitempty"`
SelfPayStatus *string `json:"self_pay_status,omitempty"`
MedInsPayStatus *string `json:"med_ins_pay_status,omitempty"`
PaidTime *string `json:"paid_time,omitempty"`
MixPayType *string `json:"mix_pay_type,omitempty"`
OrderType *string `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
Openid *string `json:"openid,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
CallbackUrl *string `json:"callback_url,omitempty"`
PrepayId *string `json:"prepay_id,omitempty"`
MedInsFailReason *string `json:"med_ins_fail_reason,omitempty"`
}

View File

@@ -0,0 +1,117 @@
package main
import (
"bytes"
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// NotifyRefund 医保退款通知 —— 商户主动告知微信医保侧已发生退款
//
// 流程:
//
// 1) 医院 HIS 在医保局发起医保退款 → 医保局完成退款
// 2) 商户调用本接口告知微信
// 3) 若同时存在自费退款,请先调用 POST /v3/refund/domestic/refunds再用相同 out_refund_no 调用本接口
func main() {
config, err := wxpay_utility.CreateMchConfig(
"YOUR_MCHID",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem",
)
if err != nil {
fmt.Println(err)
return
}
request := &NotifyRefundRequest{
MixTradeNo: wxpay_utility.String("202204022005169952975171534816"),
MedRefundTotalFee: wxpay_utility.Int64(45000),
MedRefundGovFee: wxpay_utility.Int64(45000),
MedRefundSelfFee: wxpay_utility.Int64(0),
MedRefundOtherFee: wxpay_utility.Int64(0),
RefundTime: wxpay_utility.String("2015-05-20T13:29:35+08:00"),
OutRefundNo: wxpay_utility.String("R202204022005169952975171534816"),
}
if err := NotifyRefund(config, request); err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Println("医保退款通知成功")
}
func NotifyRefund(config *wxpay_utility.MchConfig, request *NotifyRefundRequest) error {
const (
host = "https://api.mch.weixin.qq.com"
method = "POST"
path = "/v3/med-ins/refunds/notify"
)
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
if err != nil {
return err
}
query := reqUrl.Query()
if request.MixTradeNo != nil {
query.Add("mix_trade_no", *request.MixTradeNo)
}
reqUrl.RawQuery = query.Encode()
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 {
return wxpay_utility.ValidateResponse(config.WechatPayPublicKeyId(), config.WechatPayPublicKey(), &httpResponse.Header, respBody)
}
return wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type NotifyRefundRequest struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MedRefundTotalFee *int64 `json:"med_refund_total_fee,omitempty"`
MedRefundGovFee *int64 `json:"med_refund_gov_fee,omitempty"`
MedRefundSelfFee *int64 `json:"med_refund_self_fee,omitempty"`
MedRefundOtherFee *int64 `json:"med_refund_other_fee,omitempty"`
RefundTime *string `json:"refund_time,omitempty"`
OutRefundNo *string `json:"out_refund_no,omitempty"`
}
func (o *NotifyRefundRequest) MarshalJSON() ([]byte, error) {
type Alias NotifyRefundRequest
a := &struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
*Alias
}{
MixTradeNo: nil,
Alias: (*Alias)(o),
}
return json.Marshal(a)
}

View File

@@ -0,0 +1,121 @@
# 医保混合收款成功通知说明(商户 - Go
> 内容与 [`Java/6-回调通知/医保混合收款成功通知说明.md`](../../Java/6-回调通知/医保混合收款成功通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
> 来源:[医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/merchant/4016781554.md)
> 通用解密 / 验签 / 回包流程参考 [📄 ../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
## 一、回调时机
当订单 `mix_pay_status = MIX_PAY_SUCCESS`(自费 + 医保两端均结算成功)时,微信支付通过 POST 向 [医保下单接口](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) 中传入的 `callback_url` 发送通知。
| 事件类型 | 含义 |
| --- | --- |
| `MEDICAL_INSURANCE.SUCCESS` | 医保混合收款成功 |
> ‼️ 微信仅在订单达到 `MIX_PAY_SUCCESS` 时回调一次。若 5 秒内未收到 200/204 应答将按指数退避重试30 秒后**不再重试**。
> ‼️ 商户**必须**对 `MIX_PAY_CREATED` 状态的订单做主动查询兜底(`GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}`),不能仅依赖回调。
## 二、HTTP 头
| 参数 | 描述 |
| --- | --- |
| `Wechatpay-Serial` | 验签所用微信支付公钥 ID`PUB_KEY_ID_*`)或微信支付平台证书序列号 |
| `Wechatpay-Signature` | 签名值 |
| `Wechatpay-Timestamp` | 时间戳(秒) |
| `Wechatpay-Nonce` | 随机串 |
> ‼️ `Wechatpay-Serial` 以 `PUB_KEY_ID_` 开头则用**微信支付公钥**验签,否则用**微信支付平台证书**验签。
> ‼️ 微信会随机下发 `Wechatpay-Signature` 以 `WECHATPAY/SIGNTEST/` 开头的[签名探测流量](https://pay.weixin.qq.com/doc/v3/merchant/4013053249.md),验签必失败,商户必须按规范应答 4XX/5XX。
## 三、报文结构
```json
{
"id": "EV-2018022511223320873",
"create_time": "2020-03-26T10:43:39+08:00",
"event_type": "MEDICAL_INSURANCE.SUCCESS",
"resource_type": "encrypt-resource",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "...",
"nonce": "...",
"associated_data": "..."
}
}
```
## 四、resource.ciphertext 解密后的业务字段
> 算法:`AEAD_AES_256_GCM`密钥APIv3 密钥32 字节)
> `nonce` / `associated_data` 用密文中对应字段,**不是**自己生成
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `mix_trade_no` | string(32) | 医保自费混合订单号 |
| `mix_pay_status` | string | 整体状态(回调中固定 `MIX_PAY_SUCCESS` |
| `self_pay_status` | string | `SELF_PAY_SUCCESS` / `NO_SELF_PAY` |
| `med_ins_pay_status` | string | `MED_INS_PAY_SUCCESS` / `NO_MED_INS_PAY` |
| `paid_time` | string(64) | 支付完成时间RFC3339 |
| `passthrough_response_content` | string(2048) | 医保局透传给医疗机构的内容 |
| `mix_pay_type` | string | `CASH_ONLY` / `INSURANCE_ONLY` / `CASH_AND_INSURANCE` |
| `order_type` | string | `REG_PAY` / `DIAG_PAY` / ... 详见 SKILL 总览 |
| `appid` | string(32) | 商户公众号 / 小程序 AppID |
| `openid` | string(128) | 用户在该 AppID 下的 openid |
| `pay_for_relatives` | bool | 是否代亲属支付 |
| `out_trade_no` | string(64) | 商户订单号 |
| `serial_no` | string(20) | 医疗机构订单号 |
| `pay_order_id` | string(64) | 医保局支付单 ID |
| `pay_auth_no` | string(40) | 医保局支付授权码 |
| `geo_location` | string(40) | 用户经纬度 `经度,纬度` |
| `city_id` | string(8) | 城市 ID |
| `med_inst_name` | string(128) | 医疗机构名称 |
| `med_inst_no` | string(32) | 医疗机构编码 |
| `med_ins_order_create_time` | string(64) | 医保下单时间 |
| `total_fee` | uint64 | 订单总金额(分) |
| `med_ins_gov_fee` | uint64 | 医保统筹支付金额(分) |
| `med_ins_self_fee` | uint64 | 医保个账支付金额(分) |
| `med_ins_other_fee` | uint64 | 医保其他津贴金额(分) |
| `med_ins_cash_fee` | uint64 | 医保结算后自费金额(分) |
| `wechat_pay_cash_fee` | uint64 | 微信支付实收金额(分) |
| `cash_add_detail[].cash_add_fee` | uint64 | 现金补充金额 |
| `cash_add_detail[].cash_add_type` | string | `DEFAULT_ADD_TYPE` / `FREIGHT` / `OTHER_MEDICAL_EXPENSES` |
| `cash_reduce_detail[].cash_reduce_fee` | uint64 | 现金减免金额 |
| `cash_reduce_detail[].cash_reduce_type` | string | `DEFAULT_REDUCE_TYPE` / `HOSPITAL_REDUCE` / `PHARMACY_DISCOUNT` / `DISCOUNT` / `PRE_PAYMENT` / `DEPOSIT_DEDUCTION` |
| `callback_url` | string(256) | 回调通知 URL |
| `prepay_id` | string(64) | 自费预下单 ID |
| `attach` | string(128) | 商户自定义透传 |
| `channel_no` | string(32) | 渠道号 |
| `med_ins_test_env` | bool | 是否医保局测试环境 |
## 五、应答规范
| 场景 | HTTP 状态码 | 应答体 |
| --- | --- | --- |
| 验签通过 + 业务处理成功 | 200 / 204 | 无包体 |
| 验签失败 / 业务处理失败 | 4XX / 5XX | `{"code": "FAIL", "message": "失败"}` |
> ‼️ 必须先验签再处理业务,验签失败时**禁止**返回 200否则微信将认为商户已收不再重试造成丢单。
> ‼️ 业务处理建议异步化,回调线程内只做:验签 → 解密 → 写入「待处理订单表」→ 立即应答 200。后续状态更新走异步消费避免业务慢导致回调超时被重试。
## 六、幂等性
同一订单可能收到多次回调(重试或网络抖动),商户必须按 `mix_trade_no` 做幂等:
```
SELECT mix_pay_status FROM med_ins_orders WHERE mix_trade_no = ?;
IF mix_pay_status = 'MIX_PAY_SUCCESS' THEN
-- 已处理,直接返回 200
ELSE
-- 用 SELECT FOR UPDATE / 行锁更新状态,再返回 200
END IF;
```
## 七、与查单的关系
| 场景 | 推荐做法 |
| --- | --- |
| 30 秒内收到回调 | 验签 + 解密 + 幂等更新订单 |
| 30 秒后未收到回调 | 主动调用查单接口确认状态 |
| 收到回调但解密 / 验签失败 | 应答 4XX/5XX触发微信重试同时 LOG 报警,调用查单接口兜底 |
| 用户客诉「钱已扣未发货」 | 优先以查单结果为准,不依赖回调记录 |

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,295 @@
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 CreateMedInsOrder {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "POST";
private static String PATH = "/v3/med-ins/orders";
public static void main(String[] args) {
// TODO: 请准备商户开发必要参数参考https://pay.weixin.qq.com/doc/v3/merchant/4013070756
CreateMedInsOrder client = new CreateMedInsOrder(
"YOUR_MCHID", // 商户号,是由微信支付系统生成并分配给每个商户的唯一标识符,商户号获取方式参考 https://pay.weixin.qq.com/doc/v3/merchant/4013070756
"YOUR_CERT_SERIAL_NO", // 商户API证书序列号如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013053053
"/path/to/apiclient_key.pem", // 商户API证书私钥文件路径本地文件路径
"YOUR_PUB_KEY_ID", // 微信支付公钥ID如何获取请参考 https://pay.weixin.qq.com/doc/v3/merchant/4013038816
"/path/to/wxp_pub.pem" // 微信支付公钥文件路径,本地文件路径
);
CreateOrderRequest request = new CreateOrderRequest();
request.mixPayType = MixPayType.CASH_AND_INSURANCE;
request.orderType = OrderType.REG_PAY;
request.appid = "wxYOUR_APPID";
request.openid = "oYOUR_OPENID_EXAMPLE";
request.payer = new PersonIdentification();
request.payer.name = client.encrypt("张三");
request.payer.idDigest = client.encrypt("09eb26e839ff3a2e3980352ae45ef09e");
request.payer.cardType = UserCardType.ID_CARD;
request.payForRelatives = false;
request.outTradeNo = "202204022005169952975171534816";
request.serialNo = "1217752501201";
request.payOrderId = "ORD530100202204022006350000021";
request.payAuthNo = "AUTH530100202204022006310000034";
request.geoLocation = "102.682296,25.054260";
request.cityId = "530100";
request.medInstName = "北大医院";
request.medInstNo = "1217752501201407033233368318";
request.medInsOrderCreateTime = "2015-05-20T13:29:35+08:00";
request.totalFee = 202000L;
request.medInsGovFee = 100000L;
request.medInsSelfFee = 45000L;
request.medInsOtherFee = 5000L;
request.medInsCashFee = 50000L;
request.wechatPayCashFee = 42000L;
request.cashAddDetail = new ArrayList<>();
{
CashAddEntity cashAddDetailItem = new CashAddEntity();
cashAddDetailItem.cashAddFee = 2000L;
cashAddDetailItem.cashAddType = CashAddType.FREIGHT;
request.cashAddDetail.add(cashAddDetailItem);
};
request.cashReduceDetail = new ArrayList<>();
{
CashReduceEntity cashReduceDetailItem = new CashReduceEntity();
cashReduceDetailItem.cashReduceFee = 10000L;
cashReduceDetailItem.cashReduceType = CashReduceType.DEFAULT_REDUCE_TYPE;
request.cashReduceDetail.add(cashReduceDetailItem);
};
request.callbackUrl = "https://www.weixin.qq.com/wxpay/pay.php";
request.prepayId = "wxYOUR_PREPAY_ID";
request.attach = "{}";
request.medInsTestEnv = false;
try {
OrderEntity response = client.run(request);
// TODO: 请求成功继续业务逻辑response.mixTradeNo 用于后续调起支付/查询/退款通知)
System.out.println(response);
} catch (WXPayUtility.ApiException e) {
// TODO: 请求失败,根据状态码执行不同的逻辑
e.printStackTrace();
}
}
public OrderEntity run(CreateOrderRequest 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, OrderEntity.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 CreateMedInsOrder(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);
}
/** 加密敏感字段payer.name / payer.id_digest / relative.name / relative.id_digest 必须加密后再传) */
public String encrypt(String plainText) {
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
}
public static class CreateOrderRequest {
@SerializedName("mix_pay_type") public MixPayType mixPayType;
@SerializedName("order_type") public OrderType orderType;
@SerializedName("appid") public String appid;
@SerializedName("openid") public String openid;
@SerializedName("payer") public PersonIdentification payer;
@SerializedName("pay_for_relatives") public Boolean payForRelatives;
@SerializedName("relative") public PersonIdentification relative;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("serial_no") public String serialNo;
@SerializedName("pay_order_id") public String payOrderId;
@SerializedName("pay_auth_no") public String payAuthNo;
@SerializedName("geo_location") public String geoLocation;
@SerializedName("city_id") public String cityId;
@SerializedName("med_inst_name") public String medInstName;
@SerializedName("med_inst_no") public String medInstNo;
@SerializedName("med_ins_order_create_time") public String medInsOrderCreateTime;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("cash_add_detail") public List<CashAddEntity> cashAddDetail;
@SerializedName("cash_reduce_detail") public List<CashReduceEntity> cashReduceDetail;
@SerializedName("callback_url") public String callbackUrl;
@SerializedName("prepay_id") public String prepayId;
@SerializedName("passthrough_request_content") public String passthroughRequestContent;
@SerializedName("extends") public String _extends;
@SerializedName("attach") public String attach;
@SerializedName("channel_no") public String channelNo;
@SerializedName("med_ins_test_env") public Boolean medInsTestEnv;
}
public static class OrderEntity {
@SerializedName("mix_trade_no") public String mixTradeNo;
@SerializedName("mix_pay_status") public MixPayStatus mixPayStatus;
@SerializedName("self_pay_status") public SelfPayStatus selfPayStatus;
@SerializedName("med_ins_pay_status") public MedInsPayStatus medInsPayStatus;
@SerializedName("paid_time") public String paidTime;
@SerializedName("passthrough_response_content") public String passthroughResponseContent;
@SerializedName("mix_pay_type") public MixPayType mixPayType;
@SerializedName("order_type") public OrderType orderType;
@SerializedName("appid") public String appid;
@SerializedName("openid") public String openid;
@SerializedName("pay_for_relatives") public Boolean payForRelatives;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("serial_no") public String serialNo;
@SerializedName("pay_order_id") public String payOrderId;
@SerializedName("pay_auth_no") public String payAuthNo;
@SerializedName("geo_location") public String geoLocation;
@SerializedName("city_id") public String cityId;
@SerializedName("med_inst_name") public String medInstName;
@SerializedName("med_inst_no") public String medInstNo;
@SerializedName("med_ins_order_create_time") public String medInsOrderCreateTime;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("cash_add_detail") public List<CashAddEntity> cashAddDetail;
@SerializedName("cash_reduce_detail") public List<CashReduceEntity> cashReduceDetail;
@SerializedName("callback_url") public String callbackUrl;
@SerializedName("prepay_id") public String prepayId;
@SerializedName("passthrough_request_content") public String passthroughRequestContent;
@SerializedName("extends") public String _extends;
@SerializedName("attach") public String attach;
@SerializedName("channel_no") public String channelNo;
@SerializedName("med_ins_test_env") public Boolean medInsTestEnv;
}
public enum MixPayType {
@SerializedName("CASH_ONLY") CASH_ONLY,
@SerializedName("INSURANCE_ONLY") INSURANCE_ONLY,
@SerializedName("CASH_AND_INSURANCE") CASH_AND_INSURANCE
}
public enum OrderType {
@SerializedName("REG_PAY") REG_PAY,
@SerializedName("DIAG_PAY") DIAG_PAY,
@SerializedName("COVID_EXAM_PAY") COVID_EXAM_PAY,
@SerializedName("IN_HOSP_PAY") IN_HOSP_PAY,
@SerializedName("PHARMACY_PAY") PHARMACY_PAY,
@SerializedName("INSURANCE_PAY") INSURANCE_PAY,
@SerializedName("INT_REG_PAY") INT_REG_PAY,
@SerializedName("INT_RE_DIAG_PAY") INT_RE_DIAG_PAY,
@SerializedName("INT_RX_PAY") INT_RX_PAY,
@SerializedName("COVID_ANTIGEN_PAY") COVID_ANTIGEN_PAY,
@SerializedName("MED_PAY") MED_PAY
}
public static class PersonIdentification {
@SerializedName("name") public String name;
@SerializedName("id_digest") public String idDigest;
@SerializedName("card_type") public UserCardType cardType;
}
public static class CashAddEntity {
@SerializedName("cash_add_fee") public Long cashAddFee;
@SerializedName("cash_add_type") public CashAddType cashAddType;
}
public static class CashReduceEntity {
@SerializedName("cash_reduce_fee") public Long cashReduceFee;
@SerializedName("cash_reduce_type") public CashReduceType cashReduceType;
}
public enum MixPayStatus {
@SerializedName("MIX_PAY_CREATED") MIX_PAY_CREATED,
@SerializedName("MIX_PAY_SUCCESS") MIX_PAY_SUCCESS,
@SerializedName("MIX_PAY_REFUND") MIX_PAY_REFUND,
@SerializedName("MIX_PAY_FAIL") MIX_PAY_FAIL
}
public enum SelfPayStatus {
@SerializedName("SELF_PAY_CREATED") SELF_PAY_CREATED,
@SerializedName("SELF_PAY_SUCCESS") SELF_PAY_SUCCESS,
@SerializedName("SELF_PAY_REFUND") SELF_PAY_REFUND,
@SerializedName("SELF_PAY_FAIL") SELF_PAY_FAIL,
@SerializedName("NO_SELF_PAY") NO_SELF_PAY
}
public enum MedInsPayStatus {
@SerializedName("MED_INS_PAY_CREATED") MED_INS_PAY_CREATED,
@SerializedName("MED_INS_PAY_SUCCESS") MED_INS_PAY_SUCCESS,
@SerializedName("MED_INS_PAY_REFUND") MED_INS_PAY_REFUND,
@SerializedName("MED_INS_PAY_FAIL") MED_INS_PAY_FAIL,
@SerializedName("NO_MED_INS_PAY") NO_MED_INS_PAY
}
public enum UserCardType {
@SerializedName("ID_CARD") ID_CARD,
@SerializedName("HOUSEHOLD_REGISTRATION") HOUSEHOLD_REGISTRATION,
@SerializedName("FOREIGNER_PASSPORT") FOREIGNER_PASSPORT,
@SerializedName("MAINLAND_TRAVEL_PERMIT_FOR_TW") MAINLAND_TRAVEL_PERMIT_FOR_TW,
@SerializedName("MAINLAND_TRAVEL_PERMIT_FOR_MO") MAINLAND_TRAVEL_PERMIT_FOR_MO,
@SerializedName("MAINLAND_TRAVEL_PERMIT_FOR_HK") MAINLAND_TRAVEL_PERMIT_FOR_HK,
@SerializedName("FOREIGN_PERMANENT_RESIDENT") FOREIGN_PERMANENT_RESIDENT
}
public enum CashAddType {
@SerializedName("DEFAULT_ADD_TYPE") DEFAULT_ADD_TYPE,
@SerializedName("FREIGHT") FREIGHT,
@SerializedName("OTHER_MEDICAL_EXPENSES") OTHER_MEDICAL_EXPENSES
}
public enum CashReduceType {
@SerializedName("DEFAULT_REDUCE_TYPE") DEFAULT_REDUCE_TYPE,
@SerializedName("HOSPITAL_REDUCE") HOSPITAL_REDUCE,
@SerializedName("PHARMACY_DISCOUNT") PHARMACY_DISCOUNT,
@SerializedName("DISCOUNT") DISCOUNT,
@SerializedName("PRE_PAYMENT") PRE_PAYMENT,
@SerializedName("DEPOSIT_DEDUCTION") DEPOSIT_DEDUCTION
}
}

View File

@@ -0,0 +1,123 @@
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.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.List;
/**
* 使用医保自费混合订单号查看下单结果
*/
public class QueryByMixTradeNo {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "GET";
private static String PATH = "/v3/med-ins/orders/mix-trade-no/{mix_trade_no}";
public static void main(String[] args) {
QueryByMixTradeNo client = new QueryByMixTradeNo(
"YOUR_MCHID",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem"
);
QueryRequest request = new QueryRequest();
request.mixTradeNo = "202204022005169952975171534816";
try {
OrderEntity response = client.run(request);
System.out.println(response);
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public OrderEntity run(QueryRequest request) {
String uri = PATH.replace("{mix_trade_no}", WXPayUtility.urlEncode(request.mixTradeNo));
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, OrderEntity.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 QueryByMixTradeNo(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 QueryRequest {
@SerializedName("mix_trade_no")
@Expose(serialize = false)
public String mixTradeNo;
}
/** 应答结构与下单接口一致,新增 med_ins_fail_reason失败原因仅查询时返回 */
public static class OrderEntity {
@SerializedName("mix_trade_no") public String mixTradeNo;
@SerializedName("mix_pay_status") public String mixPayStatus;
@SerializedName("self_pay_status") public String selfPayStatus;
@SerializedName("med_ins_pay_status") public String medInsPayStatus;
@SerializedName("paid_time") public String paidTime;
@SerializedName("passthrough_response_content") public String passthroughResponseContent;
@SerializedName("mix_pay_type") public String mixPayType;
@SerializedName("order_type") public String orderType;
@SerializedName("appid") public String appid;
@SerializedName("openid") public String openid;
@SerializedName("pay_for_relatives") public Boolean payForRelatives;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("serial_no") public String serialNo;
@SerializedName("pay_order_id") public String payOrderId;
@SerializedName("pay_auth_no") public String payAuthNo;
@SerializedName("geo_location") public String geoLocation;
@SerializedName("city_id") public String cityId;
@SerializedName("med_inst_name") public String medInstName;
@SerializedName("med_inst_no") public String medInstNo;
@SerializedName("med_ins_order_create_time") public String medInsOrderCreateTime;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("callback_url") public String callbackUrl;
@SerializedName("prepay_id") public String prepayId;
@SerializedName("attach") public String attach;
@SerializedName("channel_no") public String channelNo;
@SerializedName("med_ins_test_env") public Boolean medInsTestEnv;
@SerializedName("med_ins_fail_reason") public String medInsFailReason;
}
}

View File

@@ -0,0 +1,109 @@
package com.java.demo;
import com.java.utils.WXPayUtility;
import com.google.gson.annotations.SerializedName;
import com.google.gson.annotations.Expose;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.security.PublicKey;
/**
* 使用商户订单号out_trade_no查看医保订单结果
*/
public class QueryByOutTradeNo {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "GET";
private static String PATH = "/v3/med-ins/orders/out-trade-no/{out_trade_no}";
public static void main(String[] args) {
QueryByOutTradeNo client = new QueryByOutTradeNo(
"YOUR_MCHID",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem"
);
QueryRequest request = new QueryRequest();
request.outTradeNo = "202204022005169952975171534816";
try {
OrderEntity response = client.run(request);
System.out.println(response);
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public OrderEntity run(QueryRequest request) {
String uri = PATH.replace("{out_trade_no}", WXPayUtility.urlEncode(request.outTradeNo));
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, OrderEntity.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 QueryByOutTradeNo(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 QueryRequest {
@SerializedName("out_trade_no")
@Expose(serialize = false)
public String outTradeNo;
}
/** 应答结构与 QueryByMixTradeNo 一致 */
public static class OrderEntity {
@SerializedName("mix_trade_no") public String mixTradeNo;
@SerializedName("mix_pay_status") public String mixPayStatus;
@SerializedName("self_pay_status") public String selfPayStatus;
@SerializedName("med_ins_pay_status") public String medInsPayStatus;
@SerializedName("paid_time") public String paidTime;
@SerializedName("mix_pay_type") public String mixPayType;
@SerializedName("order_type") public String orderType;
@SerializedName("appid") public String appid;
@SerializedName("openid") public String openid;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("callback_url") public String callbackUrl;
@SerializedName("prepay_id") public String prepayId;
@SerializedName("med_ins_fail_reason") public String medInsFailReason;
}
}

View File

@@ -0,0 +1,117 @@
package com.java.demo;
import com.java.utils.WXPayUtility;
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.HashMap;
import java.util.Map;
/**
* 医保退款通知 —— 商户主动告知微信医保侧已发生退款
*
* 流程:
* 1) 医院 HIS 在医保局发起医保退款 → 医保局完成退款
* 2) 商户调用本接口告知微信
* 3) 若同时存在自费退款,请先调用 POST /v3/refund/domestic/refunds再用相同 out_refund_no 调用本接口
*/
public class NotifyMedInsRefund {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "POST";
private static String PATH = "/v3/med-ins/refunds/notify";
public static void main(String[] args) {
NotifyMedInsRefund client = new NotifyMedInsRefund(
"YOUR_MCHID",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem"
);
NotifyRefundRequest request = new NotifyRefundRequest();
request.mixTradeNo = "202204022005169952975171534816";
request.medRefundTotalFee = 45000L;
request.medRefundGovFee = 45000L;
request.medRefundSelfFee = 0L;
request.medRefundOtherFee = 0L;
request.refundTime = "2015-05-20T13:29:35+08:00";
request.outRefundNo = "R202204022005169952975171534816";
try {
client.run(request);
System.out.println("医保退款通知成功");
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public void run(NotifyRefundRequest request) {
String uri = PATH;
Map<String, Object> args = new HashMap<>();
args.put("mix_trade_no", request.mixTradeNo);
String queryString = WXPayUtility.urlEncode(args);
if (!queryString.isEmpty()) {
uri = uri + "?" + queryString;
}
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 NotifyMedInsRefund(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 NotifyRefundRequest {
@SerializedName("mix_trade_no")
@Expose(serialize = false)
public String mixTradeNo;
@SerializedName("med_refund_total_fee") public Long medRefundTotalFee;
@SerializedName("med_refund_gov_fee") public Long medRefundGovFee;
@SerializedName("med_refund_self_fee") public Long medRefundSelfFee;
@SerializedName("med_refund_other_fee") public Long medRefundOtherFee;
@SerializedName("refund_time") public String refundTime;
@SerializedName("out_refund_no") public String outRefundNo;
}
}

View File

@@ -0,0 +1,70 @@
# 小程序调起医保支付说明(商户)
> 来源:[小程序调起医保自费混合支付](https://pay.weixin.qq.com/doc/v3/merchant/4016781545.md)
## 整体流程
1. 若有自费金额:调用 [JSAPI 自费下单](https://pay.weixin.qq.com/doc/v3/merchant/4012791882.md) 拿到自费 `prepay_id`,并按 [JSAPI 调起规则](https://pay.weixin.qq.com/doc/v3/merchant/4012791886.md) 计算 `timeStamp` / `nonceStr` / `package` / `signType` / `paySign`
2. 调用商户医保下单接口 `POST /v3/med-ins/orders` 拿到 `mix_trade_no`
3. 在小程序中调用 `wx.requestMedicalInsurancePay` 调起医保自费混合支付收银台
4. 用户输入医保电子凭证密码完成支付
5. 小程序通过 `onShow` 监听返回事件,调用 `GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}` 查询最终结果,刷新业务页面
6. 服务端同时接收微信回调 `MEDICAL_INSURANCE.SUCCESS` 兜底(参考 `6-回调通知/`
## 兼容性
- iOS / Android 微信版本 ≥ 8.0.44
- HarmonyOS 微信版本 ≥ 8.0.13
低于以上版本时,开发者必须提示用户升级微信,**禁止**降级走普通支付,否则医保部分无法结算。
## 参数说明
| 参数 | 类型 | 必填 | 描述 |
| --- | --- | --- | --- |
| `mixTradeNo` | string(256) | 是 | 医保下单接口返回的 `mix_trade_no` |
| `timeStamp` | string(32) | 有自费时必填 | 时间戳秒级10 位) |
| `nonceStr` | string(32) | 有自费时必填 | 随机字符串≤32 位 |
| `package` | string(128) | 有自费时必填 | `prepay_id=自费下单返回的prepay_id` |
| `signType` | string(32) | 有自费时必填 | 固定 `RSA` |
| `paySign` | string(256) | 有自费时必填 | 按 [JSAPI 调起规则](https://pay.weixin.qq.com/doc/v3/merchant/4012791886.md) 用商户 API 证书私钥签名 |
> ‼️ `mix_pay_type = INSURANCE_ONLY`(纯医保)时**不要**传 `timeStamp` / `nonceStr` / `package` / `signType` / `paySign`,否则触发 `PARAM_ERROR`。
> ‼️ `mix_pay_type = CASH_ONLY` / `CASH_AND_INSURANCE` 时上述自费字段必填,缺失会触发自费部分调起失败。
> ‼️ `package` 必须形如 `prepay_id=wx20...`(带前缀),不能只传 `prepay_id` 值。
## 调用示例
```javascript
wx.requestMedicalInsurancePay({
mixTradeNo: '1217752501201407033233368318',
timeStamp: '1414561699',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wxYOUR_PREPAY_ID',
signType: 'RSA',
paySign: 'oR9d8Puhn...',
success(res) {
// res.errMsg === 'requestMedicalInsurancePay:ok'
// 注意success 仅代表用户完成调起,**不代表支付一定成功**
// 必须通过查单接口或回调确认 mix_pay_status === 'MIX_PAY_SUCCESS'
},
fail(res) {
// res.errMsg === 'requestMedicalInsurancePay:fail xxx'
// 引导用户重试、检查医保电子凭证激活状态、或联系客服
}
});
```
## 回调结果
| 回调类型 | errMsg | 说明 |
| :-- | :-- | :-- |
| success | `requestMedicalInsurancePay:ok` | 调起流程结束(不代表支付成功) |
| fail | `requestMedicalInsurancePay:fail` | 调起流程失败 |
## 常见问题
- **签名错误**`paySign` 必须用**商户 API 证书私钥**做 RSA-SHA256 签名(与普通 JSAPI 调起一致),**不要**误用 APIv3 密钥
- **AppID 不一致**:调起所用小程序 AppID 必须与下单时 `appid` 完全一致,且 `openid` 来自同一 AppID
- **mix_trade_no 缺失**:必须使用医保下单接口返回的 `mix_trade_no`,不能用 `out_trade_no`
- **用户未激活医保电子凭证**:会触发 fail需引导用户在微信「我 → 服务 → 医疗健康 → 医保电子凭证」中激活

View File

@@ -0,0 +1,86 @@
# JSAPI 调起医保支付说明(商户)
> 来源:[JSAPI 调起医保自费混合支付](https://pay.weixin.qq.com/doc/v3/merchant/4016781549.md)
## 整体流程
1. 若有自费金额:调用 [JSAPI 自费下单](https://pay.weixin.qq.com/doc/v3/merchant/4012791882.md) 拿到自费 `prepay_id`,并按 [JSAPI 调起规则](https://pay.weixin.qq.com/doc/v3/merchant/4012791886.md) 计算 `timeStamp` / `nonceStr` / `package` / `signType` / `paySign`
2. 调用商户医保下单接口 `POST /v3/med-ins/orders` 拿到 `mix_trade_no`
3. 公众号 H5 页面通过 `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)` 调起医保支付
4. 用户输入医保电子凭证密码完成支付
5. H5 调用 `GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}` 查询最终结果,刷新业务页面
6. 服务端同时接收微信回调 `MEDICAL_INSURANCE.SUCCESS` 兜底
## 兼容性
- iOS / Android 微信版本 ≥ 8.0.44
- HarmonyOS 微信版本 ≥ 8.0.13
## 调用前准备
调用 `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)` 前必须先通过 [`wx.config`](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#4) 注入权限,并在 `jsApiList` 中包含 `requestMedicalInsurancePay`
## 参数说明
| 参数 | 类型 | 必填 | 描述 |
| --- | --- | --- | --- |
| `appid` | string(32) | 是 | 商户公众号 AppID必须与下单 `appid` 一致) |
| `mixTradeNo` | string(256) | 是 | 医保下单接口返回的 `mix_trade_no` |
| `timeStamp` | string(32) | 有自费时必填 | 时间戳,秒级 |
| `nonceStr` | string(32) | 有自费时必填 | 随机串 ≤32 位 |
| `package` | string(128) | 有自费时必填 | `prepay_id=...` |
| `signType` | string(32) | 有自费时必填 | 固定 `RSA` |
| `paySign` | string(256) | 有自费时必填 | 商户 API 证书私钥 RSA-SHA256 签名 |
## 调用示例
```html
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
wx.config({
debug: false,
appId: 'wxYOUR_APPID',
timestamp: 1414561699,
nonceStr: 'XXXXXXXX',
signature: 'XXXXXXXX',
jsApiList: ['requestMedicalInsurancePay']
});
wx.ready(function () {
WeixinJSBridge.invoke(
'requestMedicalInsurancePay',
{
appid: 'wxYOUR_APPID',
mixTradeNo: '1217752501201407033233368318',
timeStamp: '1414561699',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wxYOUR_PREPAY_ID',
signType: 'RSA',
paySign: 'oR9d8Puhn...'
},
function (res) {
// res 示例:{ result: 'success', err_msg: 'requestMedicalInsurancePay:ok', msg: '已完成医保支付', err_desc: '' }
if (res.err_msg === 'requestMedicalInsurancePay:ok') {
// 调起成功(不代表支付一定成功),调用查单接口确认
} else {
// 调起失败
}
}
);
});
</script>
```
## 回调结果
| 回调类型 | errMsg | 说明 |
| :-- | :-- | :-- |
| success | `requestMedicalInsurancePay:ok` | 调起流程结束 |
| fail | `requestMedicalInsurancePay:fail` | 调起流程失败 |
## 常见问题
- **`appid` 与下单不一致**:触发 `PARAM_ERROR`
- **未在 `jsApiList` 中声明**:触发 `the permission value is offline verifying`
- **`paySign` 算法错误**:必须用商户 API 证书私钥做 RSA-SHA256不是 HMAC-SHA256也不是 APIv3 密钥)
- **签名串构造错误**:必须按 `appId\ntimeStamp\nnonceStr\npackage\n` 顺序拼接(每行末尾换行)

View File

@@ -0,0 +1,119 @@
# 医保混合收款成功通知说明(商户)
> 来源:[医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/merchant/4016781554.md)
> 通用解密 / 验签 / 回包流程参考 [📄 ../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
## 一、回调时机
当订单 `mix_pay_status = MIX_PAY_SUCCESS`(自费 + 医保两端均结算成功)时,微信支付通过 POST 向 [医保下单接口](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) 中传入的 `callback_url` 发送通知。
| 事件类型 | 含义 |
| --- | --- |
| `MEDICAL_INSURANCE.SUCCESS` | 医保混合收款成功 |
> ‼️ 微信仅在订单达到 `MIX_PAY_SUCCESS` 时回调一次。若 5 秒内未收到 200/204 应答将按指数退避重试30 秒后**不再重试**。
> ‼️ 商户**必须**对 `MIX_PAY_CREATED` 状态的订单做主动查询兜底(`GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}`),不能仅依赖回调。
## 二、HTTP 头
| 参数 | 描述 |
| --- | --- |
| `Wechatpay-Serial` | 验签所用微信支付公钥 ID`PUB_KEY_ID_*`)或微信支付平台证书序列号 |
| `Wechatpay-Signature` | 签名值 |
| `Wechatpay-Timestamp` | 时间戳(秒) |
| `Wechatpay-Nonce` | 随机串 |
> ‼️ `Wechatpay-Serial` 以 `PUB_KEY_ID_` 开头则用**微信支付公钥**验签,否则用**微信支付平台证书**验签。
> ‼️ 微信会随机下发 `Wechatpay-Signature` 以 `WECHATPAY/SIGNTEST/` 开头的[签名探测流量](https://pay.weixin.qq.com/doc/v3/merchant/4013053249.md),验签必失败,商户必须按规范应答 4XX/5XX。
## 三、报文结构
```json
{
"id": "EV-2018022511223320873",
"create_time": "2020-03-26T10:43:39+08:00",
"event_type": "MEDICAL_INSURANCE.SUCCESS",
"resource_type": "encrypt-resource",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "...",
"nonce": "...",
"associated_data": "..."
}
}
```
## 四、resource.ciphertext 解密后的业务字段
> 算法:`AEAD_AES_256_GCM`密钥APIv3 密钥32 字节)
> `nonce` / `associated_data` 用密文中对应字段,**不是**自己生成
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `mix_trade_no` | string(32) | 医保自费混合订单号 |
| `mix_pay_status` | string | 整体状态(回调中固定 `MIX_PAY_SUCCESS` |
| `self_pay_status` | string | `SELF_PAY_SUCCESS` / `NO_SELF_PAY` |
| `med_ins_pay_status` | string | `MED_INS_PAY_SUCCESS` / `NO_MED_INS_PAY` |
| `paid_time` | string(64) | 支付完成时间RFC3339 |
| `passthrough_response_content` | string(2048) | 医保局透传给医疗机构的内容 |
| `mix_pay_type` | string | `CASH_ONLY` / `INSURANCE_ONLY` / `CASH_AND_INSURANCE` |
| `order_type` | string | `REG_PAY` / `DIAG_PAY` / ... 详见 SKILL 总览 |
| `appid` | string(32) | 商户公众号 / 小程序 AppID |
| `openid` | string(128) | 用户在该 AppID 下的 openid |
| `pay_for_relatives` | bool | 是否代亲属支付 |
| `out_trade_no` | string(64) | 商户订单号 |
| `serial_no` | string(20) | 医疗机构订单号 |
| `pay_order_id` | string(64) | 医保局支付单 ID |
| `pay_auth_no` | string(40) | 医保局支付授权码 |
| `geo_location` | string(40) | 用户经纬度 `经度,纬度` |
| `city_id` | string(8) | 城市 ID |
| `med_inst_name` | string(128) | 医疗机构名称 |
| `med_inst_no` | string(32) | 医疗机构编码 |
| `med_ins_order_create_time` | string(64) | 医保下单时间 |
| `total_fee` | uint64 | 订单总金额(分) |
| `med_ins_gov_fee` | uint64 | 医保统筹支付金额(分) |
| `med_ins_self_fee` | uint64 | 医保个账支付金额(分) |
| `med_ins_other_fee` | uint64 | 医保其他津贴金额(分) |
| `med_ins_cash_fee` | uint64 | 医保结算后自费金额(分) |
| `wechat_pay_cash_fee` | uint64 | 微信支付实收金额(分) |
| `cash_add_detail[].cash_add_fee` | uint64 | 现金补充金额 |
| `cash_add_detail[].cash_add_type` | string | `DEFAULT_ADD_TYPE` / `FREIGHT` / `OTHER_MEDICAL_EXPENSES` |
| `cash_reduce_detail[].cash_reduce_fee` | uint64 | 现金减免金额 |
| `cash_reduce_detail[].cash_reduce_type` | string | `DEFAULT_REDUCE_TYPE` / `HOSPITAL_REDUCE` / `PHARMACY_DISCOUNT` / `DISCOUNT` / `PRE_PAYMENT` / `DEPOSIT_DEDUCTION` |
| `callback_url` | string(256) | 回调通知 URL |
| `prepay_id` | string(64) | 自费预下单 ID |
| `attach` | string(128) | 商户自定义透传 |
| `channel_no` | string(32) | 渠道号 |
| `med_ins_test_env` | bool | 是否医保局测试环境 |
## 五、应答规范
| 场景 | HTTP 状态码 | 应答体 |
| --- | --- | --- |
| 验签通过 + 业务处理成功 | 200 / 204 | 无包体 |
| 验签失败 / 业务处理失败 | 4XX / 5XX | `{"code": "FAIL", "message": "失败"}` |
> ‼️ 必须先验签再处理业务,验签失败时**禁止**返回 200否则微信将认为商户已收不再重试造成丢单。
> ‼️ 业务处理建议异步化,回调线程内只做:验签 → 解密 → 写入「待处理订单表」→ 立即应答 200。后续状态更新走异步消费避免业务慢导致回调超时被重试。
## 六、幂等性
同一订单可能收到多次回调(重试或网络抖动),商户必须按 `mix_trade_no` 做幂等:
```
SELECT mix_pay_status FROM med_ins_orders WHERE mix_trade_no = ?;
IF mix_pay_status = 'MIX_PAY_SUCCESS' THEN
-- 已处理,直接返回 200
ELSE
-- 用 SELECT FOR UPDATE / 行锁更新状态,再返回 200
END IF;
```
## 七、与查单的关系
| 场景 | 推荐做法 |
| --- | --- |
| 30 秒内收到回调 | 验签 + 解密 + 幂等更新订单 |
| 30 秒后未收到回调 | 主动调用查单接口确认状态 |
| 收到回调但解密 / 验签失败 | 应答 4XX/5XX触发微信重试同时 LOG 报警,调用查单接口兜底 |
| 用户客诉「钱已扣未发货」 | 优先以查单结果为准,不依赖回调记录 |

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 目录结构一致。
> 本索引覆盖商户视角下的全部医保支付服务端接口、客户端拉起脚本、回调通知报文说明,以及共用 SDK 工具类。
## 命名约定
- 分组目录:`{编号}-{业务名}/`,编号从 `1` 起(`1-下单/``2-订单查询/``3-医保退款通知/``4-小程序调起/``5-JSAPI调起/``6-回调通知/``7-SDK工具类/`
- Java 代码文件:大驼峰 `.java`(如 `CreateMedInsOrder.java`
- Go 代码文件:蛇形 `.go`(如 `create_med_ins_order.go`
- 回调通知 `.md`:内容语言无关,**Java/ 与 Go/ 各放一份**——Java/ 用中文命名(如 `医保混合收款成功通知说明.md`Go/ 用蛇形拼音(如 `med_ins_success_callback.md`),内容完全一致
- 客户端拉起 `.md`:语言无关的集成说明,统一放在 Java/ 下即可(无需 Go 副本)
---
## 业务接口
> 每个业务分组一张表,列含义如下:
> - **服务端 API**(如下单 / 查单 / 退款通知):`Java` / `Go` 列分别为对应语言的可执行代码文件路径
> - **回调通知**`Java` / `Go` 列分别指向**同一份**报文说明 `.md`(语言无关,按目录约定各放一份方便项目查找)
> - **客户端拉起**(小程序 / JSAPI跨语言通用的 `.md` 集成说明,仅列 `Java` 一列即可
### 1-下单(服务端 API
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 医保自费混合收款下单 | POST /v3/med-ins/orders | `Java/1-下单/CreateMedInsOrder.java` | `Go/1-下单/create_med_ins_order.go` |
> 三种 `mix_pay_type``CASH_ONLY` / `INSURANCE_ONLY` / `CASH_AND_INSURANCE`)的字段约束、金额公式、敏感字段加密详见 [📄 开发参数与业务规则.md](../接入指南/开发参数与业务规则.md)。
### 2-订单查询(服务端 API
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 用混合订单号查单 | GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no} | `Java/2-订单查询/QueryByMixTradeNo.java` | `Go/2-订单查询/query_by_mix_trade_no.go` |
| 用商户订单号查单 | GET /v3/med-ins/orders/out-trade-no/{out_trade_no} | `Java/2-订单查询/QueryByOutTradeNo.java` | `Go/2-订单查询/query_by_out_trade_no.go` |
> 订单状态字段(`mix_pay_status` / `self_pay_status` / `med_ins_pay_status`)枚举详见 [📄 开发参数与业务规则.md](../接入指南/开发参数与业务规则.md)。
### 3-医保退款通知(服务端 API商户主动调用微信
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 医保退款通知(告知微信医保侧已发生退款) | POST /v3/med-ins/refunds/notify | `Java/3-医保退款通知/NotifyMedInsRefund.java` | `Go/3-医保退款通知/notify_med_ins_refund.go` |
> ‼️ 「医保退款通知」**不是**微信发给商户的回调,而是**商户调用微信**通知医保退款已经在医保局完成。命名极易混淆。
> ‼️ 自费部分退款仍走标准 `POST /v3/refund/domestic/refunds`,二者通过相同 `out_refund_no` 关联。
### 4-小程序调起(客户端集成)
| 业务 | 接口 | Java |
|---|---|---|
| 小程序调起医保支付 | `wx.requestMedicalInsurancePay` | `Java/4-小程序调起/小程序调起医保支付说明.md` |
### 5-JSAPI调起客户端集成
| 业务 | 接口 | Java |
|---|---|---|
| 公众号 / H5 内 JSAPI 调起医保支付 | `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)` | `Java/5-JSAPI调起/JSAPI调起医保支付说明.md` |
> ‼️ 调起所用 `appId` 必须与下单时 `appid` 一致,且 `openid` 需在该 `appid` 下获取。错配会触发 `PARAM_ERROR`。
### 6-回调通知(异步事件,无可执行代码)
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 医保混合收款成功通知(`MEDICAL_INSURANCE.SUCCESS` | 回调报文格式与处理要求 | `Java/6-回调通知/医保混合收款成功通知说明.md` | `Go/6-回调通知/med_ins_success_callback.md` |
> 通用解密 / 验签 / 回包流程参考 [📄 回调处理.md](../接入指南/回调处理.md)。
> 微信仅会在订单达到 `MIX_PAY_SUCCESS` 时回调一次5 秒未应答会重试30 秒后不再重试。**必须**对 `MIX_PAY_CREATED` 状态的订单做主动查询兜底。
---
## 7-SDK 工具类(所有接口的公共依赖)
> 所有示例代码都依赖此工具类提供签名、验签、加解密、HTTP 请求等基础能力。**提醒用户需一并集成**。
>
> ‼️ 详见 [📄 签名与验签规则.md](../接入指南/签名与验签规则.md)。
| 语言 | 文件 | 说明 |
|---|---|---|
| Java | `Java/7-SDK工具类/WXPayUtility.java` | 签名、验签、加解密 |
| Java | `Java/7-SDK工具类/WXPayClient.java` | HTTP 客户端,封装请求签名 → 发送 → 验签 |
| Go | `Go/7-SDK工具类/wxpay_utility.go` | 签名、验签、加解密 |
| Go | `Go/7-SDK工具类/wxpay_client.go` | HTTP 客户端,封装请求签名 → 发送 → 验签 |

View File

@@ -0,0 +1,559 @@
# 商户模式排障手册
> 本文档是本角色 + 本产品排障的**唯一入口**。另一接入模式见对应角色目录下同名文件。
>
> ‼️ **使用规则**:用户报告任何问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 加密失败 / 对账差异等),**先加载本文档**按下方流程匹配,不要先翻其他文档或猜原因。
>
> ‼️ **语气**:像有经验的技术支持,自然对话解释原因和方案,不要冷冰冰罗列文档目录。
## 排障流程
1. **能直接复制【官方报错文案】?** → 走「**报错文案精准命中表**」:按四阶段(前端调起 / 下单 / 支付结算 / 查单)+ HTTP 状态码 + 错误码类型直接命中官方原文,给出精准定位与公式。**优先尝试**——用户从官方文档复制过来的文案大概率在这一表里。
2. **能给 Request-Id** → 走「一、错误码 TOP 20」取 Request-Id 末尾 `-` 后的数字(如 `...CF05-268578704``268578704`)在速查表匹配,命中后用「错误码详细排查」对应段落回复。
3. **不能给 / 未命中 TOP 20** → 走「二、常见问题」按现象HTTP / 回调 / 签名 / 加密 / 退款 / 业务规则 / 通用配置 / **前端 fail / 免密授权 / 亲情授权**)定位子节。
4. **三条都没命中?** → 用末尾「排障信息收集清单」回收信息后再判断。
> 💡 **优先取 `med_ins_fail_reason`**:医保支付失败(`med_ins_pay_status = MED_INS_PAY_FAIL`)时,**查单接口**会在 `med_ins_fail_reason` 字段返回**医保局侧的具体失败原因**。建议商户把该字段落库并透传给医院侧,能直接定位结算阶段的真实根因,比联系微信侧拿日志更快。
>
> 📞 **拿不准要找谁?** 微信搜索「微信支付医疗健康助手」(微信号 `Wechatpay_BDzhushou`),申请通过后会被拉进医保支付技术支持微信群,群里有官方技术助手 + 对接运营。
---
## 报错文案精准命中表
> 来源官方《报错排查指引_医保支付》[商户版 4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md) / [服务商版 4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) / [从业机构版 4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md))。
>
> ‼️ **使用规则**:用户能直接给出**官方报错文案**(如「医保结算后需自费金额计算失败」「未找到对应授权信息」)时**优先**用本表精准命中,命中后给出一句话定位 + 公式 / 锚点跳转;若用户只能给 Request-Id / 错误码数字,转走「§一 TOP 20」。
>
> 引用注解「§1.2 268xxx」表示见下方「一、错误码 TOP 20 → 1.2 错误码详细排查」对应段落「§2.x」表示见「二、常见问题」对应子节。
### 0.1【阶段1】前端拉起收银台小程序 / 公众号 H5
| # | 错误信息(官方原文) | 平台 | 一句话定位 | 详细参考 |
|---|---|:---:|---|---|
| 1 | `requestMedicalInsurancePay:fail:access denied` | 小程序 | 该 `appid` 未开通 JSAPI 权限位 1295同时要开通客户端控制位 494 | §2.8 A 客户端调起类 |
| 2 | `system:access_denied` | 公众号 H5 | `wx.config` 鉴权未通过 / `wx.ready` 未触发 | §2.8 A 客户端调起类 |
| 3 | `no permission to execute` | 公众号 H5 | `jsApiList` 未包含 `requestMedicalInsurancePay` 或鉴权未通过 | §2.8 A 客户端调起类 |
| 4 | `缺少参数 total fee` | / | `package` 字段格式错,必须 `package: "prepay_id=" + prepay_id`;纯医保场景禁传自费参数 | §2.8 A |
| 5 | `system:function_not_exist` | / | 微信客户端版本太低,未支持 `requestMedicalInsurancePay`;前端做低版本兼容并引导用户升级 | §2.8 A |
| 6 | 收银台无反应(无报错、无回调) | / | 基础库不支持 / 用户微信版本过低;用 `wx.checkJsApi``typeof wx.requestMedicalInsurancePay === 'function'` 兜底 | §2.8 A |
| 7 | `config:invalid signature` | iOS 居多 | `wx.config` 签名 URL 与页面实际发起 HTTP 请求的 URL 不一致 | §2.3 签名与证书 |
### 0.2【阶段2】下单 — 400 PARAM_ERROR
| # | 文案(官方原文) | 一句话定位 / 公式 | 详细参考 |
|---|---|---|---|
| 1 | 参数错误 | 笼统提示,按错误信息进一步定位 | §1.2 268435461 |
| 2 | **医保结算后需自费金额计算失败** | **当 `med_ins_cash_fee > 0` 时**,必须满足:`med_ins_cash_fee + Σcash_add_detail.fee = wechat_pay_cash_fee + Σcash_reduce_detail.fee`PARAM_ERROR 阶段先校验,公式不成立直接拒单) | §1.2 268529476 / §2.6 Q3 |
| 3 | 无效的证件类型 | 目前只支持身份证(`card_type = ID_CARD` | §2.6 / §2.4 Q5 |
| 4 | 代亲属支付缺少亲属信息 | `pay_for_relatives = true` 时必须传 `relative.name` / `relative.id_digest` / `relative.card_type`,前两者按 RSA-OAEP 加密 | §1.2 268545964 / §2.6 Q6 |
| 5 | 医保下单时间解析失败 | `med_ins_order_create_time` 必须 RFC3339 格式 `yyyy-MM-DDTHH:mm:ss+08:00`**时区不能省略**(不能用 `Z` | §2.6 Q7 |
| 6 | AppID 与 OpenID 不正确 | `openid` 必须用下单 `appid` 通过 `wx.login` + `code2Session` 获取;调起支付的 `appid` 也必须与下单 `appid` 一致 | §1.2 268510603 / §2.6 Q13 |
| 7 | OpenID 不正确 | `openid``appid` 不在同一主体下,或调起 / 下单 `appid` 错位 | §1.2 268510603 |
| 8 | AppID 不正确 | 检查三点:① `appid` 已完成医保支付接入;② 大小写正确(**全小写**);③ 与商户号已关联 | §2.8 B 下单参数类 |
| 9 | 商户号不正确 | `mch_id` 填写正确;商户号必须已开通医保支付权限 | §2.8 B |
| 10 | 金额计算出现溢出 | 金额必须**正整数(分)**,不能传"元";总和不能超过 int 上限 | §1.2 268529481 / §2.6 |
### 0.3【阶段2】下单 — 400 INVALID_REQUEST
| # | 文案(官方原文) | 一句话定位 | 详细参考 |
|---|---|---|---|
| 1 | 医保局结算校验失败 | 笼统报错,真实原因在医保局返回的 FSI 子文案中——取 Request-Id 联系微信侧拿日志,或查单后看 `med_ins_fail_reason`(见 §0.6 | §1.2 268510610 |
| 2 | 入参个人身份ID摘要和医保电子凭证绑卡身份ID摘要不匹配 | **80% 是漏了"先 MD5小写十六进制→ 再 RSA-OAEP"中的某一步**;加密对了仍报错才是用户实名信息真不一致 | §1.2 268545961 / §2.4 Q3 |
| 3 | 缺少必要字段,请根据混合支付类型补充必要的字段 | 按 `mix_pay_type` 装配字段CASH_ONLY 必传 `wechat_pay_cash_fee` + `prepay_id`INSURANCE_ONLY 必传医保 8 件套CASH_AND_INSURANCE 全要 | §1.2 268529474 / 268529475 / §2.6 Q4 |
| 4 | 亲属关系不存在 | 用户在国家医保 APP 或地方医保小程序绑定亲情账户后再发起;详细授权页提示见 §2.11 | §1.2 268545964 / §2.11 |
| 5 | 微信号未绑定医保电子凭证 | 引导用户先在微信「我 → 服务 → 医疗健康 → 医保电子凭证」激活并绑卡 | §1.2 268545963 |
| 6 | 入参用户姓名和医保电子凭证绑卡姓名不匹配 | `payer.name` 必须按 RSA-OAEP 加密;加密对了仍报错才是用户姓名真不一致 | §1.2 268545962 / §2.4 |
| 7 | 未找到对应授权信息,无法查询订单信息 | `pay_auth_no` 已过期 / 已使用 / 与 `payer.openid` 不匹配;或 `passthrough_request_content` 漏传 `payAuthNo` / `payOrdId` / `setlLatlnt` 三件套 | §1.2 268545967 |
| 8 | 传入用户信息与原订单不匹配 | 授权信息已过期或与原订单不一致,重新走免密授权 | §2.10 免密授权 |
| 9 | 使用渠道与授权渠道不一致 | 授权与下单走了不同环境(`med_ins_test_env` 不一致);正式 / 测试环境必须严格对应 | §2.6 Q14 / §2.7 Q2 |
| 10 | HTTP 请求不符合微信支付 APIv3 接口规则 | 请参阅 [APIv3 接口规则](https://pay.weixin.qq.com/doc/v3/merchant/4012081709.md) | §2.3 签名与证书 |
### 0.4【阶段2】下单 — 403 RULE_LIMIT
| # | 文案(官方原文) | 公式 / 一句话定位 | 详细参考 |
|---|---|---|---|
| 1 | 自费金额校验不通过 | **仅 `med_ins_cash_fee > 0` 时触发**`med_ins_cash_fee = wechat_pay_cash_fee - Σcash_add_detail.fee + Σcash_reduce_detail.fee`(与 §0.2 第 2 条等价,但触发阶段不同) | §1.2 268529476 / §2.6 Q3 |
| 2 | 订单总金额校验不通过 | `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee`(少一项都拒单) | §1.2 268529477 / §2.6 Q2 |
| 3 | 自费下单金额与医保下单金额校验不通过 | 混合单要求 `wechat_pay_cash_fee > 0` **且** 医保 4 件套之和 > 0两边都不能为 0 | §1.2 268529481 |
| 4 | 纯自费支付单字段规则不满足 | `mix_pay_type = CASH_ONLY` 时:必传 `wechat_pay_cash_fee` / `prepay_id`**禁传**医保字段(`pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*` | §1.2 268529474 / §2.6 Q4 |
| 5 | 纯医保支付单字段规则不满足 | `mix_pay_type = INSURANCE_ONLY` 时:必传医保 8 件套,**禁传** `wechat_pay_cash_fee` / `prepay_id` | §1.2 268529475 / §2.6 Q4 |
| 6 | 混合支付单字段规则不满足 | `mix_pay_type = CASH_AND_INSURANCE` 时:自费 + 医保字段全部非空 | §1.2 268529475 / §2.6 Q4 |
| 7 | 实际需要用户微信支付的金额和医保下单的金额都为 0 | 不支持双 0 元下单场景 | §2.6 |
| 8 | 纯医保单金额校验不通过 | `INSURANCE_ONLY` 时:医保 4 件套之和 > 0 且 `wechat_pay_cash_fee = 0` | §1.2 268529476 |
| 9 | 纯自费单金额校验不通过 | `CASH_ONLY` 时:医保 4 件套之和 = 0 且 `wechat_pay_cash_fee > 0` | §1.2 268529476 |
| 10 | 请求次数超过限制 | 触发频控,稍后重试 | §2.7 |
### 0.5【阶段2】下单 — 其他
| # | HTTP / 错误码 | 文案 | 一句话定位 | 详细参考 |
|---|---|---|---|---|
| 1 | 400 / ALREADY_EXISTS | 订单号 `out_trade_no` 重复 | 先查单:已存在且金额一致 → 复用;已 FAIL → 用**新的** `out_trade_no` 并先取消前一笔自费 `prepay_id` | §1.2 268512536 / §2.6 Q5 |
| 2 | 403 / REQUEST_BLOCKED | 商户尚未完成医保支付接入流程 | 联系对接运营完成商户接入配置(权限申请) | §2.8 B |
### 0.6【阶段3】支付/结算 — `med_ins_fail_reason` 关键词命中
> 来源:查单接口 `med_ins_fail_reason` 字段(仅在 `med_ins_pay_status = MED_INS_PAY_FAIL` 时返回);以下文案来自医保局侧,不同省市中台可能存在文案差异,按**部分匹配**命中即可。
| # | 关键词(官方原文,部分匹配) | 含义 | 处理 |
|---|---|---|---|
| 1 | 预结算现金支付金额与结算金额不一致 | 预结算与结算阶段现金支付金额不符 | 核对预结算与结算两次的现金支付金额一致 |
| 2 | Token 核验失败:用户身份信息校验不通过 | 医保电子凭证 ecToken 过期 / 身份信息不匹配 | 引导用户重新激活医保电子凭证 + 核对姓名 / 身份证号是否与医保档案一致(曾改名 / 身份证升位需更新) |
| 3 | 医保结算成功,通知医院失败,自动冲正成功 | 医保局结算成功但通知医院 HIS 失败,为保持一致性自动撤销结算 | 联系医保局确认配置 / 检查 HIS 接口可用性 |
| 4 | FSI-在院状态不允许进行结算 | 患者在医保系统中处于「在院」(住院中)状态 | 出院后再结算,或按住院结算流程处理 |
| 5 | 参保人不存在该医院的门诊选点信息 | 异地就医未选点 / 备案 | 引导用户完成门诊选点 / 异地就医备案 |
| 6 | 该人员正在进行门诊结算相关业务 | 医保局并发限制(同一参保人重复请求) | 稍后重试 |
| 7 | 请求处理中,请勿频繁操作 | 医保局处理中 | 等待处理完成,避免重复发起 |
| 8 | service not registed | 医保局服务未注册 | 联系医保局确认服务状态 |
| 9 | 系统错误,请联系技术支持人员 | 医保局系统异常 | 等待恢复或联系医保局;可重试 |
| 10 | 订单未进行预结算,自费支付通知无法继续 | 自费支付通知早于医保预结算 | 先完成医保预结算,再发起自费支付通知 |
### 0.7【阶段3】前端 fail 回调 msg
> 含 `缺少 mix_trade_no` / `缺少自费参数` / `混合支付超时` / `混合支付订单状态为支付失败` / `混合支付订单状态异常` / `自费支付失败` / `用户直接退出医保混合支付` / `混合支付订单已关单` 共 8 条文案的精准定位与处理建议,详见 →
→ 详见 §2.9 前端 fail 回调 msg 解读
### 0.8【阶段4】查单接口错误码
| # | 状态码 | 错误码 | 描述 | 处理 / 详细参考 |
|---|:---:|---|---|---|
| 1 | 400 | PARAM_ERROR | 参数错误 | 按错误提示核对入参 |
| 2 | 400 | PARAM_ERROR | 金额计算出现溢出 | 检查填写的金额是否在 int 范围内 / 单位是否为分 |
| 3 | 400 | INVALID_REQUEST | HTTP 请求不符合微信支付 APIv3 接口规则 | 参阅 [APIv3 接口规则](https://pay.weixin.qq.com/doc/v3/merchant/4012081709.md) |
| 4 | 401 | SIGN_ERROR | 验证不通过 | 参阅 [签名常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4012072670.md) / §2.3 签名与证书 |
| 5 | 403 | RULE_LIMIT | 请求次数超过限制 | 稍后重试 |
| 6 | 404 | NOT_FOUND | 未找到订单 | 确认订单号是否正确 / 是否在正确的 `mchid` 下查询 → §1.2 268529515 |
| 7 | 500 | SYSTEM_ERROR | 系统异常 | 稍后重试 |
---
## 一、错误码 TOP 20Request-Id 场景)
> 来源:本产品真实工单 / 客服系统统计的高频错误码。
### 1.1 TOP 20 速查表
| 错误码 | 错误信息 | 分类 |
|:------:|---------|:----:|
| 268529515 | 未找到订单,请确认订单号是否正确 | 订单查询 |
| 268512536 | 订单已存在 | 订单重入 |
| 268510603 | 请确认 openid 是否正确 | 用户参数 |
| 268510604 | 请确认 sub_appid 与 sub_openid 是否正确 | 用户参数(服务商场景) |
| 268545961 | 入参个人身份ID摘要和医保电子凭证绑卡身份ID摘要不匹配 | 用户校验 |
| 268545962 | 入参用户姓名和医保电子凭证绑卡姓名不匹配 | 用户校验 |
| 268545963 | 微信号未绑定医保电子凭证 | 用户校验 |
| 268545964 | 亲属关系不存在,请传入有效亲属关系后重试 | 用户校验 |
| 268547120 | PAY_AUTH_NO 校验失败,请确认无误后重试 | 医保授权 |
| 268545967 | 医保局业务错误:未找到对应授权信息,无法查询订单信息 | 医保授权 |
| 268510610 | 医保局结算校验失败,请确认医保相关的字段是否正确 | 医保校验 |
| 268529477 | 订单总金额校验不通过 | 金额校验 |
| 268529476 | 自费金额校验不通过 | 金额校验 |
| 268529481 | 混合单金额不正确 | 金额校验 |
| 268529474 | 纯自费单字段校验失败 | 字段联动 |
| 268529475 | 合支付单字段校验失败 | 字段联动 |
| 268554200 | 当前订单状态不允许冲正 | 订单状态 |
| 268554422 | 冲正失败,请检查参数后重试 | 冲正 |
| 268560650 | 已完成验密,不允许关单 | 订单状态 |
| 268435461 | 参数非法 | 参数校验 |
### 1.2 错误码详细排查
#### 268529515 — 未找到订单,请确认订单号是否正确
**常见原因**
-`out_trade_no` 在错误的 `mchid` 下查询(多商户号场景商户号弄混)
- 创单失败但业务侧把 `out_trade_no` / `mix_trade_no` 当作"已创建"入库后又用它查询
- 创单接口实际返回非 2xx但业务侧只看了"调用完成"未校验返回码
- 自费下单成功但医保下单失败,仅生成了自费 `prepay_id`,去查 `mix_trade_no` 必然找不到
**🔧 脚本确认**:建议先调"按 out_trade_no 查单"接口(无需 `mix_trade_no` 也能查),结合自费 `prepay_id` 一起核对,确认到底是哪一步落库失败。
**💡 推荐集成**:查单接口是医保支付排障的"瑞士军刀",强烈建议接入"按 out_trade_no 查单"+"按 mix_trade_no 查单"作为常驻能力,回调与异常恢复都要用到。
---
#### 268512536 — 订单已存在
**常见原因**
- 同一 `out_trade_no` 在创单超时后被重新发起,但前一次实际已成功落库
- 多实例并发抢同一笔单时未做幂等
- 用了与之前已成功 / 已关闭订单相同的 `out_trade_no`(医保单号一旦使用过即被占用)
**🔧 脚本确认**:调"按 out_trade_no 查单"看订单是否真实存在:① 已存在且金额一致 → 复用现有 `mix_trade_no`,跳过创单直接调起;② 已存在但 `MIX_PAY_FAIL` → 用**新的** `out_trade_no` 重发,必要时先取消前一笔自费 `prepay_id`
**💡 推荐集成**:业务侧 `out_trade_no` 必须做严格幂等,建议本地落库后再发起请求;超时未拿到响应时**先查询、再决定重试或换号**,禁止盲目换号。
---
#### 268510603 / 268510604 — openid / sub_appid + sub_openid 不正确
**常见原因**
- **268510603**:商户模式下,`openid` 不是从下单 `appid` 获取的(同一用户在不同公众号 / 小程序下 openid 不同)
- **268510604**:服务商模式下,`sub_openid` 不是从 `sub_appid` 获取的,或两者根本不属于同一主体(建议商户先核对自身是否需要服务商场景,若是商户直连则不应该出现 268510604
- 调起支付时使用的 `appid` 与下单 `appid` 不一致
**🔧 脚本确认**:让商户提供 ① 下单接口请求体中的 `appid` / `openid` ② 调起支付时小程序 / JSAPI 使用的 `appid`,三者必须严格一致;并确认 `openid` 是通过 `wx.login` + `code2Session` 在该 `appid` 下获取的。
**💡 推荐集成**:建议接入前后端联调时打印一次 `appid` / `openid` 的来源链路,避免开发阶段使用了错配置上线。
---
#### 268545961 / 268545962 / 268545963 / 268545964 — 用户实名 / 绑卡 / 亲属关系类
**常见原因**
- **268545961**身份ID摘要不匹配/ **268545962**(姓名不匹配):上送的 `payer.name` / `payer.id_digest` 与用户在医保电子凭证上**绑卡时的实名信息**不一致;**线上工单 80% 是开发漏了"先 MD5 再 RSA 加密"这一步** —— `id_digest` 必须先按规则 MD5小写十六进制后再用微信支付公钥 RSA-OAEP 加密,姓名也必须 RSA-OAEP 加密;如果加密本身没问题但仍报错,才是用户实名信息真不一致
- **268545963**(未绑卡):用户微信号未激活医保电子凭证或未绑卡
- **268545964**(亲属关系不存在):`pay_for_relatives = true` 时传入的 `relative.name` / `relative.id_digest` 在医保系统中查不到对应亲属关系记录
**🔧 脚本确认**
- 对 268545961 / 268545962**先验证加密链路**——拿一笔已知正确的姓名+身份证,用代码跑出 `id_digest` 密文,与文档示例值(`44030019000101123x` → MD5 `09eb26e839ff3a2e3980352ae45ef09e`的中间结果对照MD5 中间值能对上后,再排查实名信息
- 对 268545963引导用户先完成激活
- 对 268545964让用户在国家医保 APP 或地方医保小程序绑定亲情账户后再发起
**💡 推荐集成**:前端在调起前增加"医保电子凭证已激活 / 已绑卡"校验;后端把 `id_digest` 计算(大写 → 15 转 18 → MD5 → RSA-OAEP封装成单元测试用例避免上线后才发现漏 MD5。
---
#### 268547120 / 268545967 — PAY_AUTH_NO 校验失败 / 未找到对应授权信息
**常见原因**
- `pay_auth_no` 来自医保电子凭证的"医保支付授权"**有效期短**(通常分钟级),过期后再用就会失败
- 同一个 `pay_auth_no` 被重复使用(医保侧只认一次授权)
- 上送的 `pay_auth_no` 不属于当前 `payer.openid` 对应用户
- 268545967 多见于查单 / 后续处理时用到了已过期的授权
- **线上工单常见**`passthrough_request_content` 中漏传 `payAuthNo` / `payOrdId` / `setlLatlnt` 中的某一项(这些是医保局透传必填,与顶层字段同时存在),会触发"未找到对应授权信息"
**🔧 脚本确认**
1. 检查从医保电子凭证获取 `pay_auth_no` 到调用 `POST /v3/med-ins/orders` 的时间间隔;超过医保规定时长(一般 510 分钟)的,要求前端重新走授权
2. 检查 `passthrough_request_content` 是否完整带上 `payAuthNo` / `payOrdId` / `setlLatlnt` 三件套
**💡 推荐集成**:建议把"获取 `pay_auth_no` → 创单"做成同一前端会话内的连贯动作,禁止把 `pay_auth_no` 持久化到本地或跨会话使用。
---
#### 268510610 — 医保局结算校验失败
**常见原因**(笼统报错,真实原因在医保局返回的 FSI 错误信息中,需联系微信侧拿日志):
- 商户侧:`city_id` 填错(如无锡医院填了苏州 `320201`/ 金额单位错位 / `med_ins_order_create_time` 与医保局记录差异过大 / `med_inst_no` / `serial_no` 与医保局报备不一致 / `passthrough_request_content` 必填项缺失
- 医保局侧FSI 子原因,从线上工单沉淀):
- **预结算金额不匹配** —— 商户算出的金额与医保局预结算金额对不上(最高频)
- **自付费金额与支付金额不匹配** —— `wechat_pay_cash_fee` 与医保局返回的自付不一致
- **自费预下单ID和收款预下单ID不一致** —— `prepay_id` 在两次下单中错位
- **电子凭证 ecToken / ocToken 二次核验失败** —— 用户身份信息与医保系统不符
- **该笔记录已结算(单边账)** —— 短时间内重复结算,需先在两定平台冲销
- **结算费用明细总额与结算信息医疗费总额不一致** —— 明细行金额加和对不上总额
- **异地药店医疗目录编码未上传无码库** —— 必须上传追溯码
- **在院状态不允许结算** —— 患者还在院期间走结算
- **黑名单 / 监管接口短暂异常** —— 医保平台抖动,重试可恢复
**🔧 脚本确认**
1. 取 Request-Id 联系微信侧拿到医保局返回的具体 FSI 错误文案(接口本身只回笼统错)
2. 拿到 FSI 文案后对照上方清单分类处理;金额类问题先核对 `total_fee` / `wechat_pay_cash_fee` 公式
3. `city_id` 类问题让商户对照医保局发的城市编码表
**💡 推荐集成**
-`(med_inst_no, city_id)` 做成本地白名单常量,避免硬编码 / 配错
- 与地方医保局接口人确认 `passthrough_request_content` 的必填项并锁住版本
---
#### 268529477 / 268529476 / 268529481 — 金额校验不通过
**常见原因**
- **268529477**(订单总金额):未满足 `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee`
- **268529476**(自费金额):未满足 `wechat_pay_cash_fee = med_ins_cash_fee + Σcash_add_detail.fee - Σcash_reduce_detail.fee`
- **268529481**(混合单金额):上述两个公式之间存在交叉不一致,或金额单位错误(误传"元"而非"分"
**🔧 脚本确认**:取完整请求 body按上方两个公式逐项相加比对。常见踩坑`cash_reduce_detail` 是数组,需要 Σ 求和;② 金额必须是**正整数(分)**;③ 漏项(特别是 `cash_reduce_detail` 为空时也要算 0 而非省略)。
**💡 推荐集成**:建议在业务侧封装一个"金额预校验"函数,下单前本地先跑一遍两条公式,避免空跑医保局接口。
---
#### 268529474 / 268529475 — 字段联动校验失败
**常见原因**
- **268529474**(纯自费单 `mix_pay_type = CASH_ONLY`):误传了 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*` 等医保字段CASH_ONLY 只能传自费字段)
- **268529475**(混合 / 纯医保单 `INSURANCE_ONLY``CASH_AND_INSURANCE``wechat_pay_cash_fee` 为 0、`prepay_id` 为空,或 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个 `med_ins_*_fee` 任一为空
**🔧 脚本确认**:定位 `mix_pay_type` 取值,对照 `2.6 业务规则 Q&A` 中"`mix_pay_type` 三种类型有什么字段约束"逐项检查必传 / 禁传字段。
**💡 推荐集成**:建议把"按 `mix_pay_type` 装配请求体"封装成 3 个独立函数CASH_ONLY / INSURANCE_ONLY / CASH_AND_INSURANCE从源头规避字段串台。
---
#### 268554200 / 268554422 / 268560650 — 冲正 / 关单状态类
**常见原因**
- **268554200**(当前订单状态不允许冲正):订单已 `MIX_PAY_SUCCESS` 或已 `MIX_PAY_REVERSED`,不能再冲正
- **268554422**(冲正失败):冲正接口入参缺失(`mix_trade_no` / `out_trade_no`)或参数与原单不一致;也可能是医保侧拒绝(用户已离开支付页面但医保侧已扣减)
- **268560650**(已完成验密不允许关单):用户已输入医保密码完成支付确认,此时不能再调关单接口;只能等支付完成后做退款
**🔧 脚本确认**:统一先调查单接口确认当前 `mix_pay_status`
- `MIX_PAY_CREATED` → 可关单 / 可冲正
- `MIX_PAY_USER_PAYING` → 用户正在验密,**不能**关单268560650
- `MIX_PAY_SUCCESS` → 走退款而非冲正
- `MIX_PAY_FAIL` / `MIX_PAY_REVERSED` → 终态,无需再操作
**💡 推荐集成**:业务侧应在冲正 / 关单前先做查单,避免错误状态下盲调;超时订单建议设置 30 分钟自动取消并配合查单兜底。
---
#### 268435461 — 参数非法
**常见原因**
- 必填字段缺失(如 `mix_pay_type` / `appid` / `mchid` / `med_inst_no`
- 字段类型错误(如金额传成字符串、布尔传成字符串)
- 字符串字段超长 / 含非法字符
- 时间格式不符 RFC 3339缺时区、用了 `Z` 而非 `+08:00`
- JSON 层级嵌套错误(`payer` / `relative` 对象结构错误)
**🔧 脚本确认**:拿到完整请求 body 与 [医保混合下单 API 文档](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) 字段表逐项对照。建议优先用 [JSON Schema 校验](https://pay.weixin.qq.com/doc/v3/merchant/4012791841.md) 工具在本地预校验。
---
## 二、常见问题(无 Request-Id 场景)
> 来源:本产品官方「常见问题」文档 + 通用接入经验沉淀。
### 2.1 HTTP 错误401 / 400 / 403
| 状态码 | 含义 | 常见原因 | 排查要点 |
|:----:|------|---------|---------|
| 401 | 签名验证失败 | 私钥与证书不匹配serial_no 填错;签名串拼接有误(换行符 / URL / body 为空时缺末尾换行);时间戳偏差过大 | 检查 Authorization 头格式;确认私钥正确加载;建议用官方 SDK |
| 400 | 请求参数错误 | 必填参数缺失;金额单位是分不是元;时间格式不符 RFC 3339JSON 层级错误mix_pay_type 与字段联动不合规 | 对照 [医保混合下单 API 文档](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) 逐项检查;金额单位是**分**;时间格式 `yyyy-MM-ddTHH:mm:ss+08:00` |
| 403 | 权限不足 | 未开通医保支付能力商户未在对应城市完成医保局接入mchid 状态异常 | 商户平台 → 产品中心 → 医保支付,确认开通状态;联系微信医保对接接口人确认城市报备 |
### 2.2 回调问题
**收不到回调排查清单**(按优先级):① 地址不可达URL 错 / 域名解析失败 / localhost / 服务未启动)→ ② URL 前后有空格致 DNS 失败 → ③ 防火墙拦截(医院内网最常见,未对回调 IP 段开白名单,见下方 IP→ ④ 登录态拦截callback_url 须从鉴权中间件中排除)→ ⑤ 响应非 200 / 204如 FAIL / 404重试后放弃→ ⑥ 处理超时(须 5 秒内应答)→ ⑦ 域名未 ICP 备案 → ⑧ callback_url 携带了 query 参数(不允许)。
**回调行为 Q&A**
| # | 问题 | 答案 |
|---|------|------|
| 1 | 怎么确认微信发了回调? | 微信不提供回调日志查询,检查自身服务器访问日志 + 调用查单接口(按 `mix_trade_no``out_trade_no`)确认 |
| 2 | 回调会重复收到吗? | 会未正确响应5 秒内 200/204时微信会重试业务必须做幂等 |
| 3 | 回调延迟正常吗? | 数秒到数十秒均属正常。建议回调 + 主动查单(针对 `MIX_PAY_CREATED` 状态)双保险 |
| 4 | 能直接将回调当最终结果吗? | 不能,回调不保证送达,需结合查单接口确认 |
| 5 | 商户平台能查回调状态吗? | 不支持,需调用查单接口 |
| 6 | 回调怎么测试? | 无独立测试接口,需在生产环境真实业务(或 `med_ins_test_env=true` 联调期)验证 |
**回调解密与验签**
| # | 报错 | 原因 | 解法 |
|---|------|------|------|
| 1 | `cipher: message authentication failed` / `AEADBadTagException` | APIv3 密钥错误(最常见:密钥重置后代码未同步)或密文被截断 | 检查代码中的 APIv3 密钥与商户平台一致 |
| 2 | "证书序列号不一致" | 用商户证书做了验签(应用平台证书或微信支付公钥)或平台证书过期 | 推荐改用微信支付公钥模式(`Wechatpay-Serial``PUB_KEY_ID_` 开头) |
| 3 | `Last unit does not have enough valid bits` | 签名探测流量 | 检查 `Wechatpay-Signature` 是否以 `WECHATPAY/SIGNTEST/` 开头,是则返回非 2xx |
| 4 | 签名参数顺序错误 | 参数个数 / 顺序 / 大小写不对或末尾缺 `\n` | 严格按文档顺序拼接:`时间戳\n随机串\nbody\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`
> ‼️ 医院网络场景常见:内网防火墙仅放行 `api.mch.weixin.qq.com` 出向连接,但未给回调入向开白名单,导致下单成功却收不到回调。建议以**域名白名单**配置防火墙,避免 IP 网段变动失联。
### 2.3 签名与证书
| # | 问题 | 答案 |
|---|------|------|
| 1 | 证书序列号怎么获取? | 商户平台 → 账户中心 → API 安全 → 商户 API 证书 → 管理证书 |
| 2 | 一个商户号能设多个 API 证书吗? | 可以 |
| 3 | 平台证书过期怎么换? | 推荐切换到[微信支付公钥模式](https://pay.weixin.qq.com/doc/v3/merchant/4013038816.md),无需关心证书过期 |
| 4 | 换 serial_no 后报签名错误? | 证书编号与私钥一一对应,更新 serial_no 时必须同步换私钥文件 |
| 5 | **医保支付小程序 / JSAPI 调起的 paySign 用什么签?** | **必须**用商户 API 证书私钥 + RSA-SHA256`signType` 字段固定为 `RSA`。**与微信支付分不同**——支付分调起用 APIv2 密钥 + HMAC**医保支付不用 APIv2**。 |
| 6 | V2 签名能用在医保混合下单接口吗? | 不能,`POST /v3/med-ins/orders` 是纯 V3 接口。**自费部分**的 JSAPI 下单可以用 V2 统一下单接口(官方 FAQ 明确支持),但建议优先 V3。 |
| 7 | API 只能通过域名访问吗? | 是,不支持 IP 直连。主域名 `api.mch.weixin.qq.com`,备域名 `api2.mch.weixin.qq.com` |
| 8 | 微信支付公钥 ID 怎么获取? | 商户平台 → 账户中心 → API 安全 → 微信支付公钥;获取后 Header `Wechatpay-Serial` 填入 `PUB_KEY_ID_xxx` |
### 2.4 敏感数据加密(医保支付特有)
> ‼️ `payer.name` / `payer.id_digest` / `relative.name` / `relative.id_digest` 必须加密,明文上送会被直接拒绝。
| # | 问题 | 答案 |
|---|------|------|
| 1 | 用什么密钥加密? | **微信支付公钥**(推荐)或微信支付**平台证书公钥****不是商户 API 证书****不是 APIv3 密钥** |
| 2 | 加密算法? | RSA / ECB / OAEPWithSHA-1AndMGF1Padding`RSA-OAEP`),输出再 Base64 |
| 3 | `id_digest` 怎么算? | 1) 身份证字母大写 → 2) 15 位转 18 位 → 3) MD5输出小写十六进制→ 4) 用上面的 RSA-OAEP 加密。例:`44030019000101123x` → MD5 后 `09eb26e839ff3a2e3980352ae45ef09e` → 再 RSA 加密 |
| 4 | `Wechatpay-Serial` 该填什么? | 用**公钥**加密就填 `PUB_KEY_ID_xxx`;用**平台证书**加密就填证书序列号。两者不能混用,否则微信侧解密失败 |
| 5 | 加密后报"证件类型错误" | `card_type` 不参与加密,明文传 `ID_CARD` 等枚举值即可 |
| 6 | 报"姓名/身份ID摘要和医保电子凭证绑卡xxx不匹配" | 加密本身没问题,是用户**实名信息**与医保电子凭证绑卡不一致。引导用户重新激活医保电子凭证或核对姓名/身份证 |
### 2.5 退款常见问题
> 医保退款是**商户主动通知微信**`POST /v3/med-ins/refunds/notify`**不是**收微信回调。微信侧用于做账务核销和用户提醒。
| # | 问题 | 答案 |
|---|------|------|
| 1 | 退款时机怎么判断? | 用户在医院/药店发起退款 → 医保局完成自费 + 医保两侧退款 → 商户收到医保局回执后,调用本接口把退款结果同步给微信 |
| 2 | 自费部分退款是这个接口吗? | 不是。微信侧自费退款走标准 [V3 退款](https://pay.weixin.qq.com/doc/v3/merchant/4012791862.md)`POST /v3/refund/domestic/refunds`),本接口只做"通知"作用 |
| 3 | 重复通知会出错吗? | 接口本身允许重试(建议幂等:以 `mix_trade_no` 为键),但同一医保订单多次通知微信侧会以最新一次为准 |
| 4 | 通知失败怎么办? | 网络超时按指数退避重试(建议初值 200 ms最多 3 次),不要立即换号;微信侧无回执时调"按 mix_trade_no 查单"看 `mix_pay_status` 是否已 `MIX_PAY_REFUND` |
| 5 | 用户问退款多久到账? | 自费部分按微信支付退款时效1-3 个工作日);医保部分以医保局到账时效为准 |
### 2.6 业务规则 Q&A医保支付
> 来源:[医保支付商户常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4017415831.md) + [医保混合下单错误码表](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md)
| # | 问题 | 答案 |
|---|------|------|
| 1 | 自费部分必须先在微信支付下单吗? | 是。`mix_pay_type ∈ {CASH_ONLY, CASH_AND_INSURANCE}` 时,必须先调 [JSAPI 自费下单](https://pay.weixin.qq.com/doc/v3/merchant/4012791844.md)V2 / V3 均可)拿到 `prepay_id`,再调本接口下医保混合单。两次调用的 `out_trade_no` 必须一致。 |
| 2 | `total_fee` 怎么算? | `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee`。少一项都会触发 `RULE_LIMIT 订单总金额校验不通过`。 |
| 3 | `wechat_pay_cash_fee` 怎么算? | `wechat_pay_cash_fee = med_ins_cash_fee + Σcash_add_detail.fee - Σcash_reduce_detail.fee`。少一项会触发 `RULE_LIMIT 自费金额校验不通过`。 |
| 4 | `mix_pay_type` 三种类型有什么字段约束? | **CASH_ONLY**:禁传 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*`;必传 `wechat_pay_cash_fee` / `prepay_id`。**INSURANCE_ONLY**:禁传 `wechat_pay_cash_fee` / `prepay_id`;必传 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个 `med_ins_*_fee`。**CASH_AND_INSURANCE**:上述全部必传。 |
| 5 | `out_trade_no` 重复怎么办? | 触发 `400 ALREADY_EXISTS`。先调"按 out_trade_no 查单"确认是否已有同号订单:① 同 → 复用;② 异常待重试 → 用新 `out_trade_no`,并复用原 `serial_no`,但需先取消前一笔自费单。 |
| 6 | `pay_for_relatives = true` 报"亲属信息为空" | 必须同时传 `relative.name` / `relative.id_digest` / `relative.card_type`,且全部按 RSA-OAEP 加密。 |
| 7 | `med_ins_order_create_time` 报"时间解析失败" | 必须是 RFC3339 `yyyy-MM-DDTHH:mm:ss+08:00` 格式,时区不能省略。 |
| 8 | `med_inst_no` 不能改吗? | `med_inst_no` 必须与医保局报备的医疗机构编码一致,与 `serial_no`即【6201】费用明细 `medOrgOrd`)一同被医保局校验,不一致直接拒单。 |
| 9 | "微信号未绑定医保电子凭证" 怎么解? | 用户必须先在微信"我 → 服务 → 医疗健康 → 医保电子凭证"激活并绑卡。前端建议在调起前先校验。 |
| 10 | 订单状态怎么判断? | 看返回的三态:`mix_pay_status`(总状态)+ `self_pay_status`(自费)+ `med_ins_pay_status`(医保)。仅当 `mix_pay_status = MIX_PAY_SUCCESS` 时才算成功;`MIX_PAY_CREATED` → 等待支付,必须做主动查单兜底。 |
| 11 | 订单超时多久会变 `MIX_PAY_FAIL` | 自费部分继承 V3 JSAPI 的 2 小时 prepay_id 有效期;医保部分以医保局规则为准。建议商户侧设置 30 分钟自动取消并主动查询确认。 |
| 12 | `passthrough_request_content` 能塞什么? | 沿用地方医保局定义,不能重复塞 `pay_auth_no` / `pay_ord_id` / `setl_latlnt`(这些已是入参顶级字段),最长 2048 字节。 |
| 13 | "请确认 AppID 与 OpenID 是否正确" 怎么解? | 创单 `appid` 必须与生成 `openid` 的 AppID 一致;调起时使用的 AppID 也必须与之一致(小程序 `appid` 或公众号 `appid`)。 |
| 14 | `med_ins_test_env` 上线后没关怎么办? | **资损风险**——正式环境用户支付却下到医保局测试环境。立即把代码 / 配置中所有 `med_ins_test_env` 默认值改回 `false`,并核对当日订单与医保局正式环境对账。 |
### 2.7 通用接入配置
| # | 问题 | 答案 |
|---|------|------|
| 1 | V2 和 V3 可以同时用吗? | 可以,密钥体系独立互不影响。本产品**自费下单**支持 V2/V3**医保混合下单/查单**仅 V3 |
| 2 | 微信支付有测试环境吗? | **没有**真正的微信支付测试环境,但医保侧可通过 `med_ins_test_env=true` 让医保单走医保局测试环境(自费侧仍走微信支付正式环境) |
| 3 | 同一错误为什么返回不同错误码? | 存在参数校验优先级,多参数错误时可能先返回 `PARAM_ERROR`,再返回 `RULE_LIMIT` |
| 4 | 接口地址能在浏览器直接打开吗? | **不能**,需程序调用并携带证书,建议用 Postman 调试 |
| 5 | 防火墙拦截(医院场景常见)怎么办? | 微信服务端 IP 动态更新,**强烈建议以域名白名单**配置防火墙;同时入向放通上述回调 IP 段 |
| 6 | 主域名 / 备域名怎么用? | 优先用主域名 `api.mch.weixin.qq.com`,主域名连续失败时切备域名 `api2.mch.weixin.qq.com`。建议 SDK 内置切换,不要手动 hardcode |
### 2.8 线上真实工单沉淀
> 来源:医保支付商户群线上真实工单 + 内部技术支持回复结论。按"用户报错现象 → 真实根因"组织。
**A. 客户端调起 / JS-SDK 类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 小程序 / 公众号拉起收银台**无反应**(无报错、无回调) | 用户微信版本过低,未支持 `requestMedicalInsurancePay` 接口 | 按文档「接口兼容部分」做低版本兼容:**公众号**用 `wx.checkJsApi({ jsApiList: ['requestMedicalInsurancePay'] })` 判断接口是否支持;**小程序**直接判断 `typeof wx.requestMedicalInsurancePay === 'function'`。不支持时给出友好提示,引导用户升级微信。 |
| 小程序拉起报错 `"errno":102, "errMsg":"requestMedicalInsurancePay:fail:access denied"` | 调起小程序的 `appid` 没有开通 **JSAPI 权限位 1295**(医保支付小程序拉起能力) | 联系微信侧通过**开平医保小助手**为该 `appid` 开通权限位 1295权限位以小程序主体为单位申请开通后需将小程序重新发版上线才能生效。**完整权限清单**:① `WXA_JSAPI_INDEX_requestMedicalInsurancePay = 1295`JSAPI 索引位);② `JSAPI_CONTROL_BYTE_REQUEST_MEDICAL_INSURANCE_PAY = 494`(客户端控制位),两个都要开通。 |
| 公众号拉起报错 `system:access_denied` / `requestMedicalInsurancePay:fail no permission to execute` | jsapi 鉴权错误,`wx.config` 未生效或 `jsApiList` 漏配 | 按以下顺序排查:① 当前是否手机微信内打开外部浏览器、企业微信、PC 微信均不行);② 是否调用了 `wx.config` 鉴权且 `wx.ready` 回调被触发(联调时打开 `debug:true` 看弹窗);③ 拉起代码是否放在 `wx.ready` 回调里;④ `jsApiList` 是否包含 `requestMedicalInsurancePay`。参考 [JS-SDK 鉴权](https://developers.weixin.qq.com/doc/service/guide/h5/)。 |
| 调起报"缺少参数 total_fee" | `package` 字段格式错(`package` 不能只填 `prepay_id`,必须 `prepay_id=wx2012...` | `package: "prepay_id=" + prepay_id` |
| 调起报"支付验证签名失败" | 用了 V2 接口拿到的 `prepay_id` 调 V3 医保调起 | 自费下单必须用 [V3 JSAPI 下单](https://pay.weixin.qq.com/doc/v3/merchant/4012791897.md) 拿 `prepay_id` |
| 调起 `paySign` 验签失败 | 商户自己生成的签名与微信侧不一致 | 用[官方签名校验工具](https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=jsapisign) 比对 |
**B. 下单参数类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 下单报"appid 字段不正确" | `appid` 大小写错误(必须全小写) | 检查请求 body 中所有 `appid` |
| `appid not match mchid` | `appid``mchid` 未在商户平台绑定 | 商户平台 → AppID 账号管理 → 关联子 AppID |
| 请求体某字段报"字段不正确" | 请求里有空字段或空字段名 | 不传则不要写入 JSON禁止 `"": ""` 这种空键 |
| 纯医保 `CASH_ONLY` 报错 | 误传了 `pay_order_id` / `pay_auth_no` / `med_ins_*` | 见 1.2 错误码段「268529474」`mix_pay_type` 严格清空 |
| 纯医保单CASH_ONLY医保局查到一直"预结算" | 商户错传了 `INSURANCE_ONLY` / `CASH_AND_INSURANCE` | 纯自费场景 `mix_pay_type` 必须 `CASH_ONLY`,否则会通知医保局结算 |
| `REQUEST_BLOCKED`:暂不支持当前商户下单 | 商户号未开通医保支付权限 | 联系微信医保对接接口人申请开通 |
| `description` 想自定义为"医保自费支付" | 直接改 `description` 字段值即可 | 微信账单展示沿用 `description` |
**C. 回调通知 / 状态查询类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 没收到混合支付回调通知 | 商户响应未按文档要求(非 200/204 或非 JSON微信判定失败后停止重试 | 严格按 [回调应答规范](https://pay.weixin.qq.com/doc/v3/merchant/4016781554.md) 返回 `{"code":"SUCCESS"}` 或空 200 |
| 自费支付通知重复发,但混合通知不重发 | 自费通知未按规范应答(混合通知已正确应答) | 两个通知都要正确应答;建议幂等 |
| 混合支付订单回调通知到哪个地址? | 走"医保混合下单"接口里的 `callback_url`,不是自费下单的 `notify_url` | 两个 URL 可分别配置,互不干扰 |
| 有自费的混合订单查不到 `transaction_id` | 混合下单回调 / 查单**不返回** `transaction_id` | 必须走自费侧"按 out_trade_no 查单"或自费的支付结果通知拿 `transaction_id` |
| 混合订单只看 `mix_pay_status` 失败就关单 | 自费可能已成功但医保失败 | 必须同时判 `self_pay_status`,自费成功要单独走基础退款 |
| 用户拉起后很久才支付(如 12:23 拉起 12:25 支付) | 用户操作延迟,**属正常行为** | 业务侧设置合理订单有效期(建议 30 分钟)+ 主动查单兜底 |
**D. 退款类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 纯医保单(无自费)要不要调自费退款? | 不需要 | 直接走医保退款接口 + 调"医保退款通知"同步给微信 |
| 0 元自费要不要调自费退款? | 视实际情况0 元单一般无自费可退 | 仅当存在 `wechat_pay_cash_fee > 0` 才调基础退款 |
| 调了医保退款但微信查到 `insuranceFeeAmt = null` / 状态不变 | 没调微信侧的"医保退款通知"接口 | 退款完成后必须调 [医保退款通知](https://pay.weixin.qq.com/doc/v3/merchant/4016781561.md) 同步结果给微信 |
| 旧的 `https://api.weixin.qq.com/payinsurance/refund` 还能用吗? | 不能,是 1.0 接口,已停用 | 2.0 改用医保中台退款 + 微信医保退款通知 |
| 用户什么时候能看到退款提醒? | 仅当走过自费微信退款时,微信会推退款提醒 | 纯医保退款不会推微信侧用户提醒 |
**E. 其他**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 加签 / 验签的 pem 文件医保和自费要分开吗? | 不需要,**用同一份**商户 API 私钥 | 整个商户号共用一套 V3 证书 |
| 医保支付有没有 SDK | **暂无官方 SDK**,需按文档自行开发 | 可参考 `示例代码` 中的 Java/Go 调用样例 |
| `refund_time` 严格校验吗? | 不严格校验时间精度,但**格式必须** RFC3339 | `yyyy-MM-DDTHH:mm:ss+08:00` |
| V3 医保现在支持直连商户吗? | 支持 | [文档](https://pay.weixin.qq.com/doc/v3/merchant/4016781466.md) |
| 互联网医院多个小程序能共用医保渠道吗? | 主体一致 + 商户号一致 → 可以;任一不同 → 不可以 | 按子商户 / 子 AppID 独立报备 |
| 医院多个诊疗场景,`order_type` 怎么选? | 按文档枚举值"对症选号"(挂号 / 诊间 / 住院 / 药店 / 互联网医院)一一对应 | 一笔单只能选一个 `order_type` |
| 接口超时怎么办? | 重试一次;仍失败用 Request-Id 找微信侧拿日志 | 自费已扣但医保超时,必须查单兜底 |
### 2.9 前端 fail 回调 msg 解读(小程序 / JSAPI 拉起后失败)
> 来源:[商户报错排查指引4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md) 阶段 3。
>
> 用户报"用户点了支付按钮但失败 / 收银台一闪而过 / 拉起后没反应"且**带 fail 回调的 msg 文案**时按下表定位;纯权限/鉴权类access denied、no permission to execute、function_not_exist 等)请回到 `2.8 A 客户端调起类`。
| `fail.msg` 内容 | 真实根因 | 处理建议 |
|---|---|---|
| `缺少 mix_trade_no` | 前端拉起接口未传混合单号(`mixTradeNo` 字段) | 检查前端调用参数,确认从下单接口拿到的 `mix_trade_no` 已正确赋值给拉起入参 |
| `缺少自费参数` | 含自费场景下,自费侧调起参数不全 | 检查 `appid` / `package` / `signType` / `paySign` / `timeStamp` / `nonceStr` 六件套;其中 `package = "prepay_id=" + prepay_id`**纯医保场景禁传**自费参数 |
| `混合支付超时` | 用户未在 30 秒内完成支付流程 | 检查用户网络 / 引导用户重试;业务侧建议设置订单 30 分钟有效期 + 主动查单兜底 |
| `混合支付订单状态为支付失败` | 自费或医保任一环节失败 | 调"按 mix_trade_no 查单",看 `self_pay_status``med_ins_pay_status` 哪一侧失败;医保侧失败时取 `med_ins_fail_reason` 看真实原因 |
| `混合支付订单状态异常` | 系统异常 | 取 Request-Id 联系微信医保技术支持群排查(`Wechatpay_BDzhushou` |
| `自费支付失败` | 微信支付收银台自费段失败 | 走标准自费排障:检查 `prepay_id` 是否过期、用户余额、风控等;`paySign` 必须用商户 API 私钥 + RSA-SHA256 |
| `用户直接退出医保混合支付` | 用户主动取消(**非异常** | 不要把这类当成失败上报告警;可用作转化漏斗指标 |
| `混合支付订单已关单` | 订单已超时关闭 | 用**新的** `out_trade_no` 重新下单 |
### 2.10 免密授权payAuthNo常见问题
> 来源:[商户报错排查指引4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md) "其他 - 免密授权"章节。
>
> 免密授权属于**支付前的电子凭证授权阶段**:用户跳转国家局 / 省中台授权页 → 拿到 `authCode` → 商户后端用 `authCode` 调授权查询接口拿到 `payAuthNo` 等参数 → 才能调医保混合下单。本节问题与下单接口报错无关,命中关键词(`payAuthNo` / `免密授权` / `机构渠道认证编码` / `国家局授权页` 等)时优先看这里。
| # | 问题 | 答案 |
|---|---|---|
| 1 | 授权时提示"该机构渠道认证编码错误" | 按顺序检查:① 国家局反馈单中的机构渠道认证编码是否复制完整(机构自查);② 编码是否含特殊符号,链接拼接时**先 URL encode 再拼接**,并查后端日志(联调同学协助);③ 1 和 2 都正常时,找省中台确认机构参数是否已加入测试环境(联调同学反馈) |
| 2 | 国家局免密授权未正确出现国家局页面 | ① 检查拼接链接是否把国家局免密授权所需的全部参数都拼上(机构自查);② 不同环境调试时,**页面参数和接口返回参数都要按对应环境配置**(联调同学协助)。例:测试环境要核对测试环境的 H5/小程序免密配置是否齐全 |
| 3 | 国家局授权页点授权时报"网络异常" | 凭证内网请求支付中台不通,或医院未在支付中心登记。需医院确认是否已登记 + 省中台自查(联调同学反馈) |
| 4 | 国家局授权页提示"用户信息已过期" | 参数失效,退出多试几次。测试环境中台一般是 IP在微信内打开会做一次 IP 确认,所以参数容易失效——**这是测试环境特有现象** |
| 5 | 用户授权后 `payAuthNo` 怎么获取? | 前端授权拿到 `authCode` 后,**后端**调授权查询接口(`authCode` 作为 `qrcode` 入参),从返回中提取 `payAuthNo` 等业务参数 |
| 6 | 测试环境没有用户 `openid`,怎么调授权查询接口? | 测试环境**可以不传 `openid`**,仅用 `authCode` 调即可;正式环境必须传 |
| 7 | 授权查询接口出参中没有所需参数 | 用户信息相关字段是机构在**立项时与腾讯商务沟通申请**,按申请到的字段返回;文档出参仅是参考,实际以立项申请为准 |
| 8 | 授权查询拿到的经纬度是 `(0, 0)` | 用户拒绝了位置授权,无法获取经纬度时返回 `(0, 0)`。前端要引导用户开启位置权限 |
| 9 | 每次支付都要做一次免密授权吗? | **是**`payAuthNo` 一次一用,每笔支付都要先走免密授权拿新的 |
| 10 | 用户授权时提示"要设置密码" | 用户只激活了医保电子凭证但未设置支付密码。需用户先在医保电子凭证内设置密码,才能完成授权 |
| 11 | 小程序免密授权如何在测试环境调试? | 把 `envVersion` 改为 `'trial'`(体验版):`envVersion: "release"` 正式版 / `"trial"` 体验版 / `"develop"` 开发版 |
| 12 | 授权后报 `{"code":150502,"message":"所在地区未查询到用户参保信息"}` | 两种可能:① 用户没有对应城市的参保地——若是测试环境,找省中台添加测试人员参保数据(姓名 / 身份证 / 参保地代码 / 参保地名称)同步到国家局后重试;② 用户有参保地但没加入微信测试环境——先解绑,让联调同学添加微信测试环境(姓名 / 身份证 / 微信号 / 手机号)后重试 |
| 13 | 小程序授权完成后没拿到 `authCode`,报错 | `authCode` 应该在 `app.js` 中获取(`onLaunch` / `onShow``query` 里),不要在页面 `onLoad` 里取 |
| 14 | H5 调试正常但小程序调试报"机构渠道认证编码错误" | ① 确认小程序使用的机构渠道认证编码是否与反馈单一致;② 确认小程序跳转的免密链接中机构渠道认证编码**不要做 encode**(与 H5 不同,小程序不需要 URL encode |
| 15 | 电子凭证展不了码,会影响免密授权吗? | **会**。2.0 模式的免密要求用户能成功展码,才能完成 `payAuthNo` 授权 |
| 16 | 小程序免密授权提示"传入小程序 appid 与主体不匹配" | 小程序授权链接上的 `sourceapp`(前 18 位)与合作方在医保局立项时申请的小程序 `appid` 不一致。改成立项申请的 `appid` 再调用 |
### 2.11 亲情授权(代亲属激活)常见问题
> 来源:[商户报错排查指引4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md) "其他 - 亲情授权"章节。
>
> 亲情授权是 **`pay_for_relatives = true` 代亲属支付场景的前置条件**——必须先帮亲属(如儿童 / 老人)激活医保电子凭证并建立亲情账户绑定关系,才能在下单时上送 `relative.name` / `relative.id_digest`。本节问题对应下单错误码 `268545964 亲属关系不存在`。
| # | 授权页提示 | 真实根因 | 处理 |
|---|---|---|---|
| 1 | "未查询到该少儿参保信息" | 国家局还没有该小孩的参保信息 | 联系**地方医保局**把数据同步到国家局后重试 |
| 2 | "尚未绑定少儿医保" | 地方医保局亲属库里查不到本人和小孩的亲属关系 | 联系**本地医保局**确认两人是否在亲属库里有亲属关系,有才能用 |
| 3 | "为了亲属的账户安全,请先设置密码" | 授权要求亲属可以正常展码,必须先有密码 | 引导操作人先帮亲属完成密码设置,再发起授权 |
| 4 | "请检查传入的亲人信息是否正确" | 授权链接传入的亲人与地方医保局亲属库里的亲属不是同一个人 | 核对身份证 / 姓名后重传**地方医保局亲属库里登记的那一位** |
| 5 | "该亲人已被他人绑定,你无法帮其激活医保电子凭证" | 该亲人已被别人绑定激活,且达到了代激活数量上限 | 业务侧无法继续,让用户与已绑定方协商解绑 |
| 6 | "亲属已自主激活医保电子凭证使用" | 该亲人已自己在手机微信激活了电子凭证 | 自激活后无法被代激活,业务侧引导亲属本人使用 |
| 7 | "需要亲属在你手机上人脸认证通过后,你可帮助其激活" | 亲人已达一定年龄,代激活需做人脸认证 | 让亲人本人在操作人手机上完成人脸认证 |
| 8 | "本人 / 亲人未参保" | 亲人没设默认参保地 | 非深圳地区可在亲人**展码页**选择对应参保地后再发起授权(系统在持续优化默认参保地逻辑) |
---
## 官方排查指引文档索引
> 当本手册命中关键词但描述不够细时,可对照官方原文校对(含最新更新)。
| 接入模式 | 链接 | 适用场景 |
|---|---|---|
| 商户模式 | [报错排查指引_医保支付4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md) | 直连商户接入医保支付 |
| 服务商模式 | [报错排查指引_医保支付4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) | 服务商代普通商户接入 |
| 服务商模式(间连/从业机构) | [报错排查指引_医保支付4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md) | 从业机构代医保定点机构接入 |
---
## 排障信息收集清单
两条路径都未命中时,请用户提供:接入模式(商户 / 服务商)、出错环节(自费下单 / 医保下单 / 调起 / 查单 / 退款通知 / 回调 / 对账 / **免密授权 / 亲情授权**、HTTP 状态码 + 完整响应体、Request-Id含尾段错误码、商户号 `mchid` + 业务单号 `out_trade_no` / `mix_trade_no` + 医院 `serial_no` + `med_inst_no` + `city_id` + 请求时间;如属医保支付失败还需取**查单接口的 `med_ins_fail_reason`**;如属授权阶段还需取 `authCode` / 授权链接 / 机构渠道认证编码。

View File

@@ -0,0 +1,98 @@
# 服务商模式产品介绍
> 来源:[商户产品介绍](https://pay.weixin.qq.com/doc/v3/merchant/4016824672.md) + [服务商医保自费混合收款下单v2.0/服务商模式)](https://pay.weixin.qq.com/doc/v3/partner/4012503131.md) + [服务商医保自费混合收款下单v2.0/间连模式)](https://pay.weixin.qq.com/doc/v3/partner/4018300080.md)
## 一、产品概览
**移动医保支付** 是基于「微信医保电子凭证」的线上医保结算解决方案。服务商/从业机构代医疗机构(子商户)接入微信医保后,医院 HIS / 药店收银系统可在小程序、公众号 H5 中直接完成「医保 + 自费」一键支付。
> ‼️ 本技能覆盖 **移动医保支付 2.0**(接口前缀 `/v3/med-ins/`。1.0 版本(接口前缀 `/v3/medicalinsurance/`)不在本技能覆盖范围。请尽量升级到 2.0 版本。
>
> ‼️ 服务商模式 vs 间连模式:两种模式的**接口路径与请求/响应字段完全一致**(都是 `POST /v3/med-ins/orders`,请求体都需要 `appid` / `sub_appid` / `sub_mchid`),区别仅在于服务商资质类型与医保局后台的对接关系:
| 模式 | 接入主体 | 与医保局对接关系 |
| ---- | -------- | -------------- |
| **服务商模式** | 普通服务商(如医疗系统集成商) | 子商户(医疗机构)自行与医保局对接 |
| **间连模式** | 从业机构(银行 / 支付机构) | 从业机构代子商户与医保局对接 |
> 接口和签名链路两种模式无差异本技能在「2-服务商/」目录下统一覆盖。如果是直连普通商户(医院/药店自有商户号且自行接入),请改用 [商户模式产品介绍](../../1-商户/产品选型/产品介绍.md)。
## 二、商户模式适用范围
| 角色 | 适用情况 |
| ---- | -------- |
| **普通服务商** | 医疗信息化集成商、HIS 厂商等,已签约成为微信支付服务商,代医院/药店接入医保支付 |
| **从业机构(银行)** | 持牌银行作为收款方,代医院/药店接入医保支付(间连模式) |
| **从业机构(支付机构)** | 持牌支付机构(第三方支付公司),代医院/药店接入医保支付(间连模式) |
接入主体与医疗机构(子商户)通过 **`sub_mchid`** 字段建立绑定关系,同一服务商可代理多个医疗机构。
## 三、典型使用场景(订单类型 `order_type`
| 订单类型枚举 | 中文 | 适用业务 |
| ----------- | ---- | -------- |
| `REG_PAY` | 挂号支付 | 门诊挂号缴费 |
| `DIAG_PAY` | 诊间支付 | 门诊就诊间缴费 |
| `IN_HOSP_PAY` | 住院费支付 | 住院预交、出院结算 |
| `PHARMACY_PAY` | 药店支付 | 定点零售药店购药 |
| `INSURANCE_PAY` | 保险费支付 | 商业健康险费用 |
| `INT_REG_PAY` | 互联网医院挂号支付 | 在线问诊预约 |
| `INT_RE_DIAG_PAY` | 互联网医院复诊支付 | 在线复诊缴费 |
| `INT_RX_PAY` | 互联网医院处方支付 | 互联网医院开具的处方药费 |
| `MED_PAY` | 药费支付 | 通用药品费用 |
| `COVID_EXAM_PAY` | 新冠检测费用 | 核酸检测 |
| `COVID_ANTIGEN_PAY` | 新冠抗原检测 | 抗原检测 |
> ‼️ `order_type` 必须严格匹配子商户实际业务场景,**不可跨场景使用**。
## 四、混合支付类型(`mix_pay_type`
| 混合支付类型 | 含义 | 必填字段 | 禁填字段 |
| ----------- | ---- | -------- | -------- |
| `CASH_ONLY` | 纯自费 | `wechat_pay_cash_fee` (>0) + `prepay_id` | `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个医保金额字段 |
| `INSURANCE_ONLY` | 纯医保 | `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个医保金额字段 | `wechat_pay_cash_fee` / `prepay_id` |
| `CASH_AND_INSURANCE` | 医保自费混合 | 上述全部字段都必填 | — |
金额校验公式:
```text
total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ wechat_pay_cash_fee + cash_reduce_detail 之和
= med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ med_ins_cash_fee + cash_add_detail 之和
wechat_pay_cash_fee = med_ins_cash_fee + cash_add_detail 之和 - cash_reduce_detail 之和
```
## 五、接入前提
### 5.1 资质与权限
1. **服务商资质**:已签约成为微信支付服务商(普通服务商)或微信支付从业机构(银行 / 支付机构)
2. **子商户资质**:被代理的医疗机构(医院 / 药店)必须具备医保定点资格,且已与当地医保局签约
3. **特约商户进件**:把每个医疗机构作为 `sub_mchid` 进件到服务商号下,并完成 AppID 绑定关系
4. **医保城市接入**:医疗机构所在城市已开通微信医保电子凭证支付能力
5. **医保局对接**:医疗机构 HIS / 从业机构系统已完成与当地医保局后台的对接费用明细上传【6201】、医保下单、医保扣款等
6. **接入申请**:联系微信医保对接邮箱/对接群,由腾讯侧分配 **渠道号**`channel_no`)和每个接入城市的 **城市 ID**`city_id`
7. **APIv3 升级**:服务商号需开通 APIv3 + 申请 APIv3 密钥 + 服务商 API 证书 + 微信支付公钥(推荐)
> ‼️ 开发参数清单(含 `sub_mchid` / `sub_appid` 等服务商特有字段)、获取步骤与字段约束统一收拢到 [开发参数与业务规则](../接入指南/开发参数与业务规则.md),本页不再重复列出。
### 5.2 与医保局的协同
服务商模式下,医疗机构 HIS或从业机构系统仍需完成与医保局的协同
1. **费用明细上传**【6201】上传到医保局后台得到 `medOrgOrd`(即下单时的 `serial_no`
2. **医保预结算**:调用医保局接口,得到 `pay_order_id`(医保局支付单 ID`pay_auth_no`(医保局支付授权码)
3. **医保自费混合下单**:服务商代子商户调用 `/v3/med-ins/orders`,请求体携带 `sub_mchid` / `sub_appid` 标识子商户
4. **用户授权 + 调起支付**:用户在 sub_appid 下的小程序 / H5 中调起医保自费混合支付(注意 `openid``sub_openid` 二选一,决定调起时使用哪个 appid
5. **回调通知**:微信医保通过 `MEDICAL_INSURANCE.SUCCESS` 事件通知服务商,回调中带 `sub_mchid` 用于路由到对应子商户业务
### 5.3 接入产物
1. 医保自费混合下单(`POST /v3/med-ins/orders`,含 `sub_mchid` / `sub_appid`)→ JSAPI/小程序调起 → 接收"医保混合收款成功通知"
2. 主动查询订单状态(按 `mix_trade_no``out_trade_no`Query 参数带 `sub_mchid`
3. 医保退款发生时,向微信医保发起「医保退款通知」(`POST /v3/med-ins/refunds/notify`body 带 `sub_mchid`
4. 自费部分独立退款走标准退款接口(详见 [📄 排障手册](../问题排查/排障手册.md)
> ‼️ 服务商必须按 `sub_mchid` **路由所有回调**到对应的子商户业务系统,否则会出现"A 医院的订单被 B 医院业务处理"的串单事故。

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,184 @@
# 服务商模式开发参数与业务规则
> 来源:[服务商医保自费混合收款下单](https://pay.weixin.qq.com/doc/v3/partner/4012503131.md) + [服务商医保退款通知](https://pay.weixin.qq.com/doc/v3/partner/4012166534.md) + [服务商医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/partner/4012165722.md)
本文档覆盖本产品**接入前**需要准备的全部参数与产品特有的字段传参规范。服务商模式与间连模式接口/请求体完全一致,本文统一描述。错误处置见 [📄 排障手册.md](../问题排查/排障手册.md)。
---
## 一、参数清单
| 参数 | 归属 | 类型 | 用途 | 必备性 |
| ------------------ | --- | ------------ | ----------------------------------- | ---- |
| `mchid` | 服务商 | string | 服务商商户号,所有 API 必传 | 必备 |
| `appid` | 服务商 | string | 服务商公众号 / 小程序 AppID必须已与 mchid 绑定) | 必备 |
| `sub_mchid` | 子商户 | string(32) | 医疗机构商户号,下单 / 查单 / 退款通知必传 | 必备 |
| `sub_appid` | 子商户 | string(32) | 医疗机构公众号 / 小程序 AppID与 sub_mchid 绑定) | 下单必传 |
| `med_inst_no` | 子商户 | string(32) | 医疗机构编码(医保局提供) | 必备 |
| `med_inst_name` | 子商户 | string(128) | 医疗机构名称 | 必备 |
| `city_id` | 子商户 | string(8) | 微信医保城市 ID腾讯对接群提供 | 必备 |
| `channel_no` | 服务商 | string(32) | 渠道号(腾讯工程师分配) | 可选 |
| **APIv3 密钥** | 服务商 | string(32) | 解密回调通知密文 | 必备 |
| 服务商 API 证书 + 序列号 | 服务商 | PEM + string | APIv3 请求签名(**不是子商户证书** | 必备 |
| **微信支付公钥 + 公钥 ID** | 服务商 | PEM + string | APIv3 响应/回调验签 + 敏感字段加密 | 强烈推荐 |
| 微信支付平台证书(旧式) | 服务商 | PEM + 序列号 | 与微信支付公钥二选一 | 可选 |
| `notify_url` | 服务商 | URL | 接收医保混合收款成功通知HTTPS + 已备案 + 公网可达 | 必备 |
> ‼️ 服务商签名一律使用**服务商**的 API 证书私钥(不是子商户的),`Authorization` 头里的 `mchid` 也是**服务商号**。子商户的 API 证书与本接入无关,常被误用导致 401 SIGN_ERROR。
>
> ‼️ 医保支付的下单接口包含**身份证姓名 + 身份证 MD5 摘要**等敏感信息,必须使用**服务商**的微信支付公钥/平台证书加密HTTP 请求头需要 `Wechatpay-Serial` 设置为对应公钥 ID / 序列号)。
## 二、获取步骤
### 2.1 服务商 mchid / appid
1. 登录 [微信支付商户平台](https://pay.weixin.qq.com/)(服务商号)
2. 进入「账户中心 → 商户信息」即可看到服务商 `mchid`10 位数字),也可在右上角点击商户简称下拉查看
3. ⚠️ 右上角直接显示的是「商户简称」(中文昵称),**不是** `mchid`,请勿混用;服务商签名头里的 `mchid` 应填该 10 位服务商商户号
4. 服务商公众号 / 小程序在 [微信公众平台](https://mp.weixin.qq.com/) 申请,并在商户平台「产品中心 → AppID 账号管理」与 mchid 绑定
### 2.2 子商户 sub_mchid / sub_appid
1. 在服务商平台「特约商户」中为每个医疗机构提交进件,得到 `sub_mchid`
2. 子商户的公众号 / 小程序 AppID`sub_appid`)必须在商户平台与 `sub_mchid` 完成绑定关系
3. 详细步骤:[特约商户进件指引](https://pay.weixin.qq.com/doc/v3/partner/4014007658.md)
> ‼️ **下单 / 调起支付必须使用同一组 appid + openid**:传 `openid` 时调起需用 `appid`(服务商),传 `sub_openid` 时调起需用 `sub_appid`(子商户)。两者不可错配,否则触发 `PARAM_ERROR`。
### 2.3 医疗机构编码 / 名称 / 城市 ID
- `med_inst_no` / `med_inst_name`:由医保局在医疗机构对接时下发
- `city_id`:由腾讯医保对接侧下发,与国标行政区划编码可能存在差异,必须使用腾讯下发值
### 2.4 APIv3 密钥 / API 证书 / 微信支付公钥
参照「商户模式」,但**所有材料一律在服务商号下申请**
1. APIv3 密钥:服务商商户平台 → 账户中心 → API 安全 → 设置 APIv3 密钥
2. API 证书:服务商商户平台 → 账户中心 → API 安全 → API 证书 → 申请并下载
3. 微信支付公钥(推荐):服务商商户平台 → 账户中心 → API 安全 → 微信支付公钥;得到公钥文件 + 公钥 ID`PUB_KEY_ID_xxxxxxxx`
### 2.5 渠道号 channel_no可选
由腾讯工程师在对接时分配,下单时通过 `channel_no` 字段传入。
### 2.6 notify_url 配置
服务商的 `notify_url` 通过下单接口的 `callback_url` 字段直接传入,**每次下单都必须传**
- HTTPS不允许 HTTP
- 域名已 ICP 备案(服务商域名)
- 公网可达,**不能用 127.0.0.1 / 192.168.x.x / localhost**
- 不能携带任何 query 参数
> ‼️ 多个子商户共用同一个 `notify_url`,回调中通过 `sub_mchid` / `sub_appid` 字段路由,**必须按 sub_mchid 路由到对应业务**,否则会出现串单。
## 三、接入前自查清单
接入前请逐项确认(任一项不满足都会触发下单失败):
- 服务商 mchid 已签约成为微信支付服务商或从业机构
- 服务商 mchid 已开通医保支付能力(联系腾讯医保对接侧确认)
- 服务商 appid 已与服务商 mchid 完成绑定
- 每个子商户已完成特约商户进件(拿到 `sub_mchid`
- 子商户 sub_appid 已与 sub_mchid 完成绑定
- 服务商 APIv3 密钥已设置
- 服务商 API 证书已下载并保存证书序列号
- 服务商微信支付公钥(或平台证书)已下载,公钥 ID 已记录
- 子商户与医保局对接费用明细上传【6201】 + 医保预结算流程已跑通
- 腾讯医保对接侧已下发每个城市的 `city_id` 和(可选的)`channel_no`
- notify_url 已配置且公网可达 + 已备案
- 回调路由代码已按 `sub_mchid` 隔离子商户业务,避免串单
- 敏感字段加密代码已就位(身份证姓名 + 身份证摘要)
---
## 四、混合支付类型与金额公式
下单接口的 `mix_pay_type` 决定后续字段约束。三种类型字段约束与商户模式一致:
| 类型 | 含义 | 自费字段 | 医保字段 |
| -------------------- | ------ | ---------------------------------------------- | -------------------------------- |
| `CASH_ONLY` | 纯自费 | `wechat_pay_cash_fee > 0` + `prepay_id` 必填 | 4 个医保金额字段之和必须为 04 个医保订单字段**禁填** |
| `INSURANCE_ONLY` | 纯医保 | `wechat_pay_cash_fee` 必须为 0`prepay_id` **禁填** | 4 个医保金额字段必填、4 个医保订单字段必填 |
| `CASH_AND_INSURANCE` | 医保自费混合 | `wechat_pay_cash_fee > 0` + `prepay_id` 必填 | 4 个医保金额字段必填、4 个医保订单字段必填 |
金额公式:
```text
total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ wechat_pay_cash_fee + Σ cash_reduce_detail.cash_reduce_fee
= med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee
+ med_ins_cash_fee + Σ cash_add_detail.cash_add_fee
wechat_pay_cash_fee = med_ins_cash_fee
+ Σ cash_add_detail.cash_add_fee
- Σ cash_reduce_detail.cash_reduce_fee
```
> ‼️ 金额单位为「分」,**必须使用整数**int / long。误用 double / float 会因精度丢失触发 `RULE_LIMIT: 自费金额校验不通过`。
`cash_add_detail.cash_add_type` 枚举:`DEFAULT_ADD_TYPE` / `FREIGHT` / `OTHER_MEDICAL_EXPENSES`
`cash_reduce_detail.cash_reduce_type` 枚举:`DEFAULT_REDUCE_TYPE` / `HOSPITAL_REDUCE` / `PHARMACY_DISCOUNT` / `DISCOUNT` / `PRE_PAYMENT` / `DEPOSIT_DEDUCTION`
---
## 五、敏感字段加密规范
下单接口需要加密的字段:`payer.name` / `payer.id_digest` / `relative.name` / `relative.id_digest`
**身份证摘要算法**:身份证号字母转大写 → 15 位转 18 位 → MD532 位小写十六进制)。例:`44030019000101123x``09eb26e839ff3a2e3980352ae45ef09e`
**加密**:使用**服务商的**微信支付公钥(`PUB_KEY_ID_xxxxxxxx`)做 RSA-OAEP 加密,结果 Base64 编码后放入字段。
**请求头**`Wechatpay-Serial` 必须设置为加密所用的公钥 ID**不是子商户的**,是服务商的)。
> 详细加密示例代码见 [示例代码/接口索引.md](../示例代码/接口索引.md) 中「下单」分组的 Java/Go 示例。
---
## 六、订单状态流转
与商户模式完全一致:
| 字段 | 枚举 |
| -------------------- | ------------------------------------------------------------------------- |
| `mix_pay_status` | `MIX_PAY_CREATED` / `MIX_PAY_SUCCESS` / `MIX_PAY_REFUND` / `MIX_PAY_FAIL` |
| `self_pay_status` | `SELF_PAY_CREATED/SUCCESS/REFUND/FAIL/NO_SELF_PAY` |
| `med_ins_pay_status` | `MED_INS_PAY_CREATED/SUCCESS/REFUND/FAIL/NO_MED_INS_PAY` |
> ‼️ 服务商场景下,订单查询接口 Query 必须带 `sub_mchid`,否则查不到子商户订单。
>
> ‼️ 仅依赖回调有遗漏风险30 秒后微信不再重试),**必须**对 `MIX_PAY_CREATED` 状态的订单做定时主动查询兜底。
---
## 七、医保退款通知
当子商户医保侧发生退款(医院在 HIS 中发起医保退款 → 医保局完成退款)后,**服务商必须代子商户主动向微信医保发起 `POST /v3/med-ins/refunds/notify` 通知**。
> ⚠️ 注意:「医保退款通知」**不是**微信发给商户的回调,而是**商户/服务商调用微信的接口**。命名易混淆。
请求体关键字段:
| 字段 | 含义 |
| ---------------------- | ---------------------------------------------------- |
| `mix_trade_no`Query | 微信医保侧的混合订单号 |
| `sub_mchid` | 医疗机构商户号 |
| `med_refund_total_fee` | 医保退款总金额(分) |
| `med_refund_gov_fee` | 医保统筹退款金额 |
| `med_refund_self_fee` | 医保个账退款金额 |
| `med_refund_other_fee` | 医保其他退款金额 |
| `refund_time` | 医保退款成功时间RFC3339 |
| `out_refund_no` | 从业机构 / 服务商退款单号;若同时退自费,需与自费退款申请的 `out_refund_no` 保持一致 |
**自费部分退款**走服务商标准退款接口 `POST /v3/refund/domestic/refunds`(请求体含 `sub_mchid`),不在本接口覆盖范围(需要先做自费退款申请,再用相同 `out_refund_no` 调用本接口同步医保退款结果)。

View File

@@ -0,0 +1,89 @@
# 服务商模式接入质量检查
> 本清单同时覆盖**服务商模式**与**间连模式**,两种模式接口字段一致,仅服务商资质与子商户绑定关系不同。
## 角色设定:金融支付系统技术专家
> ‼️ **本节角色、铁律和问题雷达是质检的全部驱动力,必须内化后再审代码。**
你是金融支付系统技术专家,全栈工程师出身,亲手写过从前端收银台到后端交易引擎的全链路代码。你主导过千万级用户规模的国民级支付系统架构设计,从零搭建过高并发交易平台。你熟悉主流支付平台的接入规范与安全体系,对 API 签名验签机制、异步回调通知处理、资金流对账有丰富的实战经验。你对代码质量有极强的直觉,尤其对资金链路上的异常处理缺失高度警觉。
你对支付系统的要求极高:接口交互必须有完善的异常处理和兜底方案,资金操作必须可追溯、可对账,所有外部输入必须经过校验才能进入业务逻辑。
## 铁律
**铁律一高可用99.9999%**
系统可用性要求 99.9999%(六个 9即每一百万次请求中最多允许一次失败。支付链路上不允许存在单点故障每一个外部调用都必须有超时、重试和降级方案。
检查直觉:调用微信支付 API 超时了,代码会自动重试还是直接报错?重试的时候会不会导致重复下单?微信的支付回调一直没来,系统有没有定时去主动查询订单状态?用户快速点了两次支付按钮,会不会创建两笔订单?
**铁律二:资金安全(一分钱都不能错)**
金额计算必须使用整数(单位:分),杜绝浮点精度丢失。每一笔资金变动(支付、退款、分账)都必须有据可查,系统必须在次日通过账单对账主动发现差异。
检查直觉:金额字段的类型是 int/long 还是 double/float用户申请退款时代码有没有累加历史退款金额并校验是否超过订单总额系统有没有每天自动拉取微信账单和本地订单做比对
**铁律三:零信任(不信任任何未经验证的外部数据)**
微信的回调通知、前端传入的参数、缓存中的数据,在进入业务逻辑前必须经过验证,未验证的输入一律视为不可信。
检查直觉:收到支付回调后,代码是先验签还是直接解析 body 处理业务?下单接口的金额是从后端数据库查的还是直接用前端传过来的值?回调通知中的支付金额有没有和本地订单金额做比对?私钥是通过环境变量加载的还是硬编码在代码里?
## 检查方法
1. **扫代码** — 快速扫描代码,按问题雷达定位高风险区域
2. **追链路** — 沿资金流完整走一遍:自费 JSAPI 下单 → 服务商医保混合下单 → 小程序/JSAPI 调起 → 用户医保密码确认 → 医保混合收款回调按 `sub_mchid` 路由 → 主动查单兜底 → 医保退款通知,任何断点都是事故点
3. **做预演** — 对每个关键节点问"如果这里故障了/超时了/被攻击了/来了两次/串了 sub_mchid会怎样"
**输出要求**:发现问题必须给出修复方向,不能只说"有风险";必须基于代码事实,不基于猜测;结果按 🔴🟡🟠 分级,致命问题置顶。
## 问题雷达
> **来源**:通用安全雷达(固定 4 项)+ 产品专属雷达(**重点从「开发指引」与「常见问题」提炼**,其他文档作为补充)。
>
> 以下仅列举常见的高风险问题,**不要只检查列出的项**。检查时应反向运用铁律:逐条铁律审视代码,发现未列出的同类问题。
### 通用安全雷达(所有产品必查)
> 4 项**独立判定**,每项必须给出"通过 / 未实现 / 不涉及"三选一的明确结论,**禁止合并多项为一条**。具体检查方法见 [签名与验签规则](./签名与验签规则.md)。
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
| --- | ------ | -------- | ---------------- | -------- |
| 1 | **HTTP 响应验签** | 发起请求并处理响应的代码OkHttp `execute()` / HttpClient `send()` 等) | 收到 2XX 响应后直接解析返回数据,中间无任何验签调用 | 🔴 致命 |
| 2 | **回调通知验签** | 处理 `MEDICAL_INSURANCE.SUCCESS` 回调的代码(含 `event_type` / `resource_type` / `encrypt-resource` 等字段) | 收到通知后**先解密或解析业务数据**,验签缺失或在解密之后 | 🔴 致命 |
| 3 | **幂等去重 + 并发锁** | 回调处理流程的入口 | 既无按"`sub_mchid` + `mix_trade_no` + `event_type`"的去重查询也无加锁逻辑Redis 锁 / 行锁 / `synchronized` 等) | 🔴 致命 |
| 4 | **探测流量未做特殊跳过** | 验签代码分支 | 对签名值含 `WECHATPAY/SIGNTEST/` 前缀的请求做了特殊跳过/早返回 | 🟠 可选 |
### 产品专属雷达(移动医保支付 2.0 · 服务商)
> **来源**[服务商医保下单](https://pay.weixin.qq.com/doc/v3/partner/4012503131.md) + [服务商产品介绍](https://pay.weixin.qq.com/doc/v3/partner/4016824698.md) + [服务商常见问题](https://pay.weixin.qq.com/doc/v3/partner/4017415847.md) + [服务商回调通知](https://pay.weixin.qq.com/doc/v3/partner/4012165722.md) + [服务商退款通知](https://pay.weixin.qq.com/doc/v3/partner/4012166534.md) + [间连回调通知](https://pay.weixin.qq.com/doc/v3/partner/4018303231.md)
| # | 检查项 | 检查锚点 | 未实现的判定特征 | 默认级别 |
| --- | ------ | -------- | ---------------- | -------- |
| 1 | **sub_mchid 必传且为子商户号** | 创单 / 查单 / 退款通知 | 误把服务商自己的 `mchid` 填到 `sub_mchid`;或漏传 `sub_mchid` 触发 `RULE_LIMIT 商户与子商户不是受理关系` | 🔴 致命 |
| 2 | **签名用服务商 API 证书私钥** | 所有 APIv3 调用 | 用子商户的密钥 / 证书签名(子商户无独立 API 证书);`mchid` 字段必须是服务商 mchid | 🔴 致命 |
| 3 | **回调按 (sub_mchid, mix_trade_no) 联合路由** | `MEDICAL_INSURANCE.SUCCESS` 回调入口 | 仅按 `out_trade_no` / `mix_trade_no` 路由:不同子商户 `out_trade_no` 可能重复,会出现串单 | 🔴 致命 |
| 4 | **payer.name / payer.id_digest 加密** | 创单参数构造payer / relative | 直接传明文姓名或身份证 MD5或用了商户 API 证书私钥而不是**微信支付公钥** RSA-OAEP 加密 | 🔴 致命 |
| 5 | **id_digest 计算流程正确** | 创单参数预处理 | 身份证未先把字母转大写、15 位未转 18 位再 MD5或 MD5 输出未转小写就加密;或对完整身份证号直接加密未做 MD5 | 🔴 致命 |
| 6 | **Wechatpay-Serial 头按加密钥写入** | 创单 / 退款通知 HTTP Header | 用微信支付**公钥**加密敏感字段,但 Header 写的是平台**证书序列号**(应为 `PUB_KEY_ID_xxx`),导致解密失败 | 🔴 致命 |
| 7 | **金额公式硬校验total_fee** | 创单调用前 | 未在本地按公式 `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee` 校验 | 🔴 致命 |
| 8 | **金额公式硬校验wechat_pay_cash_fee** | 创单调用前 | 未在本地按公式 `wechat_pay_cash_fee = med_ins_cash_fee + Σcash_add_detail.fee - Σcash_reduce_detail.fee` 校验 | 🔴 致命 |
| 9 | **mix_pay_type 与字段联动校验** | 创单参数构造 | `CASH_ONLY` 误传 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*` 字段;`INSURANCE_ONLY` 误传 `wechat_pay_cash_fee` / `prepay_id``CASH_AND_INSURANCE` 漏传 `prepay_id` 或医保字段 | 🔴 致命 |
| 10 | **prepay_id 与医保单 out_trade_no 一致** | 服务商自费 JSAPI 下单 → 服务商医保下单流程 | 两次下单使用了不同 `out_trade_no`;或自费下单用 `mchid + sub_mchid` 服务商场景,医保单却走了另一组 mchid | 🔴 致命 |
| 11 | **金额类型为 int/long** | 所有 amount 字段 | 用 `double` / `float` / `BigDecimal` 而非 `int` / `long`,单位不是"分" | 🔴 致命 |
| 12 | **MIX_PAY_CREATED 主动查单兜底** | 订单查询代码(带 `sub_mchid` | 仅依赖回调,没有对超时仍为 `MIX_PAY_CREATED` 的订单按 `sub_mchid` + `mix_trade_no` 主动查单 | 🔴 致命 |
| 13 | **回调金额与本地订单比对** | 医保混合收款成功回调处理 | 回调中的 `total_fee` / `wechat_pay_cash_fee` / `med_ins_*_fee` 未与本地(按 `sub_mchid` 路由后)订单金额比对 | 🔴 致命 |
| 14 | **回调 5 秒内应答** | 回调处理流程耗时 | 回调处理同步等待医院 HIS / 第三方调用,导致 5 秒超时被微信认为失败重试 | 🔴 致命 |
| 15 | **callback_url 必须 HTTPS 且无 query** | 创单 `callback_url` 拼装 | 使用 HTTP / 内网 / 携带 query 参数 / 未备案域名 / 域名前后有空格 | 🔴 致命 |
| 16 | **notify_url 鉴权排除** | 网关 / 中间件鉴权配置 | 多子商户共用 `callback_url` 的路径被登录态/Token 中间件拦截 | 🔴 致命 |
| 17 | **拉起 paySign 用服务商 API 证书私钥RSA-SHA256** | 调起小程序 / JSAPI 的 sign 生成 | 误用 APIv3 密钥 / APIv2 密钥 / HMAC-SHA256或错用了**子商户**密钥(子商户无独立 API 证书) | 🔴 致命 |
| 18 | **AppID 与 openid / sub_openid 配对** | 调起代码与创单参数对照 | 创单传 `openid` 但调起 `appid` 用了 `sub_appid`;或创单传 `sub_openid` 但调起 `appid` 用了服务商 `appid`,触发 `PARAM_ERROR 请确认AppID与OpenID是否正确` | 🔴 致命 |
| 19 | **wx.config 注入 appId 与调起 appid 一致** | JSAPI 调起前置 | H5 端 `wx.config` 注入的 `appId` / `signature` 与调起所用 `appid` 不一致,导致鉴权失败 | 🟡 推荐 |
| 20 | **代亲属支付 relative 必填** | 创单参数构造 | `pay_for_relatives = true` 时未传 `relative.name` / `relative.id_digest` / `relative.card_type`,触发 `PARAM_ERROR 该订单属于代亲属支付,但缺少亲属信息` | 🟡 推荐 |
| 21 | **payer 与医保电子凭证绑卡信息一致** | 创单前用户态校验 | `payer.name` / `payer.id_digest` 与用户的医保电子凭证绑卡信息不匹配,触发 `INVALID_REQUEST 入参用户姓名/个人身份ID摘要和医保电子凭证绑卡xxx不匹配` | 🟡 推荐 |
| 22 | **out_trade_no 唯一性需复合 sub_mchid** | 业务存储设计 | 业务数据库 `out_trade_no` 字段唯一索引未加 `sub_mchid` 列,多子商户场景下唯一性校验会撞键 / 串单 | 🔴 致命 |
| 23 | **time 字段 RFC3339 +08:00** | `med_ins_order_create_time` / `paid_time` | 时间格式不是 `yyyy-MM-DDTHH:mm:ss+08:00` 或时区错乱UTC 与本地混用) | 🟡 推荐 |
| 24 | **city_id 与医保对接城市编码一致** | 创单参数构造 | `city_id` 沿用国标行政区划编码而非医保接入文档定义的编码 | 🟡 推荐 |
| 25 | **med_inst_no 与 serial_no 与 6201 流水一致** | 创单参数构造 | `med_inst_no` 与医保侧报备不一致;`serial_no` 与医院 HIS【6201】费用明细 `medOrgOrd` 不一致 | 🔴 致命 |
| 26 | **退款通知是服务商主动 POST不是回调** | 退款流程实现 | 把 `POST /v3/med-ins/refunds/notify` 当作"接收微信回调"实现;且退款通知 body 中**必须**包含 `sub_mchid` | 🔴 致命 |
| 27 | **APIv3 密钥 / 私钥 / 公钥 ID 不可硬编码** | 配置加载 | 密钥写死在代码或 git 仓库的配置文件,未走环境变量 / KMS / Secrets Manager | 🔴 致命 |
| 28 | **服务商 API 证书私钥保护** | 私钥加载与权限 | 私钥文件权限为 644 或更宽未做最小权限CRLF 转 LF 后未保留 PEM 头尾 | 🟡 推荐 |
| 29 | **med_ins_test_env 上线后置 false** | 创单参数构造 | 联调期 `med_ins_test_env = true` 但上线后未关闭,导致正式环境下单到医保局测试环境,造成资损 / 医保局对账差异 | 🔴 致命 |
| 30 | **重试机制带指数退避 + 幂等 out_trade_no** | 创单 / 查单代码 | 网络超时直接换 `out_trade_no` 重试,未先按原 `out_trade_no` 查单;或重试无指数退避触发 `RULE_LIMIT 请求次数超过限制` | 🔴 致命 |
| 31 | **多子商户场景日志带 sub_mchid** | 日志埋点 | 全链路日志未带 `sub_mchid`,事后排障无法定位是哪个医院/药店的订单 | 🟡 推荐 |
| 32 | **服务商与子商户受理关系正确** | 子商户进件流程 | 创单返回 `RULE_LIMIT 商户与子商户不是受理关系`:未在服务商体系下完成子商户绑定;或服务商资质未在医保局对应城市报备 | 🔴 致命 |

View File

@@ -0,0 +1,213 @@
# 服务商模式签名与验签规则
> 本文档为微信支付 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":"wxYOUR_APPID","mchid":"YOUR_MCHID","description":"Image形象店-深圳腾大-QQ公仔","out_trade_no":"1217752501201407033233368018","notify_url":"https://www.weixin.qq.com/wxpay/pay.php","amount":{"total":100,"currency":"CNY"},"payer":{"openid":"oYOUR_OPENID_EXAMPLE"}}\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="YOUR_MCHID",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` |
| **医保自费混合JSAPI / 小程序)** | 公众号 / 小程序 AppID | **`prepay_id=<value>`**(与 JSAPI 完全一致) | 在普通 JSAPI/小程序字段基础上,额外多一个 `mixTradeNo`**不参与 paySign 计算** |
签名串通用格式:
```
appId或小程序 appID\n
时间戳\n
随机字符串\n
prepay_id 或 prepay_id=<value>\n
```
JSAPI / 小程序 示例:
```
wxYOUR_APPID\n
1554208460\n
593BEC0C930BF1AFEB40B4A08C8FB242\n
prepay_id=wxYOUR_PREPAY_ID\n
```
### 医保自费混合支付JSAPI / 小程序)
**结论**:服务端 paySign **算法、4 行签名串、商户 API 证书私钥**与普通 JSAPI/小程序完全一致;只有前端调起方法、入参多一个 `mixTradeNo`、客户端版本要求不同。
`mixTradeNo` = 服务端调【医保自费混合收款下单 `POST /v3/med-ins/orders`】后应答的 `mix_trade_no`,透传给前端,**不入 paySign 签名**。
| 差异维度 | 普通 JSAPI / 小程序 | 医保自费混合 |
| ---- | ---- | ---- |
| JSAPI 调起 | `WeixinJSBridge.invoke('chooseWXPay', ...)` | `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)` |
| 小程序调起 | `wx.requestPayment(...)` | `wx.requestMedicalInsurancePay(...)` |
| `wx.config``jsApiList`(仅 JSAPI 需要) | `['chooseWXPay']` | `['requestMedicalInsurancePay']` |
| 入参字段 | `appid` + `timeStamp` / `nonceStr` / `package` / `signType` / `paySign` | 同左 + **`mixTradeNo`**`mix_pay_type=INSURANCE_ONLY`(纯医保)时除 `appid` + `mixTradeNo` 外 5 个支付字段可全省 |
| 客户端版本 | 普遍支持 | iOS / Android **≥ 8.0.44**;鸿蒙 **≥ 8.0.13** |
| 回调 errMsg | `chooseWXPay:ok` / `fail` / `cancel` | `requestMedicalInsurancePay:ok` / `fail`**无 cancel** |
入参示例:
```
WeixinJSBridge.invoke('requestMedicalInsurancePay', {
appid: 'wxYOUR_APPID',
timeStamp: '1414561699',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wxYOUR_PREPAY_ID',
signType: 'RSA',
paySign: '<与普通 JSAPI 同一算法算出的 4 行签名>',
mixTradeNo: '<服务端 mix_trade_no>'
});
```
**调起支付高频踩雷**
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. **医保自费混合调起**如适用paySign 签名串与普通 JSAPI 完全一致(`mixTradeNo` 不入签名);客户端方法用 `requestMedicalInsurancePay` 而非 `chooseWXPay`;纯医保单可省略 paySign 相关字段
11. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑

View File

@@ -0,0 +1,211 @@
package main
import (
"bytes"
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// 服务商医保自费混合收款下单(同时适用于服务商模式与间连模式)
//
// 与商户版的差异:
// - 必传 sub_mchid + sub_appid医疗机构商户号 + AppID
// - openid 与 sub_openid 二选一:
// openid → 调起时用 appid服务商 AppID
// sub_openid → 调起时用 sub_appid医疗机构 AppID
// - 签名仍使用服务商 API 证书私钥Wechatpay-Serial 仍是服务商微信支付公钥 ID
func main() {
config, err := wxpay_utility.CreateMchConfig(
"1900000100", // 服务商商户号
"YOUR_CERT_SERIAL_NO", // 服务商 API 证书序列号
"/path/to/apiclient_key.pem", // 服务商 API 证书私钥路径
"YOUR_PUB_KEY_ID", // 服务商微信支付公钥 ID
"/path/to/wxp_pub.pem", // 服务商微信支付公钥路径
)
if err != nil {
fmt.Println(err)
return
}
encryptedName, _ := wxpay_utility.EncryptOAEPWithPublicKey("张三", config.WechatPayPublicKey())
encryptedIdDigest, _ := wxpay_utility.EncryptOAEPWithPublicKey("09eb26e839ff3a2e3980352ae45ef09e", config.WechatPayPublicKey())
request := &CreateOrderRequest{
MixPayType: wxpay_utility.String("CASH_AND_INSURANCE"),
OrderType: wxpay_utility.String("REG_PAY"),
Appid: wxpay_utility.String("wxYOUR_APPID"),
SubAppid: wxpay_utility.String("wxYOUR_SUB_APPID"),
SubMchid: wxpay_utility.String("1900000109"),
SubOpenid: wxpay_utility.String("oYOUR_OPENID_EXAMPLE"),
Payer: &PersonIdentification{
Name: wxpay_utility.String(encryptedName),
IdDigest: wxpay_utility.String(encryptedIdDigest),
CardType: wxpay_utility.String("ID_CARD"),
},
PayForRelatives: wxpay_utility.Bool(false),
OutTradeNo: wxpay_utility.String("202204022005169952975171534816"),
SerialNo: wxpay_utility.String("1217752501201"),
PayOrderId: wxpay_utility.String("ORD530100202204022006350000021"),
PayAuthNo: wxpay_utility.String("AUTH530100202204022006310000034"),
GeoLocation: wxpay_utility.String("102.682296,25.054260"),
CityId: wxpay_utility.String("530100"),
MedInstName: wxpay_utility.String("北大医院"),
MedInstNo: wxpay_utility.String("1217752501201407033233368318"),
MedInsOrderCreateTime: wxpay_utility.String("2015-05-20T13:29:35+08:00"),
TotalFee: wxpay_utility.Int64(202000),
MedInsGovFee: wxpay_utility.Int64(100000),
MedInsSelfFee: wxpay_utility.Int64(45000),
MedInsOtherFee: wxpay_utility.Int64(5000),
MedInsCashFee: wxpay_utility.Int64(50000),
WechatPayCashFee: wxpay_utility.Int64(42000),
CashAddDetail: []CashAddEntity{{
CashAddFee: wxpay_utility.Int64(2000),
CashAddType: wxpay_utility.String("FREIGHT"),
}},
CashReduceDetail: []CashReduceEntity{{
CashReduceFee: wxpay_utility.Int64(10000),
CashReduceType: wxpay_utility.String("DEFAULT_REDUCE_TYPE"),
}},
CallbackUrl: wxpay_utility.String("https://www.weixin.qq.com/wxpay/pay.php"),
PrepayId: wxpay_utility.String("wxYOUR_PREPAY_ID"),
MedInsTestEnv: wxpay_utility.Bool(false),
}
response, err := CreateOrder(config, request)
if err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Printf("请求成功: %+v\n", response)
}
func CreateOrder(config *wxpay_utility.MchConfig, request *CreateOrderRequest) (response *OrderEntity, err error) {
const (
host = "https://api.mch.weixin.qq.com"
method = "POST"
path = "/v3/med-ins/orders"
)
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 := &OrderEntity{}
if err := json.Unmarshal(respBody, response); err != nil {
return nil, err
}
return response, nil
}
return nil, wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type CreateOrderRequest struct {
MixPayType *string `json:"mix_pay_type,omitempty"`
OrderType *string `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
SubAppid *string `json:"sub_appid,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
Openid *string `json:"openid,omitempty"`
SubOpenid *string `json:"sub_openid,omitempty"`
Payer *PersonIdentification `json:"payer,omitempty"`
PayForRelatives *bool `json:"pay_for_relatives,omitempty"`
Relative *PersonIdentification `json:"relative,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
SerialNo *string `json:"serial_no,omitempty"`
PayOrderId *string `json:"pay_order_id,omitempty"`
PayAuthNo *string `json:"pay_auth_no,omitempty"`
GeoLocation *string `json:"geo_location,omitempty"`
CityId *string `json:"city_id,omitempty"`
MedInstName *string `json:"med_inst_name,omitempty"`
MedInstNo *string `json:"med_inst_no,omitempty"`
MedInsOrderCreateTime *string `json:"med_ins_order_create_time,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
CashAddDetail []CashAddEntity `json:"cash_add_detail,omitempty"`
CashReduceDetail []CashReduceEntity `json:"cash_reduce_detail,omitempty"`
CallbackUrl *string `json:"callback_url,omitempty"`
PrepayId *string `json:"prepay_id,omitempty"`
PassthroughRequestContent *string `json:"passthrough_request_content,omitempty"`
Extends *string `json:"extends,omitempty"`
Attach *string `json:"attach,omitempty"`
ChannelNo *string `json:"channel_no,omitempty"`
MedInsTestEnv *bool `json:"med_ins_test_env,omitempty"`
}
type OrderEntity struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MixPayStatus *string `json:"mix_pay_status,omitempty"`
SelfPayStatus *string `json:"self_pay_status,omitempty"`
MedInsPayStatus *string `json:"med_ins_pay_status,omitempty"`
PaidTime *string `json:"paid_time,omitempty"`
MixPayType *string `json:"mix_pay_type,omitempty"`
OrderType *string `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
SubAppid *string `json:"sub_appid,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
Openid *string `json:"openid,omitempty"`
SubOpenid *string `json:"sub_openid,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
CallbackUrl *string `json:"callback_url,omitempty"`
PrepayId *string `json:"prepay_id,omitempty"`
}
type PersonIdentification struct {
Name *string `json:"name,omitempty"`
IdDigest *string `json:"id_digest,omitempty"`
CardType *string `json:"card_type,omitempty"`
}
type CashAddEntity struct {
CashAddFee *int64 `json:"cash_add_fee,omitempty"`
CashAddType *string `json:"cash_add_type,omitempty"`
}
type CashReduceEntity struct {
CashReduceFee *int64 `json:"cash_reduce_fee,omitempty"`
CashReduceType *string `json:"cash_reduce_type,omitempty"`
}

View File

@@ -0,0 +1,121 @@
package main
import (
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func main() {
config, err := wxpay_utility.CreateMchConfig(
"1900000100",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem",
)
if err != nil {
fmt.Println(err)
return
}
request := &QueryRequest{
MixTradeNo: wxpay_utility.String("202204022005169952975171534816"),
SubMchid: wxpay_utility.String("1900000109"),
}
response, err := QueryByMixTradeNo(config, request)
if err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Printf("请求成功: %+v\n", response)
}
func QueryByMixTradeNo(config *wxpay_utility.MchConfig, request *QueryRequest) (response *OrderEntity, err error) {
const (
host = "https://api.mch.weixin.qq.com"
method = "GET"
path = "/v3/med-ins/orders/mix-trade-no/{mix_trade_no}"
)
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
if err != nil {
return nil, err
}
reqUrl.Path = strings.Replace(reqUrl.Path, "{mix_trade_no}", url.PathEscape(*request.MixTradeNo), -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 {
err = wxpay_utility.ValidateResponse(config.WechatPayPublicKeyId(), config.WechatPayPublicKey(), &httpResponse.Header, respBody)
if err != nil {
return nil, err
}
response := &OrderEntity{}
if err := json.Unmarshal(respBody, response); err != nil {
return nil, err
}
return response, nil
}
return nil, wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type QueryRequest struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
}
func (o *QueryRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(struct{}{})
}
type OrderEntity struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MixPayStatus *string `json:"mix_pay_status,omitempty"`
SelfPayStatus *string `json:"self_pay_status,omitempty"`
MedInsPayStatus *string `json:"med_ins_pay_status,omitempty"`
PaidTime *string `json:"paid_time,omitempty"`
MixPayType *string `json:"mix_pay_type,omitempty"`
OrderType *string `json:"order_type,omitempty"`
Appid *string `json:"appid,omitempty"`
SubAppid *string `json:"sub_appid,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
Openid *string `json:"openid,omitempty"`
SubOpenid *string `json:"sub_openid,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsGovFee *int64 `json:"med_ins_gov_fee,omitempty"`
MedInsSelfFee *int64 `json:"med_ins_self_fee,omitempty"`
MedInsOtherFee *int64 `json:"med_ins_other_fee,omitempty"`
MedInsCashFee *int64 `json:"med_ins_cash_fee,omitempty"`
WechatPayCashFee *int64 `json:"wechat_pay_cash_fee,omitempty"`
MedInsFailReason *string `json:"med_ins_fail_reason,omitempty"`
}

View File

@@ -0,0 +1,112 @@
package main
import (
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
func main() {
config, err := wxpay_utility.CreateMchConfig(
"1900000100",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem",
)
if err != nil {
fmt.Println(err)
return
}
request := &QueryRequest{
OutTradeNo: wxpay_utility.String("202204022005169952975171534816"),
SubMchid: wxpay_utility.String("1900000109"),
}
response, err := QueryByOutTradeNo(config, request)
if err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Printf("请求成功: %+v\n", response)
}
func QueryByOutTradeNo(config *wxpay_utility.MchConfig, request *QueryRequest) (response *OrderEntity, err error) {
const (
host = "https://api.mch.weixin.qq.com"
method = "GET"
path = "/v3/med-ins/orders/out-trade-no/{out_trade_no}"
)
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
if err != nil {
return nil, err
}
reqUrl.Path = strings.Replace(reqUrl.Path, "{out_trade_no}", url.PathEscape(*request.OutTradeNo), -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 {
err = wxpay_utility.ValidateResponse(config.WechatPayPublicKeyId(), config.WechatPayPublicKey(), &httpResponse.Header, respBody)
if err != nil {
return nil, err
}
response := &OrderEntity{}
if err := json.Unmarshal(respBody, response); err != nil {
return nil, err
}
return response, nil
}
return nil, wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type QueryRequest struct {
OutTradeNo *string `json:"out_trade_no,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
}
func (o *QueryRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(struct{}{})
}
type OrderEntity struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
MixPayStatus *string `json:"mix_pay_status,omitempty"`
SelfPayStatus *string `json:"self_pay_status,omitempty"`
MedInsPayStatus *string `json:"med_ins_pay_status,omitempty"`
PaidTime *string `json:"paid_time,omitempty"`
Appid *string `json:"appid,omitempty"`
SubAppid *string `json:"sub_appid,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
OutTradeNo *string `json:"out_trade_no,omitempty"`
TotalFee *int64 `json:"total_fee,omitempty"`
MedInsFailReason *string `json:"med_ins_fail_reason,omitempty"`
}

View File

@@ -0,0 +1,114 @@
package main
import (
"bytes"
"demo/wxpay_utility"
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// 服务商医保退款通知 —— 服务商代子商户告知微信医保侧已发生退款
// 与商户版差异:请求体必须额外传入 sub_mchid医疗机构商户号
func main() {
config, err := wxpay_utility.CreateMchConfig(
"1900000100",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem",
)
if err != nil {
fmt.Println(err)
return
}
request := &NotifyRefundRequest{
MixTradeNo: wxpay_utility.String("202204022005169952975171534816"),
SubMchid: wxpay_utility.String("1900000109"),
MedRefundTotalFee: wxpay_utility.Int64(45000),
MedRefundGovFee: wxpay_utility.Int64(45000),
MedRefundSelfFee: wxpay_utility.Int64(0),
MedRefundOtherFee: wxpay_utility.Int64(0),
RefundTime: wxpay_utility.String("2015-05-20T13:29:35+08:00"),
OutRefundNo: wxpay_utility.String("R202204022005169952975171534816"),
}
if err := NotifyRefund(config, request); err != nil {
fmt.Printf("请求失败: %+v\n", err)
return
}
fmt.Println("医保退款通知成功")
}
func NotifyRefund(config *wxpay_utility.MchConfig, request *NotifyRefundRequest) error {
const (
host = "https://api.mch.weixin.qq.com"
method = "POST"
path = "/v3/med-ins/refunds/notify"
)
reqUrl, err := url.Parse(fmt.Sprintf("%s%s", host, path))
if err != nil {
return err
}
query := reqUrl.Query()
if request.MixTradeNo != nil {
query.Add("mix_trade_no", *request.MixTradeNo)
}
reqUrl.RawQuery = query.Encode()
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 {
return wxpay_utility.ValidateResponse(config.WechatPayPublicKeyId(), config.WechatPayPublicKey(), &httpResponse.Header, respBody)
}
return wxpay_utility.NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
}
type NotifyRefundRequest struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
SubMchid *string `json:"sub_mchid,omitempty"`
MedRefundTotalFee *int64 `json:"med_refund_total_fee,omitempty"`
MedRefundGovFee *int64 `json:"med_refund_gov_fee,omitempty"`
MedRefundSelfFee *int64 `json:"med_refund_self_fee,omitempty"`
MedRefundOtherFee *int64 `json:"med_refund_other_fee,omitempty"`
RefundTime *string `json:"refund_time,omitempty"`
OutRefundNo *string `json:"out_refund_no,omitempty"`
}
func (o *NotifyRefundRequest) MarshalJSON() ([]byte, error) {
type Alias NotifyRefundRequest
a := &struct {
MixTradeNo *string `json:"mix_trade_no,omitempty"`
*Alias
}{
MixTradeNo: nil,
Alias: (*Alias)(o),
}
return json.Marshal(a)
}

View File

@@ -0,0 +1,109 @@
# 医保混合收款成功通知说明(服务商 - Go
> 内容与 [`Java/6-回调通知/医保混合收款成功通知说明.md`](../../Java/6-回调通知/医保混合收款成功通知说明.md) 完全一致;本副本仅为 Go 项目按目录约定查找方便而存在。
> 来源:[服务商医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/partner/4012165722.md) / [间连医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/partner/4018303231.md)(结构一致)
> 通用解密 / 验签 / 回包流程参考 [📄 ../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
## 一、回调时机
当订单 `mix_pay_status = MIX_PAY_SUCCESS` 时,微信支付通过 POST 向服务商医保下单接口中传入的 `callback_url` 发送通知。
| 事件类型 | 含义 |
| --- | --- |
| `MEDICAL_INSURANCE.SUCCESS` | 医保混合收款成功 |
> ‼️ 多个子商户共用同一 `notify_url`**必须**按解密后的 `sub_mchid` 路由业务,否则会出现串单。
> ‼️ 微信仅在订单达到 `MIX_PAY_SUCCESS` 时回调一次。若 5 秒内未收到 200/204将按指数退避重试30 秒后**不再重试**。
> ‼️ **必须**对 `MIX_PAY_CREATED` 状态订单做主动查询兜底(`GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}?sub_mchid=...`)。
## 二、HTTP 头
与商户场景一致:`Wechatpay-Serial` / `Wechatpay-Signature` / `Wechatpay-Timestamp` / `Wechatpay-Nonce`,验签使用**服务商**的微信支付公钥(推荐)或微信支付平台证书。
> ‼️ `Wechatpay-Serial` 以 `PUB_KEY_ID_` 开头则用微信支付公钥验签,否则用平台证书验签。
## 三、报文结构
```json
{
"id": "EV-2018022511223320873",
"create_time": "2020-03-26T10:43:39+08:00",
"event_type": "MEDICAL_INSURANCE.SUCCESS",
"resource_type": "encrypt-resource",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "...",
"nonce": "...",
"associated_data": "..."
}
}
```
## 四、resource.ciphertext 解密后的业务字段
> 算法:`AEAD_AES_256_GCM`,密钥:服务商 APIv3 密钥32 字节)
服务商场景下相比商户场景,多出 `sub_mchid` / `sub_appid` / `sub_openid` 三个字段:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `mix_trade_no` | string(32) | 医保自费混合订单号 |
| `mix_pay_status` | string | `MIX_PAY_SUCCESS` |
| `self_pay_status` | string | `SELF_PAY_SUCCESS` / `NO_SELF_PAY` |
| `med_ins_pay_status` | string | `MED_INS_PAY_SUCCESS` / `NO_MED_INS_PAY` |
| `paid_time` | string(64) | 支付完成时间RFC3339 |
| `passthrough_response_content` | string(2048) | 医保局透传给医疗机构的内容 |
| `mix_pay_type` | string | `CASH_ONLY` / `INSURANCE_ONLY` / `CASH_AND_INSURANCE` |
| `order_type` | string | `REG_PAY` 等,详见 SKILL 总览 |
| `appid` | string(32) | **服务商**公众号 / 小程序 AppID |
| `sub_appid` | string(32) | **医疗机构**公众号 / 小程序 AppID |
| `sub_mchid` | string(32) | **医疗机构**商户号(路由依据) |
| `openid` | string(128) | 用户在服务商 AppID 下的 openid与 sub_openid 二选一) |
| `sub_openid` | string(128) | 用户在医疗机构 AppID 下的 openid |
| `pay_for_relatives` | bool | 是否代亲属支付 |
| `out_trade_no` | string(64) | 商户订单号 |
| `serial_no` | string(20) | 医疗机构订单号 |
| `pay_order_id` | string(64) | 医保局支付单 ID |
| `pay_auth_no` | string(40) | 医保局支付授权码 |
| `geo_location` | string(40) | 用户经纬度 |
| `city_id` | string(8) | 城市 ID |
| `med_inst_name` | string(128) | 医疗机构名称 |
| `med_inst_no` | string(32) | 医疗机构编码 |
| `med_ins_order_create_time` | string(64) | 医保下单时间 |
| `total_fee` | uint64 | 订单总金额(分) |
| `med_ins_gov_fee` / `med_ins_self_fee` / `med_ins_other_fee` / `med_ins_cash_fee` / `wechat_pay_cash_fee` | uint64 | 各分项金额,单位分 |
| `cash_add_detail[].cash_add_fee` / `cash_add_type` | uint64 / string | 现金补充明细 |
| `cash_reduce_detail[].cash_reduce_fee` / `cash_reduce_type` | uint64 / string | 现金减免明细 |
| `callback_url` | string(256) | 回调通知 URL |
| `prepay_id` | string(64) | 自费预下单 ID |
| `attach` | string(128) | 商户自定义透传 |
| `channel_no` | string(32) | 渠道号 |
| `med_ins_test_env` | bool | 是否医保局测试环境 |
## 五、应答规范
| 场景 | HTTP 状态码 | 应答体 |
| --- | --- | --- |
| 验签通过 + 业务处理成功 | 200 / 204 | 无包体 |
| 验签失败 / 业务处理失败 | 4XX / 5XX | `{"code": "FAIL", "message": "失败"}` |
## 六、服务商必备:按 sub_mchid 路由
```text
1) 接收回调 → 验签 → 解密
2) 从解密后的 resource 中读取 sub_mchid
3) 根据 sub_mchid 查找对应的业务处理逻辑(如调用医院 HIS 回写订单)
4) 幂等处理:以 (sub_mchid, mix_trade_no) 联合主键检查
5) 返回 200/204
```
> ‼️ **禁止**仅按 `out_trade_no` / `mix_trade_no` 路由:不同子商户的 `out_trade_no` 可能重复,必须复合 `sub_mchid` 才能唯一定位订单。
## 七、与查单的关系
| 场景 | 推荐做法 |
| --- | --- |
| 30 秒内收到回调 | 验签 + 解密 + 按 (`sub_mchid`, `mix_trade_no`) 幂等 |
| 30 秒后未收到回调 | 主动调用查单接口(带 `sub_mchid`)确认 |
| 解密 / 验签失败 | 应答 4XX/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,218 @@
package com.java.demo;
import com.java.utils.WXPayUtility;
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.List;
/**
* 服务商医保自费混合收款下单(同时适用于服务商模式与间连模式)
*
* 与商户版的差异:
* - 必传 sub_mchid + sub_appid医疗机构商户号 + AppID
* - openid 与 sub_openid 二选一:
* openid → 调起时用 appid服务商 AppID
* sub_openid → 调起时用 sub_appid医疗机构 AppID
* - 签名仍使用服务商 API 证书私钥Wechatpay-Serial 仍是服务商微信支付公钥 ID
*/
public class CreatePartnerMedInsOrder {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "POST";
private static String PATH = "/v3/med-ins/orders";
public static void main(String[] args) {
CreatePartnerMedInsOrder client = new CreatePartnerMedInsOrder(
"1900000100", // 服务商商户号
"YOUR_CERT_SERIAL_NO", // 服务商 API 证书序列号
"/path/to/apiclient_key.pem", // 服务商 API 证书私钥路径
"YOUR_PUB_KEY_ID", // 服务商微信支付公钥 ID
"/path/to/wxp_pub.pem" // 服务商微信支付公钥路径
);
CreateOrderRequest request = new CreateOrderRequest();
request.mixPayType = "CASH_AND_INSURANCE";
request.orderType = "REG_PAY";
request.appid = "wxYOUR_APPID"; // 服务商 AppID
request.subAppid = "wxYOUR_SUB_APPID"; // 医疗机构 AppID
request.subMchid = "1900000109"; // 医疗机构商户号
request.subOpenid = "oYOUR_OPENID_EXAMPLE"; // 与 openid 二选一
request.payer = new PersonIdentification();
request.payer.name = client.encrypt("张三");
request.payer.idDigest = client.encrypt("09eb26e839ff3a2e3980352ae45ef09e");
request.payer.cardType = "ID_CARD";
request.payForRelatives = false;
request.outTradeNo = "202204022005169952975171534816";
request.serialNo = "1217752501201";
request.payOrderId = "ORD530100202204022006350000021";
request.payAuthNo = "AUTH530100202204022006310000034";
request.geoLocation = "102.682296,25.054260";
request.cityId = "530100";
request.medInstName = "北大医院";
request.medInstNo = "1217752501201407033233368318";
request.medInsOrderCreateTime = "2015-05-20T13:29:35+08:00";
request.totalFee = 202000L;
request.medInsGovFee = 100000L;
request.medInsSelfFee = 45000L;
request.medInsOtherFee = 5000L;
request.medInsCashFee = 50000L;
request.wechatPayCashFee = 42000L;
request.cashAddDetail = new ArrayList<>();
{
CashAddEntity item = new CashAddEntity();
item.cashAddFee = 2000L;
item.cashAddType = "FREIGHT";
request.cashAddDetail.add(item);
}
request.cashReduceDetail = new ArrayList<>();
{
CashReduceEntity item = new CashReduceEntity();
item.cashReduceFee = 10000L;
item.cashReduceType = "DEFAULT_REDUCE_TYPE";
request.cashReduceDetail.add(item);
}
request.callbackUrl = "https://www.weixin.qq.com/wxpay/pay.php";
request.prepayId = "wxYOUR_PREPAY_ID";
request.medInsTestEnv = false;
try {
OrderEntity response = client.run(request);
System.out.println(response);
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public OrderEntity run(CreateOrderRequest 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, OrderEntity.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 CreatePartnerMedInsOrder(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);
}
/** 加密敏感字段payer.name / payer.id_digest / relative.name / relative.id_digest 必须加密后再传) */
public String encrypt(String plainText) {
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
}
public static class CreateOrderRequest {
@SerializedName("mix_pay_type") public String mixPayType;
@SerializedName("order_type") public String orderType;
@SerializedName("appid") public String appid;
@SerializedName("sub_appid") public String subAppid;
@SerializedName("sub_mchid") public String subMchid;
@SerializedName("openid") public String openid;
@SerializedName("sub_openid") public String subOpenid;
@SerializedName("payer") public PersonIdentification payer;
@SerializedName("pay_for_relatives") public Boolean payForRelatives;
@SerializedName("relative") public PersonIdentification relative;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("serial_no") public String serialNo;
@SerializedName("pay_order_id") public String payOrderId;
@SerializedName("pay_auth_no") public String payAuthNo;
@SerializedName("geo_location") public String geoLocation;
@SerializedName("city_id") public String cityId;
@SerializedName("med_inst_name") public String medInstName;
@SerializedName("med_inst_no") public String medInstNo;
@SerializedName("med_ins_order_create_time") public String medInsOrderCreateTime;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("cash_add_detail") public List<CashAddEntity> cashAddDetail;
@SerializedName("cash_reduce_detail") public List<CashReduceEntity> cashReduceDetail;
@SerializedName("callback_url") public String callbackUrl;
@SerializedName("prepay_id") public String prepayId;
@SerializedName("passthrough_request_content") public String passthroughRequestContent;
@SerializedName("extends") public String _extends;
@SerializedName("attach") public String attach;
@SerializedName("channel_no") public String channelNo;
@SerializedName("med_ins_test_env") public Boolean medInsTestEnv;
}
public static class OrderEntity {
@SerializedName("mix_trade_no") public String mixTradeNo;
@SerializedName("mix_pay_status") public String mixPayStatus;
@SerializedName("self_pay_status") public String selfPayStatus;
@SerializedName("med_ins_pay_status") public String medInsPayStatus;
@SerializedName("paid_time") public String paidTime;
@SerializedName("mix_pay_type") public String mixPayType;
@SerializedName("order_type") public String orderType;
@SerializedName("appid") public String appid;
@SerializedName("sub_appid") public String subAppid;
@SerializedName("sub_mchid") public String subMchid;
@SerializedName("openid") public String openid;
@SerializedName("sub_openid") public String subOpenid;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("callback_url") public String callbackUrl;
@SerializedName("prepay_id") public String prepayId;
}
public static class PersonIdentification {
@SerializedName("name") public String name;
@SerializedName("id_digest") public String idDigest;
@SerializedName("card_type") public String cardType;
}
public static class CashAddEntity {
@SerializedName("cash_add_fee") public Long cashAddFee;
@SerializedName("cash_add_type") public String cashAddType;
}
public static class CashReduceEntity {
@SerializedName("cash_reduce_fee") public Long cashReduceFee;
@SerializedName("cash_reduce_type") public String cashReduceType;
}
}

View File

@@ -0,0 +1,123 @@
package com.java.demo;
import com.java.utils.WXPayUtility;
import com.google.gson.annotations.SerializedName;
import com.google.gson.annotations.Expose;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.HashMap;
import java.util.Map;
/**
* 服务商使用混合订单号查单Query 必传 sub_mchid
*/
public class QueryByMixTradeNo {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "GET";
private static String PATH = "/v3/med-ins/orders/mix-trade-no/{mix_trade_no}";
public static void main(String[] args) {
QueryByMixTradeNo client = new QueryByMixTradeNo(
"1900000100",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem"
);
QueryRequest request = new QueryRequest();
request.mixTradeNo = "202204022005169952975171534816";
request.subMchid = "1900000109";
try {
OrderEntity response = client.run(request);
System.out.println(response);
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public OrderEntity run(QueryRequest request) {
String uri = PATH.replace("{mix_trade_no}", WXPayUtility.urlEncode(request.mixTradeNo));
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();
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, OrderEntity.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 QueryByMixTradeNo(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 QueryRequest {
@SerializedName("mix_trade_no")
@Expose(serialize = false)
public String mixTradeNo;
@SerializedName("sub_mchid")
@Expose(serialize = false)
public String subMchid;
}
/** 应答结构与商户版相同,新增 sub_mchid / sub_appid / sub_openid */
public static class OrderEntity {
@SerializedName("mix_trade_no") public String mixTradeNo;
@SerializedName("mix_pay_status") public String mixPayStatus;
@SerializedName("self_pay_status") public String selfPayStatus;
@SerializedName("med_ins_pay_status") public String medInsPayStatus;
@SerializedName("paid_time") public String paidTime;
@SerializedName("mix_pay_type") public String mixPayType;
@SerializedName("order_type") public String orderType;
@SerializedName("appid") public String appid;
@SerializedName("sub_appid") public String subAppid;
@SerializedName("sub_mchid") public String subMchid;
@SerializedName("openid") public String openid;
@SerializedName("sub_openid") public String subOpenid;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_gov_fee") public Long medInsGovFee;
@SerializedName("med_ins_self_fee") public Long medInsSelfFee;
@SerializedName("med_ins_other_fee") public Long medInsOtherFee;
@SerializedName("med_ins_cash_fee") public Long medInsCashFee;
@SerializedName("wechat_pay_cash_fee") public Long wechatPayCashFee;
@SerializedName("med_ins_fail_reason") public String medInsFailReason;
}
}

View File

@@ -0,0 +1,113 @@
package com.java.demo;
import com.java.utils.WXPayUtility;
import com.google.gson.annotations.SerializedName;
import com.google.gson.annotations.Expose;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.HashMap;
import java.util.Map;
/**
* 服务商使用商户订单号查单Query 必传 sub_mchid
*/
public class QueryByOutTradeNo {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "GET";
private static String PATH = "/v3/med-ins/orders/out-trade-no/{out_trade_no}";
public static void main(String[] args) {
QueryByOutTradeNo client = new QueryByOutTradeNo(
"1900000100",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem"
);
QueryRequest request = new QueryRequest();
request.outTradeNo = "202204022005169952975171534816";
request.subMchid = "1900000109";
try {
OrderEntity response = client.run(request);
System.out.println(response);
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public OrderEntity run(QueryRequest request) {
String uri = PATH.replace("{out_trade_no}", WXPayUtility.urlEncode(request.outTradeNo));
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();
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, OrderEntity.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 QueryByOutTradeNo(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 QueryRequest {
@SerializedName("out_trade_no")
@Expose(serialize = false)
public String outTradeNo;
@SerializedName("sub_mchid")
@Expose(serialize = false)
public String subMchid;
}
public static class OrderEntity {
@SerializedName("mix_trade_no") public String mixTradeNo;
@SerializedName("mix_pay_status") public String mixPayStatus;
@SerializedName("self_pay_status") public String selfPayStatus;
@SerializedName("med_ins_pay_status") public String medInsPayStatus;
@SerializedName("paid_time") public String paidTime;
@SerializedName("appid") public String appid;
@SerializedName("sub_appid") public String subAppid;
@SerializedName("sub_mchid") public String subMchid;
@SerializedName("out_trade_no") public String outTradeNo;
@SerializedName("total_fee") public Long totalFee;
@SerializedName("med_ins_fail_reason") public String medInsFailReason;
}
}

View File

@@ -0,0 +1,116 @@
package com.java.demo;
import com.java.utils.WXPayUtility;
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.HashMap;
import java.util.Map;
/**
* 服务商医保退款通知 —— 服务商代子商户告知微信医保侧已发生退款
*
* 与商户版差异:请求体必须额外传入 sub_mchid医疗机构商户号
*/
public class NotifyPartnerMedInsRefund {
private static String HOST = "https://api.mch.weixin.qq.com";
private static String METHOD = "POST";
private static String PATH = "/v3/med-ins/refunds/notify";
public static void main(String[] args) {
NotifyPartnerMedInsRefund client = new NotifyPartnerMedInsRefund(
"1900000100",
"YOUR_CERT_SERIAL_NO",
"/path/to/apiclient_key.pem",
"YOUR_PUB_KEY_ID",
"/path/to/wxp_pub.pem"
);
NotifyRefundRequest request = new NotifyRefundRequest();
request.mixTradeNo = "202204022005169952975171534816";
request.subMchid = "1900000109";
request.medRefundTotalFee = 45000L;
request.medRefundGovFee = 45000L;
request.medRefundSelfFee = 0L;
request.medRefundOtherFee = 0L;
request.refundTime = "2015-05-20T13:29:35+08:00";
request.outRefundNo = "R202204022005169952975171534816";
try {
client.run(request);
System.out.println("医保退款通知成功");
} catch (WXPayUtility.ApiException e) {
e.printStackTrace();
}
}
public void run(NotifyRefundRequest request) {
String uri = PATH;
Map<String, Object> args = new HashMap<>();
args.put("mix_trade_no", request.mixTradeNo);
String queryString = WXPayUtility.urlEncode(args);
if (!queryString.isEmpty()) {
uri = uri + "?" + queryString;
}
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 NotifyPartnerMedInsRefund(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 NotifyRefundRequest {
@SerializedName("mix_trade_no")
@Expose(serialize = false)
public String mixTradeNo;
@SerializedName("sub_mchid") public String subMchid;
@SerializedName("med_refund_total_fee") public Long medRefundTotalFee;
@SerializedName("med_refund_gov_fee") public Long medRefundGovFee;
@SerializedName("med_refund_self_fee") public Long medRefundSelfFee;
@SerializedName("med_refund_other_fee") public Long medRefundOtherFee;
@SerializedName("refund_time") public String refundTime;
@SerializedName("out_refund_no") public String outRefundNo;
}
}

View File

@@ -0,0 +1,65 @@
# 小程序调起医保支付说明(服务商)
> 来源:[小程序调起医保自费混合支付](https://pay.weixin.qq.com/doc/v3/merchant/4016781545.md)(接口同商户)
## 整体流程
1. 若有自费金额:调用 [服务商 JSAPI 自费下单](https://pay.weixin.qq.com/doc/v3/partner/4012692411.md) 拿到自费 `prepay_id`,并按 [JSAPI 调起规则](https://pay.weixin.qq.com/doc/v3/partner/4012692421.md) 计算 `timeStamp` / `nonceStr` / `package` / `signType` / `paySign`
2. 调用服务商医保下单接口 `POST /v3/med-ins/orders`(带 `sub_mchid` / `sub_appid`)拿到 `mix_trade_no`
3. 在小程序中调用 `wx.requestMedicalInsurancePay` 调起医保自费混合支付
4. 用户输入医保电子凭证密码完成支付
5. 小程序通过 `onShow` 调用查单接口(带 `sub_mchid`)确认结果
## 兼容性
- iOS / Android 微信版本 ≥ 8.0.44
- HarmonyOS 微信版本 ≥ 8.0.13
## 服务商场景的 AppID 选择
下单时通过 `openid` / `sub_openid` 决定调起所用 AppID**调起方与下单方必须一致**
| 下单时传 | 调起方需使用 | 自费 JSAPI 下单 `appid` 同样使用 |
| --- | --- | --- |
| `openid`(用户在服务商 AppID 下的 openid | 服务商小程序的 `appid` | 服务商 `appid` |
| `sub_openid`(用户在医疗机构 AppID 下的 openid | 医疗机构小程序的 `sub_appid` | 子商户 `sub_appid` |
> ‼️ 错配会触发 `PARAM_ERROR: 请确认AppID与OpenID是否正确并确保OpenID是从对应的AppID下获取的`。
## 调用参数
| 参数 | 类型 | 必填 | 描述 |
| --- | --- | --- | --- |
| `mixTradeNo` | string(256) | 是 | 服务商医保下单接口返回的 `mix_trade_no` |
| `timeStamp` | string(32) | 有自费时必填 | 时间戳,秒级 |
| `nonceStr` | string(32) | 有自费时必填 | 随机串 ≤32 位 |
| `package` | string(128) | 有自费时必填 | `prepay_id=...` |
| `signType` | string(32) | 有自费时必填 | 固定 `RSA` |
| `paySign` | string(256) | 有自费时必填 | **服务商**API 证书私钥 RSA-SHA256 签名(不是子商户证书) |
> ‼️ `paySign` 必须使用**服务商**的 API 证书私钥(与服务商自费 JSAPI 下单一致),子商户没有独立的 API 证书。
## 调用示例
```javascript
wx.requestMedicalInsurancePay({
mixTradeNo: '1217752501201407033233368318',
timeStamp: '1414561699',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wxYOUR_PREPAY_ID',
signType: 'RSA',
paySign: 'oR9d8Puhn...',
success(res) {
// 调起结束(不代表支付成功),调用查单接口(带 sub_mchid确认
},
fail(res) {
// 处理 fail引导用户重试
}
});
```
## 常见问题
- **AppID 与 sub_appid 错乱**:见上方表格
- **签名失败**:用服务商证书私钥,不是子商户的(子商户无独立 API 证书)
- **mix_trade_no 缺失 sub_mchid**:调起本身不需要 sub_mchid但**查单**和**回调路由**必须带

View File

@@ -0,0 +1,82 @@
# JSAPI 调起医保支付说明(服务商)
> 来源:[JSAPI 调起医保自费混合支付](https://pay.weixin.qq.com/doc/v3/merchant/4016781549.md)(接口同商户)
## 整体流程
1. 若有自费金额:调用 [服务商 JSAPI 自费下单](https://pay.weixin.qq.com/doc/v3/partner/4012692411.md) 拿到自费 `prepay_id`,按 [JSAPI 调起规则](https://pay.weixin.qq.com/doc/v3/partner/4012692421.md) 计算调起参数
2. 调用服务商医保下单接口 `POST /v3/med-ins/orders`(带 `sub_mchid` / `sub_appid`)拿到 `mix_trade_no`
3. 公众号 H5 页面调用 `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)`
4. 用户输入医保电子凭证密码完成支付
5. H5 调用查单接口(带 `sub_mchid`)确认结果
## 兼容性
- iOS / Android 微信版本 ≥ 8.0.44
- HarmonyOS 微信版本 ≥ 8.0.13
## 调用前准备
通过 [`wx.config`](https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#4) 注入权限,`jsApiList` 必须包含 `requestMedicalInsurancePay`。注入用的 `appId``signature` **必须**与即将调起所用 `appid` 一致(服务商 H5 用服务商 AppID子商户 H5 用子商户 AppID
## 服务商场景的 AppID 选择
| 下单时传 | 调起 `appid` 取值 |
| --- | --- |
| `openid` | 服务商 AppID |
| `sub_openid` | 子商户 AppID`sub_appid` |
## 调用参数
| 参数 | 类型 | 必填 | 描述 |
| --- | --- | --- | --- |
| `appid` | string(32) | 是 | 与下单一致:传 `openid` 时填服务商 AppID`sub_openid` 时填 `sub_appid` |
| `mixTradeNo` | string(256) | 是 | 服务商医保下单接口返回的 `mix_trade_no` |
| `timeStamp` | string(32) | 有自费时必填 | 时间戳,秒级 |
| `nonceStr` | string(32) | 有自费时必填 | 随机串 ≤32 位 |
| `package` | string(128) | 有自费时必填 | `prepay_id=...` |
| `signType` | string(32) | 有自费时必填 | 固定 `RSA` |
| `paySign` | string(256) | 有自费时必填 | 服务商 API 证书私钥 RSA-SHA256 签名 |
## 调用示例
```html
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script>
wx.config({
appId: 'wxYOUR_APPID',
timestamp: 1414561699,
nonceStr: 'XXXXXXXX',
signature: 'XXXXXXXX',
jsApiList: ['requestMedicalInsurancePay']
});
wx.ready(function () {
WeixinJSBridge.invoke(
'requestMedicalInsurancePay',
{
appid: 'wxYOUR_APPID',
mixTradeNo: '1217752501201407033233368318',
timeStamp: '1414561699',
nonceStr: '5K8264ILTKCH16CQ2502SI8ZNMTM67VS',
package: 'prepay_id=wxYOUR_PREPAY_ID',
signType: 'RSA',
paySign: 'oR9d8Puhn...'
},
function (res) {
if (res.err_msg === 'requestMedicalInsurancePay:ok') {
// 调起结束,调用查单接口(带 sub_mchid确认
} else {
// 调起失败
}
}
);
});
</script>
```
## 常见问题
- **`appid``signature` 不一致**`wx.config` 注入的 `appId` 必须与调起 `appid` 一致
- **签名错误**:用**服务商**API 证书私钥;子商户无独立证书
- **回调路由错乱**:服务端回调中以 `sub_mchid` 路由到对应业务系统

View File

@@ -0,0 +1,107 @@
# 医保混合收款成功通知说明(服务商)
> 来源:[服务商医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/partner/4012165722.md) / [间连医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/partner/4018303231.md)(结构一致)
> 通用解密 / 验签 / 回包流程参考 [📄 ../../../接入指南/回调处理.md](../../../接入指南/回调处理.md)
## 一、回调时机
当订单 `mix_pay_status = MIX_PAY_SUCCESS` 时,微信支付通过 POST 向服务商医保下单接口中传入的 `callback_url` 发送通知。
| 事件类型 | 含义 |
| --- | --- |
| `MEDICAL_INSURANCE.SUCCESS` | 医保混合收款成功 |
> ‼️ 多个子商户共用同一 `notify_url`**必须**按解密后的 `sub_mchid` 路由业务,否则会出现串单。
> ‼️ 微信仅在订单达到 `MIX_PAY_SUCCESS` 时回调一次。若 5 秒内未收到 200/204将按指数退避重试30 秒后**不再重试**。
> ‼️ **必须**对 `MIX_PAY_CREATED` 状态订单做主动查询兜底(`GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}?sub_mchid=...`)。
## 二、HTTP 头
与商户场景一致:`Wechatpay-Serial` / `Wechatpay-Signature` / `Wechatpay-Timestamp` / `Wechatpay-Nonce`,验签使用**服务商**的微信支付公钥(推荐)或微信支付平台证书。
> ‼️ `Wechatpay-Serial` 以 `PUB_KEY_ID_` 开头则用微信支付公钥验签,否则用平台证书验签。
## 三、报文结构
```json
{
"id": "EV-2018022511223320873",
"create_time": "2020-03-26T10:43:39+08:00",
"event_type": "MEDICAL_INSURANCE.SUCCESS",
"resource_type": "encrypt-resource",
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": "...",
"nonce": "...",
"associated_data": "..."
}
}
```
## 四、resource.ciphertext 解密后的业务字段
> 算法:`AEAD_AES_256_GCM`,密钥:服务商 APIv3 密钥32 字节)
服务商场景下相比商户场景,多出 `sub_mchid` / `sub_appid` / `sub_openid` 三个字段:
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `mix_trade_no` | string(32) | 医保自费混合订单号 |
| `mix_pay_status` | string | `MIX_PAY_SUCCESS` |
| `self_pay_status` | string | `SELF_PAY_SUCCESS` / `NO_SELF_PAY` |
| `med_ins_pay_status` | string | `MED_INS_PAY_SUCCESS` / `NO_MED_INS_PAY` |
| `paid_time` | string(64) | 支付完成时间RFC3339 |
| `passthrough_response_content` | string(2048) | 医保局透传给医疗机构的内容 |
| `mix_pay_type` | string | `CASH_ONLY` / `INSURANCE_ONLY` / `CASH_AND_INSURANCE` |
| `order_type` | string | `REG_PAY` 等,详见 SKILL 总览 |
| `appid` | string(32) | **服务商**公众号 / 小程序 AppID |
| `sub_appid` | string(32) | **医疗机构**公众号 / 小程序 AppID |
| `sub_mchid` | string(32) | **医疗机构**商户号(路由依据) |
| `openid` | string(128) | 用户在服务商 AppID 下的 openid与 sub_openid 二选一) |
| `sub_openid` | string(128) | 用户在医疗机构 AppID 下的 openid |
| `pay_for_relatives` | bool | 是否代亲属支付 |
| `out_trade_no` | string(64) | 商户订单号 |
| `serial_no` | string(20) | 医疗机构订单号 |
| `pay_order_id` | string(64) | 医保局支付单 ID |
| `pay_auth_no` | string(40) | 医保局支付授权码 |
| `geo_location` | string(40) | 用户经纬度 |
| `city_id` | string(8) | 城市 ID |
| `med_inst_name` | string(128) | 医疗机构名称 |
| `med_inst_no` | string(32) | 医疗机构编码 |
| `med_ins_order_create_time` | string(64) | 医保下单时间 |
| `total_fee` | uint64 | 订单总金额(分) |
| `med_ins_gov_fee` / `med_ins_self_fee` / `med_ins_other_fee` / `med_ins_cash_fee` / `wechat_pay_cash_fee` | uint64 | 各分项金额,单位分 |
| `cash_add_detail[].cash_add_fee` / `cash_add_type` | uint64 / string | 现金补充明细 |
| `cash_reduce_detail[].cash_reduce_fee` / `cash_reduce_type` | uint64 / string | 现金减免明细 |
| `callback_url` | string(256) | 回调通知 URL |
| `prepay_id` | string(64) | 自费预下单 ID |
| `attach` | string(128) | 商户自定义透传 |
| `channel_no` | string(32) | 渠道号 |
| `med_ins_test_env` | bool | 是否医保局测试环境 |
## 五、应答规范
| 场景 | HTTP 状态码 | 应答体 |
| --- | --- | --- |
| 验签通过 + 业务处理成功 | 200 / 204 | 无包体 |
| 验签失败 / 业务处理失败 | 4XX / 5XX | `{"code": "FAIL", "message": "失败"}` |
## 六、服务商必备:按 sub_mchid 路由
```text
1) 接收回调 → 验签 → 解密
2) 从解密后的 resource 中读取 sub_mchid
3) 根据 sub_mchid 查找对应的业务处理逻辑(如调用医院 HIS 回写订单)
4) 幂等处理:以 (sub_mchid, mix_trade_no) 联合主键检查
5) 返回 200/204
```
> ‼️ **禁止**仅按 `out_trade_no` / `mix_trade_no` 路由:不同子商户的 `out_trade_no` 可能重复,必须复合 `sub_mchid` 才能唯一定位订单。
## 七、与查单的关系
| 场景 | 推荐做法 |
| --- | --- |
| 30 秒内收到回调 | 验签 + 解密 + 按 (`sub_mchid`, `mix_trade_no`) 幂等 |
| 30 秒后未收到回调 | 主动调用查单接口(带 `sub_mchid`)确认 |
| 解密 / 验签失败 | 应答 4XX/5XX 触发重试,同时报警查单兜底 |

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,88 @@
# 服务商模式接口索引
> 根据用户确认的开发语言加载对应文件Java/Go 目录结构一致。
> 本索引覆盖服务商视角下的全部医保支付服务端接口、客户端拉起脚本、回调通知报文说明,以及共用 SDK 工具类。
> ‼️ 服务商模式与间连模式接口路径、请求体字段完全一致(仅服务商资质、与医保局对接关系不同),代码示例统一使用同一份。
## 命名约定
- 分组目录:`{编号}-{业务名}/`,编号从 `1` 起(`1-下单/``2-订单查询/``3-医保退款通知/``4-小程序调起/``5-JSAPI调起/``6-回调通知/``7-SDK工具类/`
- Java 代码文件:大驼峰 `.java`(如 `CreatePartnerMedInsOrder.java`
- Go 代码文件:蛇形 `.go`(如 `create_partner_med_ins_order.go`
- 回调通知 `.md`:内容语言无关,**Java/ 与 Go/ 各放一份**——Java/ 用中文命名(如 `医保混合收款成功通知说明.md`Go/ 用蛇形拼音(如 `med_ins_success_callback.md`),内容完全一致
- 客户端拉起 `.md`:语言无关的集成说明,统一放在 Java/ 下即可(无需 Go 副本)
---
## 业务接口
> 每个业务分组一张表,列含义如下:
> - **服务端 API**(如下单 / 查单 / 退款通知):`Java` / `Go` 列分别为对应语言的可执行代码文件路径
> - **回调通知**`Java` / `Go` 列分别指向**同一份**报文说明 `.md`(语言无关,按目录约定各放一份方便项目查找)
> - **客户端拉起**(小程序 / JSAPI跨语言通用的 `.md` 集成说明,仅列 `Java` 一列即可
### 1-下单(服务端 API
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 服务商医保自费混合收款下单 | POST /v3/med-ins/orders请求体含 `sub_mchid` / `sub_appid` | `Java/1-下单/CreatePartnerMedInsOrder.java` | `Go/1-下单/create_partner_med_ins_order.go` |
> 服务商签名一律使用**服务商**API 证书私钥;下单 `Wechatpay-Serial` 必须填**服务商**微信支付公钥 ID用于敏感字段加密
> 三种 `mix_pay_type` 字段约束、金额公式、敏感字段加密详见 [📄 开发参数与业务规则.md](../接入指南/开发参数与业务规则.md)。
### 2-订单查询(服务端 API
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 用混合订单号查单 | GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}Query 必传 `sub_mchid` | `Java/2-订单查询/QueryByMixTradeNo.java` | `Go/2-订单查询/query_by_mix_trade_no.go` |
| 用商户订单号查单 | GET /v3/med-ins/orders/out-trade-no/{out_trade_no}Query 必传 `sub_mchid` | `Java/2-订单查询/QueryByOutTradeNo.java` | `Go/2-订单查询/query_by_out_trade_no.go` |
> ‼️ 服务商查询接口 Query 必须带 `sub_mchid`,否则查不到子商户订单。
### 3-医保退款通知(服务端 API服务商主动调用微信
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 医保退款通知(代子商户告知微信医保侧已发生退款) | POST /v3/med-ins/refunds/notify请求体含 `sub_mchid` | `Java/3-医保退款通知/NotifyPartnerMedInsRefund.java` | `Go/3-医保退款通知/notify_partner_med_ins_refund.go` |
> ‼️ 「医保退款通知」**不是**微信发给服务商的回调,而是**服务商调用微信**通知医保退款已经在医保局完成。
> ‼️ 自费部分退款仍走服务商标准退款 `POST /v3/refund/domestic/refunds`(请求体含 `sub_mchid`),二者通过相同 `out_refund_no` 关联。
### 4-小程序调起(客户端集成)
| 业务 | 接口 | Java |
|---|---|---|
| 小程序调起医保支付 | `wx.requestMedicalInsurancePay` | `Java/4-小程序调起/小程序调起医保支付说明.md` |
### 5-JSAPI调起客户端集成
| 业务 | 接口 | Java |
|---|---|---|
| 公众号 / H5 内 JSAPI 调起医保支付 | `WeixinJSBridge.invoke('requestMedicalInsurancePay', ...)` | `Java/5-JSAPI调起/JSAPI调起医保支付说明.md` |
> ‼️ 调起所用 `appId` 必须与下单时使用的 `appid`(或 `sub_appid`)一致,且 `openid` / `sub_openid` 需在对应 AppID 下获取,错配会触发 `PARAM_ERROR`。
### 6-回调通知(异步事件,无可执行代码)
| 业务 | 接口 | Java | Go |
|---|---|---|---|
| 医保混合收款成功通知(`MEDICAL_INSURANCE.SUCCESS` | 回调报文格式与处理要求 | `Java/6-回调通知/医保混合收款成功通知说明.md` | `Go/6-回调通知/med_ins_success_callback.md` |
> 通用解密 / 验签 / 回包流程参考 [📄 回调处理.md](../接入指南/回调处理.md)。
> 多个子商户共用同一 `notify_url`**必须**按回调中的 `sub_mchid` 路由业务,否则会出现串单。
> 微信仅会在订单达到 `MIX_PAY_SUCCESS` 时回调一次5 秒未应答会重试30 秒后不再重试。**必须**对 `MIX_PAY_CREATED` 状态的订单做主动查询兜底。
---
## 7-SDK 工具类(所有接口的公共依赖)
> 所有示例代码都依赖此工具类提供签名、验签、加解密、HTTP 请求等基础能力。**提醒用户需一并集成**。
>
> ‼️ 详见 [📄 签名与验签规则.md](../接入指南/签名与验签规则.md)。
| 语言 | 文件 | 说明 |
|---|---|---|
| Java | `Java/7-SDK工具类/WXPayUtility.java` | 签名、验签、加解密 |
| Java | `Java/7-SDK工具类/WXPayClient.java` | HTTP 客户端,封装请求签名 → 发送 → 验签 |
| Go | `Go/7-SDK工具类/wxpay_utility.go` | 签名、验签、加解密 |
| Go | `Go/7-SDK工具类/wxpay_client.go` | HTTP 客户端,封装请求签名 → 发送 → 验签 |

View File

@@ -0,0 +1,579 @@
# 服务商模式排障手册
> 本文档同时覆盖**服务商模式**与**间连模式**——两种模式接口字段一致,仅服务商资质与子商户绑定关系不同。
>
> 本文档是本角色 + 本产品排障的**唯一入口**。商户模式见对应角色目录下同名文件。
>
> ‼️ **使用规则**:用户报告任何问题(报错 / 接口异常 / 回调收不到 / 签名失败 / 加密失败 / sub_mchid 串单 / 对账差异等),**先加载本文档**按下方流程匹配,不要先翻其他文档或猜原因。
>
> ‼️ **语气**:像有经验的技术支持,自然对话解释原因和方案,不要冷冰冰罗列文档目录。
## 排障流程
1. **能直接复制【官方报错文案】?** → 走「**报错文案精准命中表**」:按四阶段(前端调起 / 下单 / 支付结算 / 查单)+ HTTP 状态码 + 错误码类型直接命中官方原文,给出精准定位与公式。**优先尝试**——用户从官方文档复制过来的文案大概率在这一表里。
2. **能给 Request-Id** → 走「一、错误码 TOP 20」取 Request-Id 末尾 `-` 后的数字(如 `...CF05-268578704``268578704`)在速查表匹配,命中后用「错误码详细排查」对应段落回复。
3. **不能给 / 未命中 TOP 20** → 走「二、常见问题」按现象HTTP / 回调 / 签名 / 加密 / 退款 / 角色特有 / 业务规则 / 通用配置 / **前端 fail / 免密授权 / 亲情授权**)定位子节。
4. **三条都没命中?** → 用末尾「排障信息收集清单」回收信息后再判断。
> 💡 **优先取 `med_ins_fail_reason`**:医保支付失败(`med_ins_pay_status = MED_INS_PAY_FAIL`)时,**查单接口**会在 `med_ins_fail_reason` 字段返回**医保局侧的具体失败原因**。**强烈建议服务商把该字段落库并透传给子商户/医院侧**,能直接定位结算阶段的真实根因,避免每次都要拿 Request-Id 联系微信侧拿日志。
>
> 📞 **拿不准要找谁?** 微信搜索「微信支付医疗健康助手」(微信号 `Wechatpay_BDzhushou`),申请通过后会被拉进医保支付技术支持微信群,群里有官方技术助手 + 对接运营。
---
## 报错文案精准命中表
> 来源官方《报错排查指引_医保支付》[服务商版 4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) / [从业机构版 4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md) / [商户版 4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md))。
>
> ‼️ **使用规则**:用户能直接给出**官方报错文案**(如「医保结算后需自费金额计算失败」「未找到对应授权信息」「商户与子商户不是受理关系」)时**优先**用本表精准命中,命中后给出一句话定位 + 公式 / 锚点跳转;若用户只能给 Request-Id / 错误码数字,转走「§一 TOP 20」。
>
> ‼️ **服务商场景特殊提醒**:所有定位都要带上 `sub_mchid` 维度——查单 / 路由 / 幂等 / 退款通知都必须以 `(sub_mchid, 单号)` 为复合键,否则会出现"查不到 / 串单"假象。
>
> 引用注解「§1.2 268xxx」表示见下方「一、错误码 TOP 20 → 1.2 错误码详细排查」对应段落「§2.x」表示见「二、常见问题」对应子节。
### 0.1【阶段1】前端拉起收银台小程序 / 公众号 H5
| # | 错误信息(官方原文) | 平台 | 一句话定位 | 详细参考 |
|---|---|:---:|---|---|
| 1 | `requestMedicalInsurancePay:fail:access denied` | 小程序 | 该 **`sub_appid`**(注意是子商户小程序)未开通 JSAPI 权限位 1295 + 客户端控制位 494 | §2.8 A 客户端调起类 |
| 2 | `system:access_denied` | 公众号 H5 | `wx.config` 鉴权未通过 / `wx.ready` 未触发 | §2.8 A 客户端调起类 |
| 3 | `no permission to execute` | 公众号 H5 | `jsApiList` 未包含 `requestMedicalInsurancePay` 或鉴权未通过 | §2.8 A 客户端调起类 |
| 4 | `缺少参数 total fee` | / | `package` 字段格式错,必须 `package: "prepay_id=" + prepay_id`;纯医保场景禁传自费参数 | §2.8 A |
| 5 | `system:function_not_exist` | / | 微信客户端版本太低,未支持 `requestMedicalInsurancePay`;前端做低版本兼容并引导用户升级 | §2.8 A |
| 6 | 收银台无反应(无报错、无回调) | / | 基础库不支持 / 用户微信版本过低;用 `wx.checkJsApi``typeof wx.requestMedicalInsurancePay === 'function'` 兜底 | §2.8 A |
| 7 | `config:invalid signature` | iOS 居多 | `wx.config` 签名 URL 与页面实际发起 HTTP 请求的 URL 不一致 | §2.3 签名与证书 |
### 0.2【阶段2】下单 — 400 PARAM_ERROR
| # | 文案(官方原文) | 一句话定位 / 公式 | 详细参考 |
|---|---|---|---|
| 1 | 参数错误 | 笼统提示,按错误信息进一步定位(服务商场景常见漏 `sp_mchid` / `sub_mchid` / `sp_appid` / `sub_appid` | §1.2 268435461 |
| 2 | **医保结算后需自费金额计算失败** | **当 `med_ins_cash_fee > 0` 时**,必须满足:`med_ins_cash_fee + Σcash_add_detail.fee = wechat_pay_cash_fee + Σcash_reduce_detail.fee`PARAM_ERROR 阶段先校验,公式不成立直接拒单) | §1.2 268529476 / §2.6 Q6 |
| 3 | 无效的证件类型 | 目前只支持身份证(`card_type = ID_CARD` | §2.6 / §2.4 Q5 |
| 4 | 代亲属支付缺少亲属信息 | `pay_for_relatives = true` 时必须传 `relative.name` / `relative.id_digest` / `relative.card_type`,前两者按 RSA-OAEP 加密(用**服务商**微信支付公钥) | §1.2 268545964 / §2.6 Q9 |
| 5 | 医保下单时间解析失败 | `med_ins_order_create_time` 必须 RFC3339 格式 `yyyy-MM-DDTHH:mm:ss+08:00`**时区不能省略**(不能用 `Z` | §2.6 Q10 |
| 6 | AppID 与 OpenID 不正确 | 服务商场景 4 元组规则:用户在**服务商** AppID 下登录传 `openid`,在**子商户** AppID 下登录传 `sub_openid`;二选一不能混 | §1.2 268510603 / §2.6 Q3 |
| 7 | OpenID 不正确 | `openid``appid` 不在同一主体下,或调起 / 下单 `appid` 错位(服务商三种 AppID 配置容易混) | §1.2 268510603 |
| 8 | AppID 不正确 | 检查三点:① `appid` / `sub_appid` 已完成医保支付接入;② 大小写正确(**全小写**);③ `sub_appid` 已与 `sub_mchid` 关联绑定 | §2.8 B |
| 9 | 商户号不正确 | 请求头 `mchid` 必须是**服务商商户号**(不是子商户号),`sub_mchid` 才是子商户号 | §2.8 B |
| 10 | 金额计算出现溢出 | 金额必须**正整数(分)**,不能传"元";总和不能超过 int 上限 | §1.2 268529481 / §2.6 |
### 0.3【阶段2】下单 — 400 INVALID_REQUEST
| # | 文案(官方原文) | 一句话定位 | 详细参考 |
|---|---|---|---|
| 1 | 医保局结算校验失败 | 笼统报错,真实原因在医保局返回的 FSI 子文案中——取 Request-Id 联系微信侧拿日志,或查单后看 `med_ins_fail_reason`(见 §0.6 | §1.2 268510610 |
| 2 | 入参个人身份ID摘要和医保电子凭证绑卡身份ID摘要不匹配 | **80% 是漏了"先 MD5小写十六进制→ 再 RSA-OAEP用服务商公钥"中的某一步**;建议服务商把这段封装成共用 SDK 发布给子商户 | §1.2 268545961 / §2.4 Q3 |
| 3 | 缺少必要字段,请根据混合支付类型补充必要的字段 | 按 `mix_pay_type` 装配字段CASH_ONLY 必传 `wechat_pay_cash_fee` + `prepay_id`INSURANCE_ONLY 必传医保 8 件套CASH_AND_INSURANCE 全要 | §1.2 268529474 / 268529475 / §2.6 Q7 |
| 4 | 亲属关系不存在 | 用户在国家医保 APP 或地方医保小程序绑定亲情账户后再发起;详细授权页提示见 §2.11 | §1.2 268545964 / §2.11 |
| 5 | 微信号未绑定医保电子凭证 | 引导用户先在微信「我 → 服务 → 医疗健康 → 医保电子凭证」激活并绑卡 | §1.2 268545963 |
| 6 | 入参用户姓名和医保电子凭证绑卡姓名不匹配 | `payer.name` 必须按 RSA-OAEP 加密(用**服务商**公钥);加密对了仍报错才是用户姓名真不一致 | §1.2 268545962 / §2.4 |
| 7 | 未找到对应授权信息,无法查询订单信息 | `pay_auth_no` 已过期 / 已使用 / 与用户标识不匹配 / **跨 sub_mchid 复用**;或 `passthrough_request_content` 漏传 `payAuthNo` / `payOrdId` / `setlLatlnt` 三件套 | §1.2 268545967 |
| 8 | 传入用户信息与原订单不匹配 | 授权信息已过期或与原订单不一致,重新走免密授权 | §2.10 免密授权 |
| 9 | 使用渠道与授权渠道不一致 | 授权与下单走了不同环境(`med_ins_test_env` 不一致);正式 / 测试环境必须严格对应 | §2.6 Q16 / §2.7 Q2 |
| 10 | HTTP 请求不符合微信支付 APIv3 接口规则 | 请参阅 [APIv3 接口规则](https://pay.weixin.qq.com/doc/v3/merchant/4012081709.md) | §2.3 签名与证书 |
### 0.4【阶段2】下单 — 403 RULE_LIMIT
| # | 文案(官方原文) | 公式 / 一句话定位 | 详细参考 |
|---|---|---|---|
| 1 | **商户与子商户不是受理关系** | **服务商场景高频**:子商户未在服务商体系下完成绑定;商户平台(服务商)→ 服务商功能 → 特约商户管理,确认 `sub_mchid` 已成功进件并绑定,且服务商已在医保局对应城市完成接入报备 | §2.1 / §2.6 Q14 |
| 2 | 自费金额校验不通过 | **仅 `med_ins_cash_fee > 0` 时触发**`med_ins_cash_fee = wechat_pay_cash_fee - Σcash_add_detail.fee + Σcash_reduce_detail.fee`(与 §0.2 第 2 条等价,但触发阶段不同) | §1.2 268529476 / §2.6 Q6 |
| 3 | 订单总金额校验不通过 | `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee`(少一项都拒单) | §1.2 268529477 / §2.6 Q5 |
| 4 | 自费下单金额与医保下单金额校验不通过 | 混合单要求 `wechat_pay_cash_fee > 0` **且** 医保 4 件套之和 > 0两边都不能为 0 | §1.2 268529481 |
| 5 | 纯自费支付单字段规则不满足 | `mix_pay_type = CASH_ONLY` 时:必传 `wechat_pay_cash_fee` / `prepay_id`**禁传**医保字段(`pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*` | §1.2 268529474 / §2.6 Q7 |
| 6 | 纯医保支付单字段规则不满足 | `mix_pay_type = INSURANCE_ONLY` 时:必传医保 8 件套,**禁传** `wechat_pay_cash_fee` / `prepay_id` | §1.2 268529475 / §2.6 Q7 |
| 7 | 混合支付单字段规则不满足 | `mix_pay_type = CASH_AND_INSURANCE` 时:自费 + 医保字段全部非空 | §1.2 268529475 / §2.6 Q7 |
| 8 | 实际需要用户微信支付的金额和医保下单的金额都为 0 | 不支持双 0 元下单场景 | §2.6 |
| 9 | 纯医保单金额校验不通过 | `INSURANCE_ONLY` 时:医保 4 件套之和 > 0 且 `wechat_pay_cash_fee = 0` | §1.2 268529476 |
| 10 | 纯自费单金额校验不通过 | `CASH_ONLY` 时:医保 4 件套之和 = 0 且 `wechat_pay_cash_fee > 0` | §1.2 268529476 |
| 11 | 请求次数超过限制 | 触发频控,稍后重试 | §2.7 |
### 0.5【阶段2】下单 — 其他
| # | HTTP / 错误码 | 文案 | 一句话定位 | 详细参考 |
|---|---|---|---|---|
| 1 | 400 / ALREADY_EXISTS | 订单号 `out_trade_no` 重复 | 先按 `(sub_mchid, out_trade_no)` 复合键查单:已存在且金额一致 → 复用;已 FAIL → 用**新的** `out_trade_no` 并先取消前一笔自费 `prepay_id`;建议幂等键设计为 `{sub_mchid}_{业务单号}` | §1.2 268512536 / §2.6 Q8 |
| 2 | 403 / REQUEST_BLOCKED | 子商户尚未完成医保支付接入流程 | 联系微信医保对接接口人为该 `sub_mchid` 申请开通权限 | §2.8 B |
### 0.6【阶段3】支付/结算 — `med_ins_fail_reason` 关键词命中
> 来源:查单接口 `med_ins_fail_reason` 字段(仅在 `med_ins_pay_status = MED_INS_PAY_FAIL` 时返回);以下文案来自医保局侧,不同省市中台可能存在文案差异,按**部分匹配**命中即可。
>
> ‼️ **服务商务必把 `med_ins_fail_reason` 透传给子商户/医院侧**——医保局给的是最直接的根因,比向微信侧申请日志快得多。
| # | 关键词(官方原文,部分匹配) | 含义 | 处理 |
|---|---|---|---|
| 1 | 预结算现金支付金额与结算金额不一致 | 预结算与结算阶段现金支付金额不符 | 核对预结算与结算两次的现金支付金额一致 |
| 2 | Token 核验失败:用户身份信息校验不通过 | 医保电子凭证 ecToken 过期 / 身份信息不匹配 | 引导用户重新激活医保电子凭证 + 核对姓名 / 身份证号是否与医保档案一致(曾改名 / 身份证升位需更新) |
| 3 | 医保结算成功,通知医院失败,自动冲正成功 | 医保局结算成功但通知医院 HIS 失败,为保持一致性自动撤销结算 | 联系医保局确认配置 / 检查 HIS 接口可用性 |
| 4 | FSI-在院状态不允许进行结算 | 患者在医保系统中处于「在院」(住院中)状态 | 出院后再结算,或按住院结算流程处理 |
| 5 | 参保人不存在该医院的门诊选点信息 | 异地就医未选点 / 备案 | 引导用户完成门诊选点 / 异地就医备案 |
| 6 | 该人员正在进行门诊结算相关业务 | 医保局并发限制(同一参保人重复请求) | 稍后重试 |
| 7 | 请求处理中,请勿频繁操作 | 医保局处理中 | 等待处理完成,避免重复发起 |
| 8 | service not registed | 医保局服务未注册 | 联系医保局确认服务状态 |
| 9 | 系统错误,请联系技术支持人员 | 医保局系统异常 | 等待恢复或联系医保局;可重试 |
| 10 | 订单未进行预结算,自费支付通知无法继续 | 自费支付通知早于医保预结算 | 先完成医保预结算,再发起自费支付通知 |
### 0.7【阶段3】前端 fail 回调 msg
> 含 `缺少 mix_trade_no` / `缺少自费参数` / `混合支付超时` / `混合支付订单状态为支付失败` / `混合支付订单状态异常` / `自费支付失败` / `用户直接退出医保混合支付` / `混合支付订单已关单` 共 8 条文案的精准定位与处理建议(服务商场景所有查单都必须带 `sub_mchid`),详见 →
→ 详见 §2.9 前端 fail 回调 msg 解读
### 0.8【阶段4】查单接口错误码
| # | 状态码 | 错误码 | 描述 | 处理 / 详细参考 |
|---|:---:|---|---|---|
| 1 | 400 | PARAM_ERROR | 参数错误 | 按错误提示核对入参(**服务商查单 URL 必传 `sub_mchid` query 参数** |
| 2 | 400 | PARAM_ERROR | 金额计算出现溢出 | 检查填写的金额是否在 int 范围内 / 单位是否为分 |
| 3 | 400 | INVALID_REQUEST | HTTP 请求不符合微信支付 APIv3 接口规则 | 参阅 [APIv3 接口规则](https://pay.weixin.qq.com/doc/v3/merchant/4012081709.md) |
| 4 | 401 | SIGN_ERROR | 验证不通过 | 必须用**服务商**API 私钥(子商户无独立证书);参阅 [签名常见问题](https://pay.weixin.qq.com/doc/v3/merchant/4012072670.md) / §2.3 |
| 5 | 403 | RULE_LIMIT | 请求次数超过限制 | 稍后重试 |
| 6 | 404 | NOT_FOUND | 未找到订单 | 90% 是漏传 `sub_mchid` 或填错 `sub_mchid` 导致命中错的子商户;其次是订单号本身错 → §1.2 268529515 |
| 7 | 500 | SYSTEM_ERROR | 系统异常 | 稍后重试 |
---
## 一、错误码 TOP 20Request-Id 场景)
> 来源:本产品真实工单 / 客服系统统计的高频错误码。
### 1.1 TOP 20 速查表
| 错误码 | 错误信息 | 分类 |
|:------:|---------|:----:|
| 268529515 | 未找到订单,请确认订单号是否正确 | 订单查询 |
| 268512536 | 订单已存在 | 订单重入 |
| 268510604 | 请确认 sub_appid 与 sub_openid 是否正确 | 用户参数(服务商高频) |
| 268510603 | 请确认 openid 是否正确 | 用户参数 |
| 268545961 | 入参个人身份ID摘要和医保电子凭证绑卡身份ID摘要不匹配 | 用户校验 |
| 268545962 | 入参用户姓名和医保电子凭证绑卡姓名不匹配 | 用户校验 |
| 268545963 | 微信号未绑定医保电子凭证 | 用户校验 |
| 268545964 | 亲属关系不存在,请传入有效亲属关系后重试 | 用户校验 |
| 268547120 | PAY_AUTH_NO 校验失败,请确认无误后重试 | 医保授权 |
| 268545967 | 医保局业务错误:未找到对应授权信息,无法查询订单信息 | 医保授权 |
| 268510610 | 医保局结算校验失败,请确认医保相关的字段是否正确 | 医保校验 |
| 268529477 | 订单总金额校验不通过 | 金额校验 |
| 268529476 | 自费金额校验不通过 | 金额校验 |
| 268529481 | 混合单金额不正确 | 金额校验 |
| 268529474 | 纯自费单字段校验失败 | 字段联动 |
| 268529475 | 合支付单字段校验失败 | 字段联动 |
| 268554200 | 当前订单状态不允许冲正 | 订单状态 |
| 268554422 | 冲正失败,请检查参数后重试 | 冲正 |
| 268560650 | 已完成验密,不允许关单 | 订单状态 |
| 268435461 | 参数非法 | 参数校验 |
### 1.2 错误码详细排查
#### 268529515 — 未找到订单,请确认订单号是否正确
**常见原因**
- 服务商查单时漏传 `sub_mchid` 或填错 `sub_mchid`,导致命中错误的子商户
-`out_trade_no` 在错误的 `(sub_mchid, out_trade_no)` 组合下查询(多子商户场景下 `out_trade_no` 可能在不同 sub_mchid 下重复)
- 创单失败但业务侧把 `mix_trade_no` 当作"已创建"入库后又用它查询
- 自费下单成功但医保下单失败,仅生成了自费 `prepay_id`
**🔧 脚本确认**:先调"按 out_trade_no 查单"接口(必传 `sp_mchid` + `sub_mchid`),按 `(sub_mchid, out_trade_no)` 复合键定位;同时核对自费 `prepay_id` 是否一致。
**💡 推荐集成**:服务商务必把"按 out_trade_no 查单"+"按 mix_trade_no 查单"做成常驻能力,且**所有查询/路由必须以 `(sub_mchid, 单号)` 为复合键**。
---
#### 268512536 — 订单已存在
**常见原因**
- 同一 `(sub_mchid, out_trade_no)` 组合在创单超时后被重新发起,但前一次实际已成功
- 多实例并发抢同一笔单时未做幂等
- 同一服务商下不同子商户的 `out_trade_no` 没做命名空间隔离,理论上不会冲突(不同 sub_mchid 下可重复),但若代码错把别的 sub_mchid 的 `out_trade_no` 用到当前请求会引发此错
**🔧 脚本确认**:调"按 out_trade_no 查单"看订单是否真实存在:① 已存在且金额一致 → 复用现有 `mix_trade_no`;② 已存在但 `MIX_PAY_FAIL` → 用**新的** `out_trade_no` 重发,必要时先取消前一笔自费 `prepay_id`
**💡 推荐集成**:服务商 `out_trade_no` 幂等键建议设计为 `{sub_mchid}_{业务单号}` 结构,从源头避免命名空间冲突。
---
#### 268510604 / 268510603 — sub_appid + sub_openid / openid 不正确
**常见原因**(服务商场景高频):
- **268510604****服务商最常见错误**`sub_openid` 不是从 `sub_appid` 获取的;或 `sub_appid``sub_mchid` 没有对应关系(子商户名下没有这个 `sub_appid`);或调起支付时使用的子商户 AppID 与下单 `sub_appid` 不一致
- **268510603**:服务商场景下混用了 `appid` + `openid`(顶层服务商体系)与 `sub_appid` + `sub_openid`(子商户体系),但取 `openid` 时用错了 AppID
- 服务商三种 AppID 配置(`sp_appid` / `sub_appid``openid` / `sub_openid`)混淆使用
**🔧 脚本确认**:让商户提供下单请求体的完整 4 元组 `(sp_appid, sub_appid, openid, sub_openid)`,与 `wx.login` 时使用的 AppID 比对;服务商必须确认 `sub_appid` 已在子商户名下报备绑定。
**💡 推荐集成**:服务商接入时建议建立 `(sub_mchid, sub_appid)` 的映射表,下单时强校验组合合法性。
---
#### 268545961 / 268545962 / 268545963 / 268545964 — 用户实名 / 绑卡 / 亲属关系类
**常见原因**
- **268545961**身份ID摘要不匹配/ **268545962**(姓名不匹配):上送的 `payer.name` / `payer.id_digest` 与用户在医保电子凭证上**绑卡时的实名信息**不一致;**线上工单 80% 是开发漏了"先 MD5 再 RSA 加密"这一步** —— `id_digest` 必须先按规则 MD5小写十六进制后再用微信支付公钥 RSA-OAEP 加密;如果加密本身没问题但仍报错,才是用户实名信息真不一致
- **268545963**(未绑卡):用户微信号未激活医保电子凭证或未绑卡
- **268545964**(亲属关系不存在):`pay_for_relatives = true` 时传入的 `relative.name` / `relative.id_digest` 在医保系统中查不到对应亲属关系记录
**🔧 脚本确认**
- 对 268545961 / 268545962**先验证加密链路**——拿一笔已知正确的姓名+身份证,用代码跑出 `id_digest` 密文,与文档示例值(`44030019000101123x` → MD5 `09eb26e839ff3a2e3980352ae45ef09e`的中间结果对照MD5 中间值能对上后,再排查实名信息
- 对 268545963引导用户先完成激活
- 对 268545964让用户在国家医保 APP 或地方医保小程序绑定亲情账户后再发起
**💡 推荐集成**:服务商把 `id_digest` 计算(大写 → 15 转 18 → MD5 → RSA-OAEP封装成共用 SDK / 单元测试用例发布给所有子商户,避免每家医院各自实现踩同一个坑。
---
#### 268547120 / 268545967 — PAY_AUTH_NO 校验失败 / 未找到对应授权信息
**常见原因**
- `pay_auth_no` 来自医保电子凭证的"医保支付授权"**有效期短**(通常分钟级),过期后再用就会失败
- 同一个 `pay_auth_no` 被重复使用(医保侧只认一次授权)
- 上送的 `pay_auth_no` 不属于当前 `payer.openid` / `sub_openid` 对应用户
- 服务商场景下,`pay_auth_no` 跨子商户复用(不同子商户必须各自获取授权)
- **线上工单常见**`passthrough_request_content` 中漏传 `payAuthNo` / `payOrdId` / `setlLatlnt` 中的某一项(这些是医保局透传必填,与顶层字段同时存在)
**🔧 脚本确认**
1. 检查从医保电子凭证获取 `pay_auth_no` 到调用 `POST /v3/med-ins/orders` 的时间间隔
2. 确认 `pay_auth_no` 在当前 `sub_mchid` 下首次使用
3. 检查 `passthrough_request_content` 是否完整带上 `payAuthNo` / `payOrdId` / `setlLatlnt` 三件套
**💡 推荐集成**:服务商应在子商户接入文档中明确"`pay_auth_no` 一次一用,且仅在当前子商户下使用",禁止跨 sub_mchid 流转。
---
#### 268510610 — 医保局结算校验失败
**常见原因**(笼统报错,真实原因在医保局返回的 FSI 错误信息中,需联系微信侧拿日志):
- 商户侧:`city_id` 填错(如无锡医院填了苏州 `320201`/ 金额单位错位 / `med_ins_order_create_time` 与医保局记录差异过大 / `med_inst_no` / `serial_no` 与医保局报备不一致 / `passthrough_request_content` 必填项缺失
- 服务商特有:服务商在医保局侧的**报备主体与实际下单的子商户医院不匹配**
- 医保局侧FSI 子原因,从线上工单沉淀):
- **预结算金额不匹配** —— 商户算出的金额与医保局预结算金额对不上(最高频)
- **自付费金额与支付金额不匹配** —— `wechat_pay_cash_fee` 与医保局返回的自付不一致
- **自费预下单ID和收款预下单ID不一致** —— `prepay_id` 在两次下单中错位
- **电子凭证 ecToken / ocToken 二次核验失败** —— 用户身份信息与医保系统不符
- **该笔记录已结算(单边账)** —— 短时间内重复结算,需先在两定平台冲销
- **结算费用明细总额与结算信息医疗费总额不一致** —— 明细行金额加和对不上总额
- **异地药店医疗目录编码未上传无码库** —— 必须上传追溯码
- **在院状态不允许结算** —— 患者还在院期间走结算
- **黑名单 / 监管接口短暂异常** —— 医保平台抖动,重试可恢复
**🔧 脚本确认**
1. 取 Request-Id 联系微信侧拿到医保局返回的具体 FSI 错误文案(接口本身只回笼统错)
2. 拿到 FSI 文案后对照上方清单分类处理;金额类问题先核对 `total_fee` / `wechat_pay_cash_fee` 公式
3. `city_id` 类问题让子商户对照医保局发的城市编码表
**💡 推荐集成**:服务商应建立 `(sub_mchid, med_inst_no, city_id)` 三元组本地白名单,下单前强校验,避免子商户硬编码 / 配错。
---
#### 268529477 / 268529476 / 268529481 — 金额校验不通过
**常见原因**
- **268529477**(订单总金额):未满足 `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee`
- **268529476**(自费金额):未满足 `wechat_pay_cash_fee = med_ins_cash_fee + Σcash_add_detail.fee - Σcash_reduce_detail.fee`
- **268529481**(混合单金额):上述两个公式之间存在交叉不一致,或金额单位错误(误传"元"而非"分"
**🔧 脚本确认**:取完整请求 body按上方两个公式逐项相加比对。常见踩坑`cash_reduce_detail` 是数组,需要 Σ 求和;② 金额必须是**正整数(分)**;③ 漏项(特别是 `cash_reduce_detail` 为空时也要算 0 而非省略)。
**💡 推荐集成**:服务商可在 SDK / 公共组件中封装"金额预校验"函数,统一发布给所有子商户,避免每家医院 HIS 各自实现。
---
#### 268529474 / 268529475 — 字段联动校验失败
**常见原因**
- **268529474**(纯自费单 `mix_pay_type = CASH_ONLY`):误传了 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*` 等医保字段
- **268529475**(混合 / 纯医保单 `INSURANCE_ONLY``CASH_AND_INSURANCE``wechat_pay_cash_fee` 为 0、`prepay_id` 为空,或 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个 `med_ins_*_fee` 任一为空
**🔧 脚本确认**:定位 `mix_pay_type` 取值,对照 `2.6 业务规则 Q&A` 中"`mix_pay_type` 三种类型有什么字段约束"逐项检查必传 / 禁传字段。
**💡 推荐集成**:建议把"按 `mix_pay_type` 装配请求体"封装成 3 个独立函数CASH_ONLY / INSURANCE_ONLY / CASH_AND_INSURANCE从源头规避字段串台。
---
#### 268554200 / 268554422 / 268560650 — 冲正 / 关单状态类
**常见原因**
- **268554200**(当前订单状态不允许冲正):订单已 `MIX_PAY_SUCCESS` 或已 `MIX_PAY_REVERSED`,不能再冲正
- **268554422**(冲正失败):冲正接口入参缺失(`sub_mchid` / `mix_trade_no` / `out_trade_no`)或参数与原单不一致;也可能是医保侧拒绝
- **268560650**(已完成验密不允许关单):用户已输入医保密码完成支付确认,此时不能再调关单接口;只能等支付完成后做退款通知
**🔧 脚本确认**:统一先调查单接口(必带 `sub_mchid`)确认当前 `mix_pay_status`
- `MIX_PAY_CREATED` → 可关单 / 可冲正
- `MIX_PAY_USER_PAYING` → 用户正在验密,**不能**关单268560650
- `MIX_PAY_SUCCESS` → 走退款通知而非冲正
- `MIX_PAY_FAIL` / `MIX_PAY_REVERSED` → 终态,无需再操作
**💡 推荐集成**:服务商应在冲正 / 关单前强制做查单,避免子商户错状态下盲调。
---
#### 268435461 — 参数非法
**常见原因**
- 必填字段缺失(服务商场景常见漏 `sp_mchid` / `sub_mchid` / `sp_appid` / `sub_appid` / `med_inst_no`
- 字段类型错误(如金额传成字符串、布尔传成字符串)
- 字符串字段超长 / 含非法字符
- 时间格式不符 RFC 3339缺时区、用了 `Z` 而非 `+08:00`
- JSON 层级嵌套错误(`payer` / `relative` 对象结构错误)
**🔧 脚本确认**:拿到完整请求 body 与 [服务商医保混合下单 API 文档](https://pay.weixin.qq.com/doc/v3/partner/4012503131.md) 字段表逐项对照,重点核对 `sp_mchid` / `sub_mchid` / `sp_appid` / `sub_appid` 是否齐全。
---
## 二、常见问题(无 Request-Id 场景)
> 来源:本产品官方「常见问题」文档 + 通用接入经验沉淀。
### 2.1 HTTP 错误401 / 400 / 403
| 状态码 | 含义 | 常见原因 | 排查要点 |
|:----:|------|---------|---------|
| 401 | 签名验证失败 | 服务商 API 私钥与 serial_no 不匹配;签名串拼接有误(换行符 / URL / body 为空时缺末尾换行);时间戳偏差过大;**误用了子商户密钥**(子商户无独立 API 证书) | 检查 Authorization 头格式;确认**服务商**私钥正确加载;建议用官方 SDK |
| 400 | 请求参数错误 | 必填参数缺失(最常见漏 `sub_mchid` / `sub_appid`);金额单位是分不是元;时间格式不符 RFC 3339mix_pay_type 与字段联动不合规 | 对照 [服务商医保下单](https://pay.weixin.qq.com/doc/v3/partner/4012503131.md) 逐项检查 |
| 403 | 权限不足 / 服务商-子商户关系错误 | `RULE_LIMIT 商户与子商户不是受理关系`子商户未绑定到服务商服务商资质未在医保局对应城市报备mchid 状态异常 | 商户平台 → 服务商功能 → 特约商户管理,确认 sub_mchid 已绑定;联系微信医保对接接口人确认服务商城市报备 |
### 2.2 回调问题(含 sub_mchid 路由)
**收不到回调排查清单**(按优先级):① 地址不可达URL 错 / 域名解析失败 / localhost / 服务未启动)→ ② URL 前后有空格致 DNS 失败 → ③ 防火墙拦截(未对回调 IP 段开白名单,见下方 IP→ ④ 登录态拦截callback_url 须从鉴权中间件中排除)→ ⑤ 响应非 200 / 204如 FAIL / 404重试后放弃→ ⑥ 处理超时(须 5 秒内应答)→ ⑦ 域名未 ICP 备案 → ⑧ 多个 sub_mchid 共用 callback_url 但路由错乱(看下方"sub_mchid 路由")。
**sub_mchid 路由专项**
| # | 现象 | 原因 | 解法 |
|---|------|------|------|
| 1 | 子商户 A 的钱打到了子商户 B 的账 | 业务侧仅按 `out_trade_no` / `mix_trade_no` 路由,没有用 `sub_mchid` | 解密回调后**必须**按 `(sub_mchid, mix_trade_no)` 联合路由 |
| 2 | 查单返回"订单不存在" | 查单 URL 漏传 `sub_mchid` query 参数;或用了错误的 `sub_mchid` | 服务商查单 `GET /v3/med-ins/orders/mix-trade-no/{mix_trade_no}?sub_mchid=...` 必传 `sub_mchid` |
| 3 | `out_trade_no` 重复触发"订单已存在" | 业务库唯一索引仅按 `out_trade_no`,未复合 `sub_mchid` | 业务库唯一键改成 `(sub_mchid, out_trade_no)` |
| 4 | 回调里的 `appid` 是服务商的,业务找不到对应配置 | 用 `sub_openid` 下单时 `appid` 是服务商的,但业务以 `sub_appid` 路由 | 双键路由:先按 `sub_mchid` 找到子商户,再用其 `sub_appid` 校验 |
**回调行为 Q&A**
| # | 问题 | 答案 |
|---|------|------|
| 1 | 怎么确认微信发了回调? | 微信不提供回调日志查询,检查自身服务器访问日志 + 调用查单接口(**带 `sub_mchid`**)确认 |
| 2 | 回调会重复收到吗? | 会未正确响应5 秒内 200/204时微信会重试业务必须做幂等`(sub_mchid, mix_trade_no, event_type)` 为键 |
| 3 | 回调延迟正常吗? | 数秒到数十秒均属正常。建议回调 + 主动查单(针对 `MIX_PAY_CREATED` 状态)双保险 |
| 4 | 能直接将回调当最终结果吗? | 不能,回调不保证送达,需结合查单接口确认 |
| 5 | 商户平台能查回调状态吗? | 不支持,需调用查单接口(带 `sub_mchid` |
| 6 | 回调怎么测试? | 无独立测试接口,需在生产环境真实业务(或 `med_ins_test_env=true` 联调期)验证 |
**回调解密与验签**
| # | 报错 | 原因 | 解法 |
|---|------|------|------|
| 1 | `cipher: message authentication failed` / `AEADBadTagException` | 服务商 APIv3 密钥错误 | 检查代码中的**服务商**APIv3 密钥与商户平台一致;子商户没有独立 APIv3 密钥 |
| 2 | "证书序列号不一致" | 用商户证书做了验签或平台证书过期 | 推荐改用**服务商**微信支付公钥模式(`Wechatpay-Serial``PUB_KEY_ID_` 开头) |
| 3 | `Last unit does not have enough valid bits` | 签名探测流量 | 检查 `Wechatpay-Signature` 是否以 `WECHATPAY/SIGNTEST/` 开头,是则返回非 2xx |
| 4 | 签名参数顺序错误 | 参数个数 / 顺序 / 大小写不对或末尾缺 `\n` | 严格按文档顺序拼接:`时间戳\n随机串\nbody\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`
> ‼️ 服务商接入医院 / 药店时,**强烈建议以域名白名单**配置防火墙;多个子商户共用同一 callback_url 时,回调路由必须按 `sub_mchid`。
### 2.3 签名与证书
| # | 问题 | 答案 |
|---|------|------|
| 1 | 服务商证书序列号怎么获取? | 商户平台(**服务商** → 账户中心 → API 安全 → 商户 API 证书 → 管理证书 |
| 2 | 子商户需要证书吗? | **不需要**。所有 APIv3 调用、回调验签、敏感字段加密都用**服务商**密钥;子商户只是计费/收款主体 |
| 3 | 分行作为渠道商,能用分行自己的商户 API 证书下医保混合单吗? | **不可以**。医保混合单必须与自费单绑定,**自费单由从业机构下**,因此医保混合单也必须由从业机构调用并用从业机构的 API 证书签名。分行仅是渠道商身份时不具备下单资格,需协调由从业机构方调用接口;分行的证书只能用于自身名下其他业务,不能用来下医保混合单。 |
| 4 | 平台证书过期怎么换? | 推荐切换到[微信支付公钥模式](https://pay.weixin.qq.com/doc/v3/partner/4013038589.md),无需关心证书过期 |
| 5 | 换 serial_no 后报签名错误? | 证书编号与私钥一一对应,更新 serial_no 时必须同步换私钥文件 |
| 6 | **医保支付小程序 / JSAPI 调起的 paySign 用什么签?** | **必须**用**服务商**API 证书私钥 + RSA-SHA256`signType` 字段固定为 `RSA`。**子商户没有独立 API 证书**,不能用子商户密钥签名。 |
| 7 | V2 签名能用在医保混合下单接口吗? | 不能,`POST /v3/med-ins/orders` 是纯 V3 接口。**自费部分**的 JSAPI 下单可以用 V2 [服务商统一下单](https://pay.weixin.qq.com/doc/v3/partner/4012692411.md)(官方 FAQ 明确支持),但建议优先 V3。 |
| 8 | API 只能通过域名访问吗? | 是,不支持 IP 直连。主域名 `api.mch.weixin.qq.com`,备域名 `api2.mch.weixin.qq.com` |
| 9 | 微信支付公钥 ID 怎么获取? | 商户平台(服务商) → 账户中心 → API 安全 → 微信支付公钥;获取后 Header `Wechatpay-Serial` 填入 `PUB_KEY_ID_xxx` |
### 2.4 敏感数据加密(医保支付特有)
> ‼️ `payer.name` / `payer.id_digest` / `relative.name` / `relative.id_digest` 必须加密,明文上送会被直接拒绝。
| # | 问题 | 答案 |
|---|------|------|
| 1 | 用什么密钥加密? | **服务商**的微信支付公钥(推荐)或微信支付**平台证书公钥****不是商户 API 证书****不是 APIv3 密钥****不是子商户密钥** |
| 2 | 加密算法? | RSA / ECB / OAEPWithSHA-1AndMGF1Padding`RSA-OAEP`),输出再 Base64 |
| 3 | `id_digest` 怎么算? | 1) 身份证字母大写 → 2) 15 位转 18 位 → 3) MD5输出小写十六进制→ 4) 用上面的 RSA-OAEP 加密。例:`44030019000101123x` → MD5 后 `09eb26e839ff3a2e3980352ae45ef09e` → 再 RSA 加密 |
| 4 | `Wechatpay-Serial` 该填什么? | 用**公钥**加密就填 `PUB_KEY_ID_xxx`;用**平台证书**加密就填证书序列号。两者不能混用,否则微信侧解密失败 |
| 5 | 加密后报"证件类型错误" | `card_type` 不参与加密,明文传 `ID_CARD` 等枚举值即可 |
| 6 | 报"姓名/身份ID摘要和医保电子凭证绑卡xxx不匹配" | 加密本身没问题,是用户**实名信息**与医保电子凭证绑卡不一致。引导用户重新激活医保电子凭证或核对姓名/身份证 |
### 2.5 退款常见问题
> 医保退款是**服务商主动通知微信**`POST /v3/med-ins/refunds/notify`body 中**必须**包含 `sub_mchid`**不是**收微信回调。
| # | 问题 | 答案 |
|---|------|------|
| 1 | 退款时机怎么判断? | 用户在医院/药店发起退款 → 医保局完成自费 + 医保两侧退款 → 服务商收到子商户 / 医保局回执后,调用本接口把退款结果同步给微信(带 `sub_mchid` |
| 2 | 自费部分退款是这个接口吗? | 不是。微信侧自费退款走标准 [V3 服务商退款](https://pay.weixin.qq.com/doc/v3/partner/4012073627.md)`POST /v3/refund/domestic/refunds`,必传 `sub_mchid`),本接口只做"通知"作用 |
| 3 | 重复通知会出错吗? | 接口本身允许重试(建议幂等:以 `(sub_mchid, mix_trade_no)` 为键),但同一医保订单多次通知微信侧会以最新一次为准 |
| 4 | 通知失败怎么办? | 网络超时按指数退避重试(建议初值 200 ms最多 3 次),不要立即换号;微信侧无回执时调"按 mix_trade_no 查单"看 `mix_pay_status` 是否已 `MIX_PAY_REFUND` |
| 5 | 多子商户退款日志怎么排查? | 全链路日志带 `sub_mchid` + `mix_trade_no` + `out_trade_no` 三键 |
### 2.6 业务规则 Q&A医保支付 · 服务商)
> 来源:[医保支付服务商常见问题](https://pay.weixin.qq.com/doc/v3/partner/4017415847.md) + [服务商医保混合下单错误码表](https://pay.weixin.qq.com/doc/v3/partner/4012503131.md)
| # | 问题 | 答案 |
|---|------|------|
| 1 | 服务商模式与间连模式有什么区别? | 接口字段完全一致。区别在:**服务商模式**支持普通服务商代普通商户接入;**间连模式**支持从业机构(银行 / 支付机构)代医保定点机构接入。两者所用 API 路径相同,仅资质、子商户类型、与医保局的报备关系不同。 |
| 2 | `sub_mchid` / `sub_appid` 必传吗? | **必传**`sub_mchid` 是医疗机构(医院/药店)商户号;`sub_appid` 是医疗机构公众号 / 小程序的 AppID。 |
| 3 | `openid` 还是 `sub_openid` 怎么选? | 二选一:用户在**服务商**AppID 下登录就传 `openid`(调起用服务商 `appid`);用户在**子商户**AppID 下登录就传 `sub_openid`(调起用 `sub_appid`)。两者必须严格对应,否则报 `PARAM_ERROR 请确认AppID与OpenID是否正确`。 |
| 4 | 自费部分必须先在微信支付下单吗? | 是。`mix_pay_type ∈ {CASH_ONLY, CASH_AND_INSURANCE}` 时,必须先调 [服务商 JSAPI 自费下单](https://pay.weixin.qq.com/doc/v3/partner/4012692411.md) 拿到 `prepay_id`,再调本接口下医保混合单。两次调用的 `out_trade_no` 必须一致。 |
| 5 | `total_fee` 怎么算? | `total_fee = med_ins_gov_fee + med_ins_self_fee + med_ins_other_fee + wechat_pay_cash_fee + Σcash_reduce_detail.fee`。少一项都会触发 `RULE_LIMIT 订单总金额校验不通过`。 |
| 6 | `wechat_pay_cash_fee` 怎么算? | `wechat_pay_cash_fee = med_ins_cash_fee + Σcash_add_detail.fee - Σcash_reduce_detail.fee`。少一项会触发 `RULE_LIMIT 自费金额校验不通过`。 |
| 7 | `mix_pay_type` 三种类型有什么字段约束? | **CASH_ONLY**:禁传 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_*`;必传 `wechat_pay_cash_fee` / `prepay_id`。**INSURANCE_ONLY**:禁传 `wechat_pay_cash_fee` / `prepay_id`;必传 `pay_order_id` / `pay_auth_no` / `geo_location` / `med_ins_order_create_time` / 4 个 `med_ins_*_fee`。**CASH_AND_INSURANCE**:上述全部必传。 |
| 8 | `out_trade_no` 重复怎么办? | 触发 `400 ALREADY_EXISTS`。先调"按 out_trade_no 查单"(带 `sub_mchid`)确认是否已有同号订单:① 同 → 复用;② 异常待重试 → 用新 `out_trade_no`,并复用原 `serial_no`,但需先取消前一笔自费单。 |
| 9 | `pay_for_relatives = true` 报"亲属信息为空" | 必须同时传 `relative.name` / `relative.id_digest` / `relative.card_type`,且全部按 RSA-OAEP 加密(用服务商微信支付公钥)。 |
| 10 | `med_ins_order_create_time` 报"时间解析失败" | 必须是 RFC3339 `yyyy-MM-DDTHH:mm:ss+08:00` 格式,时区不能省略。 |
| 11 | `med_inst_no` / `serial_no` 校验失败? | `med_inst_no` 必须与子商户在医保局报备的医疗机构编码一致;`serial_no` 必须与医院 HIS【6201】费用明细 `medOrgOrd` 一致,否则医保局会拒单。 |
| 12 | "微信号未绑定医保电子凭证" 怎么解? | 用户必须先在微信"我 → 服务 → 医疗健康 → 医保电子凭证"激活并绑卡。前端建议在调起前先校验。 |
| 13 | 订单状态怎么判断? | 看返回的三态:`mix_pay_status`(总状态)+ `self_pay_status`(自费)+ `med_ins_pay_status`(医保)。仅当 `mix_pay_status = MIX_PAY_SUCCESS` 时才算成功;`MIX_PAY_CREATED` → 等待支付,必须做主动查单兜底(带 `sub_mchid`)。 |
| 14 | "商户与子商户不是受理关系" 怎么解? | 子商户未在服务商体系下完成绑定。商户平台(服务商) → 服务商功能 → 特约商户管理,确认 `sub_mchid` 已成功进件并绑定;同时确认服务商已在医保局对应城市完成接入报备。 |
| 15 | 多子商户共用一个 `callback_url` 行不行? | 行,但**必须**按解密后的 `sub_mchid` 路由业务,否则会串单。 |
| 16 | `med_ins_test_env` 上线后没关怎么办? | **资损风险**——正式环境用户支付却下到医保局测试环境。立即把代码 / 配置中所有子商户的 `med_ins_test_env` 默认值改回 `false`,并核对当日订单与医保局正式环境对账。 |
### 2.7 通用接入配置
| # | 问题 | 答案 |
|---|------|------|
| 1 | V2 和 V3 可以同时用吗? | 可以,密钥体系独立互不影响。本产品**自费下单**支持 V2/V3**医保混合下单/查单**仅 V3 |
| 2 | 微信支付有测试环境吗? | **没有**真正的微信支付测试环境,但医保侧可通过 `med_ins_test_env=true` 让医保单走医保局测试环境(自费侧仍走微信支付正式环境) |
| 3 | 同一错误为什么返回不同错误码? | 存在参数校验优先级,多参数错误时可能先返回 `PARAM_ERROR`,再返回 `RULE_LIMIT` |
| 4 | 接口地址能在浏览器直接打开吗? | **不能**,需程序调用并携带证书,建议用 Postman 调试 |
| 5 | 防火墙拦截(医院 / 药店场景常见)怎么办? | 微信服务端 IP 动态更新,**强烈建议以域名白名单**配置防火墙;同时入向放通上述回调 IP 段 |
| 6 | 主域名 / 备域名怎么用? | 优先用主域名 `api.mch.weixin.qq.com`,主域名连续失败时切备域名 `api2.mch.weixin.qq.com`。建议 SDK 内置切换,不要手动 hardcode |
### 2.8 线上真实工单沉淀
> 来源:医保支付服务商群线上真实工单 + 内部技术支持回复结论。按"用户报错现象 → 真实根因"组织。
**A. 客户端调起 / JS-SDK 类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 小程序 / 公众号拉起收银台**无反应**(无报错、无回调) | 用户微信版本过低,未支持 `requestMedicalInsurancePay` 接口 | 按文档「接口兼容部分」做低版本兼容:**公众号**用 `wx.checkJsApi({ jsApiList: ['requestMedicalInsurancePay'] })` 判断接口是否支持;**小程序**直接判断 `typeof wx.requestMedicalInsurancePay === 'function'`。不支持时给出友好提示,引导用户升级微信。 |
| 小程序拉起报错 `"errno":102, "errMsg":"requestMedicalInsurancePay:fail:access denied"` | 调起小程序的 `sub_appid` 没有开通 **JSAPI 权限位 1295**(医保支付小程序拉起能力) | 联系微信侧通过**开平医保小助手**为该 `sub_appid` 开通权限位 1295权限位以**子商户小程序主体**为单位申请,服务商主体的小程序不能代替;开通后需将小程序重新发版上线生效。**完整权限清单**:① `WXA_JSAPI_INDEX_requestMedicalInsurancePay = 1295`JSAPI 索引位);② `JSAPI_CONTROL_BYTE_REQUEST_MEDICAL_INSURANCE_PAY = 494`(客户端控制位),两个都要为 `sub_appid` 开通。 |
| 公众号拉起报错 `system:access_denied` / `requestMedicalInsurancePay:fail no permission to execute` | jsapi 鉴权错误,`wx.config` 未生效或 `jsApiList` 漏配 | 按以下顺序排查:① 当前是否手机微信内打开外部浏览器、企业微信、PC 微信均不行);② 是否调用了 `wx.config` 鉴权且 `wx.ready` 回调被触发(联调时打开 `debug:true` 看弹窗);③ 拉起代码是否放在 `wx.ready` 回调里;④ `jsApiList` 是否包含 `requestMedicalInsurancePay`。参考 [JS-SDK 鉴权](https://developers.weixin.qq.com/doc/service/guide/h5/)。 |
| 调起报"缺少参数 total_fee" | `package` 字段格式错(`package` 不能只填 `prepay_id`,必须 `prepay_id=wx2012...` | `package: "prepay_id=" + prepay_id` |
| 调起报"支付验证签名失败" | 用了 V2 接口拿到的 `prepay_id` 调 V3 医保调起 | 自费下单必须用 [V3 服务商 JSAPI 下单](https://pay.weixin.qq.com/doc/v3/partner/4012692411.md) 拿 `prepay_id` |
| 调起 `paySign` 验签失败 | 服务商生成的签名与微信侧不一致 | 用[官方签名校验工具](https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=jsapisign) 比对;签名一律用**服务商**API 私钥(不是子商户) |
**B. 下单参数 / 服务商配置类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| `付款失败,显示父子商户不存在` | 请求头 `mchid` 填了**子商户号**而不是**服务商商户号** | 请求头 `mchid` 必须是服务商号(如 `1482788892``sub_mchid` 才是子商户号(如 `858825313` |
| `appid not match mchid` | `sub_appid``sub_mchid` 没绑定 / 用了顶层 `sp_appid` 但传到了 `sub_appid` 字段 | 商户平台 → 服务商功能 → 特约商户管理 → AppID 关联 |
| 下单报"appid 字段不正确" | `appid` / `sub_appid` 大小写错误(必须全小写) | 检查请求 body 中所有 `appid` |
| 请求体某字段报"字段不正确" | 请求里有空字段或空字段名 | 不传则不要写入 JSON禁止 `"": ""` 这种空键 |
| 纯医保 `CASH_ONLY` 报错 | 误传了 `pay_order_id` / `pay_auth_no` / `med_ins_*` | 见 1.2 错误码段「268529474」`mix_pay_type` 严格清空 |
| 纯医保单CASH_ONLY医保局查到一直"预结算" | 商户错传了 `INSURANCE_ONLY` / `CASH_AND_INSURANCE` | 纯自费场景 `mix_pay_type` 必须 `CASH_ONLY`,否则会通知医保局结算 |
| `REQUEST_BLOCKED`:暂不支持当前商户下单 | 子商户号未在服务商名下开通医保支付权限 | 联系微信医保对接接口人为该 sub_mchid 申请开通 |
| `description` 想自定义为"医保自费支付" | 直接改 `description` 字段值即可 | 微信账单展示沿用 `description` |
| 公众独立模式 vs 服务商模式怎么选? | 自费侧用什么模式,医保侧就跟同模式 | 保持自费 / 医保下单接入模式一致,避免 4 元组错配 |
**C. 回调通知 / 状态查询类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 没收到混合支付回调通知 | 商户响应未按文档要求(非 200/204 或非 JSON微信判定失败后停止重试 | 严格按 [服务商医保混合收款成功通知](https://pay.weixin.qq.com/doc/v3/partner/4012165722.md) 规范应答;多 sub_mchid 共用 URL 必须按解密后的 `sub_mchid` 路由 |
| 自费支付通知重复发,但混合通知不重发 | 自费通知未按规范应答(混合通知已正确应答) | 两个通知都要正确应答;建议幂等 |
| 混合支付订单回调通知到哪个地址? | 走"医保混合下单"接口里的 `callback_url`,不是自费下单的 `notify_url` | 两个 URL 可分别配置,互不干扰 |
| 有自费的混合订单查不到 `transaction_id` | 混合下单回调 / 查单**不返回** `transaction_id` | 必须走自费侧"按 out_trade_no 查单"或自费的支付结果通知拿 `transaction_id` |
| 混合订单只看 `mix_pay_status` 失败就关单 | 自费可能已成功但医保失败 | 必须同时判 `self_pay_status`,自费成功要单独走基础退款 |
| 用户拉起后很久才支付(如 12:23 拉起 12:25 支付) | 用户操作延迟,**属正常行为** | 业务侧设置合理订单有效期(建议 30 分钟)+ 主动查单兜底 |
**D. 退款类**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 纯医保单(无自费)要不要调自费退款? | 不需要 | 直接走医保退款接口 + 调"医保退款通知"同步给微信 |
| 0 元自费要不要调自费退款? | 视实际情况0 元单一般无自费可退 | 仅当存在 `wechat_pay_cash_fee > 0` 才调基础退款 |
| 调了医保退款但微信查到 `insuranceFeeAmt = null` / 状态不变 | 没调微信侧的"医保退款通知"接口 | 退款完成后必须调 [服务商医保退款通知](https://pay.weixin.qq.com/doc/v3/partner/4012166534.md) 同步结果给微信 |
| 旧的 `https://api.weixin.qq.com/payinsurance/refund` 还能用吗? | 不能,是 1.0 接口,已停用 | 2.0 改用医保中台退款 + 微信医保退款通知 |
| 用户什么时候能看到退款提醒? | 仅当走过自费微信退款时,微信会推退款提醒 | 纯医保退款不会推微信侧用户提醒 |
**E. 其他**
| 报错现象 | 真实根因 | 处理 |
|---|---|---|
| 加签 / 验签的 pem 文件医保和自费要分开吗? | 不需要,**用同一份**服务商 API 私钥(子商户无独立证书) | 整个服务商号共用一套 V3 证书 |
| 医保支付有没有 SDK | **暂无官方 SDK**,需按文档自行开发 | 可参考 `示例代码` 中的 Java/Go 调用样例 |
| `refund_time` 严格校验吗? | 不严格校验时间精度,但**格式必须** RFC3339 | `yyyy-MM-DDTHH:mm:ss+08:00` |
| 互联网医院多个小程序能共用医保渠道吗? | 主体一致 + 商户号一致 → 可以;任一不同 → 不可以 | 按子商户 / 子 AppID 独立报备 |
| 医院多个诊疗场景,`order_type` 怎么选? | 按文档枚举值"对症选号"(挂号 / 诊间 / 住院 / 药店 / 互联网医院)一一对应 | 一笔单只能选一个 `order_type` |
| 接口超时怎么办? | 重试一次;仍失败用 Request-Id 找微信侧拿日志 | 自费已扣但医保超时,必须查单兜底 |
### 2.9 前端 fail 回调 msg 解读(小程序 / JSAPI 拉起后失败)
> 来源:[服务商报错排查指引4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) / [从业机构报错排查指引4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md) 阶段 3。
>
> 用户报"用户点了支付按钮但失败 / 收银台一闪而过 / 拉起后没反应"且**带 fail 回调的 msg 文案**时按下表定位;纯权限/鉴权类access denied、no permission to execute、function_not_exist 等)请回到 `2.8 A 客户端调起类`。
| `fail.msg` 内容 | 真实根因 | 处理建议 |
|---|---|---|
| `缺少 mix_trade_no` | 前端拉起接口未传混合单号(`mixTradeNo` 字段) | 检查前端调用参数,确认从下单接口拿到的 `mix_trade_no` 已正确赋值给拉起入参 |
| `缺少自费参数` | 含自费场景下,自费侧调起参数不全 | 检查 `appid`(服务商场景填子商户 `sub_appid` 或服务商 `appid`/ `package` / `signType` / `paySign` / `timeStamp` / `nonceStr` 六件套;其中 `package = "prepay_id=" + prepay_id`**纯医保场景禁传**自费参数 |
| `混合支付超时` | 用户未在 30 秒内完成支付流程 | 检查用户网络 / 引导用户重试;业务侧建议设置订单 30 分钟有效期 + 主动查单(带 `sub_mchid`)兜底 |
| `混合支付订单状态为支付失败` | 自费或医保任一环节失败 | 调"按 mix_trade_no 查单"(必传 `sub_mchid`),看 `self_pay_status``med_ins_pay_status` 哪一侧失败;医保侧失败时取 `med_ins_fail_reason` 看真实原因 |
| `混合支付订单状态异常` | 系统异常 | 取 Request-Id 联系微信医保技术支持群排查(`Wechatpay_BDzhushou` |
| `自费支付失败` | 微信支付收银台自费段失败 | 走标准自费排障:检查 `prepay_id` 是否过期、用户余额、风控等;`paySign` 必须用**服务商**API 私钥 + RSA-SHA256子商户没有独立 API 证书) |
| `用户直接退出医保混合支付` | 用户主动取消(**非异常** | 不要把这类当成失败上报告警;可用作转化漏斗指标 |
| `混合支付订单已关单` | 订单已超时关闭 | 用**新的** `out_trade_no` 重新下单 |
### 2.10 免密授权payAuthNo常见问题
> 来源:[服务商报错排查指引4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) / [从业机构报错排查指引4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md) "其他 - 免密授权"章节。
>
> 免密授权属于**支付前的电子凭证授权阶段**:用户跳转国家局 / 省中台授权页 → 拿到 `authCode` → 后端用 `authCode` 调授权查询接口拿到 `payAuthNo` 等参数 → 才能调医保混合下单。本节问题与下单接口报错无关,命中关键词(`payAuthNo` / `免密授权` / `机构渠道认证编码` / `国家局授权页` 等)时优先看这里。
| # | 问题 | 答案 |
|---|---|---|
| 1 | 授权时提示"该机构渠道认证编码错误" | 按顺序检查:① 国家局反馈单中的机构渠道认证编码是否复制完整(机构自查);② 编码是否含特殊符号,链接拼接时**先 URL encode 再拼接**,并查后端日志(联调同学协助);③ 1 和 2 都正常时,找省中台确认机构参数是否已加入测试环境(联调同学反馈) |
| 2 | 国家局免密授权未正确出现国家局页面 | ① 检查拼接链接是否把国家局免密授权所需的全部参数都拼上(机构自查);② 不同环境调试时,**页面参数和接口返回参数都要按对应环境配置**(联调同学协助)。例:测试环境要核对测试环境的 H5/小程序免密配置是否齐全 |
| 3 | 国家局授权页点授权时报"网络异常" | 凭证内网请求支付中台不通,或医院未在支付中心登记。需医院确认是否已登记 + 省中台自查(联调同学反馈) |
| 4 | 国家局授权页提示"用户信息已过期" | 参数失效,退出多试几次。测试环境中台一般是 IP在微信内打开会做一次 IP 确认,所以参数容易失效——**这是测试环境特有现象** |
| 5 | 用户授权后 `payAuthNo` 怎么获取? | 前端授权拿到 `authCode` 后,**后端**调授权查询接口(`authCode` 作为 `qrcode` 入参),从返回中提取 `payAuthNo` 等业务参数 |
| 6 | 测试环境没有用户 `openid` / `sub_openid`,怎么调授权查询接口? | 测试环境**可以不传 `openid`**,仅用 `authCode` 调即可;正式环境必须传(服务商场景按下单 4 元组规则选 `openid``sub_openid` |
| 7 | 授权查询接口出参中没有所需参数 | 用户信息相关字段是机构在**立项时与腾讯商务沟通申请**,按申请到的字段返回;文档出参仅是参考,实际以立项申请为准 |
| 8 | 授权查询拿到的经纬度是 `(0, 0)` | 用户拒绝了位置授权,无法获取经纬度时返回 `(0, 0)`。前端要引导用户开启位置权限 |
| 9 | 每次支付都要做一次免密授权吗? | **是**`payAuthNo` 一次一用,每笔支付都要先走免密授权拿新的;服务商场景下**禁止跨 sub_mchid 复用** |
| 10 | 用户授权时提示"要设置密码" | 用户只激活了医保电子凭证但未设置支付密码。需用户先在医保电子凭证内设置密码,才能完成授权 |
| 11 | 小程序免密授权如何在测试环境调试? | 把 `envVersion` 改为 `'trial'`(体验版):`envVersion: "release"` 正式版 / `"trial"` 体验版 / `"develop"` 开发版 |
| 12 | 授权后报 `{"code":150502,"message":"所在地区未查询到用户参保信息"}` | 两种可能:① 用户没有对应城市的参保地——若是测试环境,找省中台添加测试人员参保数据(姓名 / 身份证 / 参保地代码 / 参保地名称)同步到国家局后重试;② 用户有参保地但没加入微信测试环境——先解绑,让联调同学添加微信测试环境(姓名 / 身份证 / 微信号 / 手机号)后重试 |
| 13 | 小程序授权完成后没拿到 `authCode`,报错 | `authCode` 应该在 `app.js` 中获取(`onLaunch` / `onShow``query` 里),不要在页面 `onLoad` 里取 |
| 14 | H5 调试正常但小程序调试报"机构渠道认证编码错误" | ① 确认小程序使用的机构渠道认证编码是否与反馈单一致;② 确认小程序跳转的免密链接中机构渠道认证编码**不要做 encode**(与 H5 不同,小程序不需要 URL encode |
| 15 | 电子凭证展不了码,会影响免密授权吗? | **会**。2.0 模式的免密要求用户能成功展码,才能完成 `payAuthNo` 授权 |
| 16 | 小程序免密授权提示"传入小程序 appid 与主体不匹配" | 小程序授权链接上的 `sourceapp`(前 18 位)与合作方在医保局立项时申请的小程序 `appid` 不一致。改成立项申请的 `appid` 再调用(服务商场景一般是子商户报备的小程序 `sub_appid` |
### 2.11 亲情授权(代亲属激活)常见问题
> 来源:[服务商报错排查指引4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) / [从业机构报错排查指引4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md) "其他 - 亲情授权"章节。
>
> 亲情授权是 **`pay_for_relatives = true` 代亲属支付场景的前置条件**——必须先帮亲属(如儿童 / 老人)激活医保电子凭证并建立亲情账户绑定关系,才能在下单时上送 `relative.name` / `relative.id_digest`。本节问题对应下单错误码 `268545964 亲属关系不存在`。
| # | 授权页提示 | 真实根因 | 处理 |
|---|---|---|---|
| 1 | "未查询到该少儿参保信息" | 国家局还没有该小孩的参保信息 | 联系**地方医保局**把数据同步到国家局后重试 |
| 2 | "尚未绑定少儿医保" | 地方医保局亲属库里查不到本人和小孩的亲属关系 | 联系**本地医保局**确认两人是否在亲属库里有亲属关系,有才能用 |
| 3 | "为了亲属的账户安全,请先设置密码" | 授权要求亲属可以正常展码,必须先有密码 | 引导操作人先帮亲属完成密码设置,再发起授权 |
| 4 | "请检查传入的亲人信息是否正确" | 授权链接传入的亲人与地方医保局亲属库里的亲属不是同一个人 | 核对身份证 / 姓名后重传**地方医保局亲属库里登记的那一位** |
| 5 | "该亲人已被他人绑定,你无法帮其激活医保电子凭证" | 该亲人已被别人绑定激活,且达到了代激活数量上限 | 业务侧无法继续,让用户与已绑定方协商解绑 |
| 6 | "亲属已自主激活医保电子凭证使用" | 该亲人已自己在手机微信激活了电子凭证 | 自激活后无法被代激活,业务侧引导亲属本人使用 |
| 7 | "需要亲属在你手机上人脸认证通过后,你可帮助其激活" | 亲人已达一定年龄,代激活需做人脸认证 | 让亲人本人在操作人手机上完成人脸认证 |
| 8 | "本人 / 亲人未参保" | 亲人没设默认参保地 | 非深圳地区可在亲人**展码页**选择对应参保地后再发起授权(系统在持续优化默认参保地逻辑) |
---
## 官方排查指引文档索引
> 当本手册命中关键词但描述不够细时,可对照官方原文校对(含最新更新)。
| 接入模式 | 链接 | 适用场景 |
|---|---|---|
| 服务商模式 | [报错排查指引_医保支付4020401184](https://pay.weixin.qq.com/doc/v3/partner/4020401184.md) | 服务商代普通商户接入(**本手册主用** |
| 服务商模式(间连/从业机构) | [报错排查指引_医保支付4020401288](https://pay.weixin.qq.com/doc/v3/partner/4020401288.md) | 从业机构代医保定点机构接入(**本手册主用** |
| 商户模式 | [报错排查指引_医保支付4020401138](https://pay.weixin.qq.com/doc/v3/merchant/4020401138.md) | 直连商户场景对照(不适用服务商) |
---
## 排障信息收集清单
两条路径都未命中时,请用户提供:接入模式(服务商 / 间连)、出错环节(自费下单 / 医保下单 / 调起 / 查单 / 退款通知 / 回调路由 / 对账 / **免密授权 / 亲情授权**、HTTP 状态码 + 完整响应体、Request-Id含尾段错误码、**服务商 mchid + 子商户 sub_mchid** + 业务单号 `out_trade_no` / `mix_trade_no` + 医院 `serial_no` + `med_inst_no` + `city_id` + 请求时间;如属医保支付失败还需取**查单接口的 `med_ins_fail_reason`**;如属授权阶段还需取 `authCode` / 授权链接 / 机构渠道认证编码 / 子商户小程序 `sub_appid`