Add WeChat Pay local skills
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 商户模式回调处理
|
||||
|
||||
> 本文档为微信支付**通用回调处理规范**,适用于**商户**、**品牌直连**、**服务商**三种接入模式。三方在**回调报文解密、IP 白名单、应答要求、幂等、收不到回调排查**上完全一致;仅在 **`notify_url` 配置方式**和**回调归属维度**上有差异,差异点已在文中以"模式分支"标注。
|
||||
>
|
||||
> 各业务(如商品券、营销立减金、基础支付等)的**事件类型清单、解密后业务字段、二次确认接口路径**等业务专属内容,由各业务自身的接口文档提供,不在本通用文档范围内。
|
||||
|
||||
## 一、回调处理
|
||||
|
||||
### 前提条件
|
||||
|
||||
1. **必须设置 APIv3 密钥**(32 字节),未设置不会收到任何回调
|
||||
2. **必须配置 `notify_url`**,按接入模式分支处理:
|
||||
- **商户模式**:在下单/业务请求体里直接传入 `notify_url` 字段(如 JSAPI 下单),或在商户平台「产品中心 → 开发配置」中预设
|
||||
- **品牌直连**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /brand/marketing/{业务}/notify-config`),品牌维度
|
||||
- **服务商模式**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /v3/marketing/{业务}/notify-config`),服务商维度,所有子商户/品牌共用同一地址
|
||||
3. 回调地址要求:HTTPS + 域名已 ICP 备案 + 公网可访问
|
||||
4. 不能使用内网地址(127.0.0.1 / 192.168.x.x / localhost)
|
||||
|
||||
### 回调 IP 白名单
|
||||
|
||||
商户侧对微信支付回调 IP 有防火墙策略限制的,需要对以下 IP 段开通白名单:
|
||||
|
||||
|
||||
| 出口 | 网段/IP |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| 上海电信出口网段 | 101.226.103.0/25 |
|
||||
| 上海联通出口网段 | 140.207.54.0/25 |
|
||||
| 上海CAP出口网段 | 121.51.58.128/25 |
|
||||
| 深圳电信出口网段 | 183.3.234.0/25 |
|
||||
| 深圳联通出口网段 | 58.251.80.0/25 |
|
||||
| 深圳CAP出口网段 | 121.51.30.128/25 |
|
||||
| 香港出口网段 | 203.205.219.128/25 |
|
||||
| 广州腾讯云出口IP | 81.71.199.64, 81.71.198.25, 81.71.199.59 |
|
||||
| 退款结果通知、分账动账通知IP | 175.24.214.208, 175.24.211.24, 175.24.213.135, 109.244.180.23, 114.132.203.119, 43.139.43.69 |
|
||||
|
||||
|
||||
同时关闭 WAF/CC 防护对回调 URL 的拦截,避免误将微信支付回调请求判定为恶意请求。
|
||||
|
||||
### 回调报文与解密
|
||||
|
||||
回调通知整体结构(三种接入模式完全一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "通知唯一ID",
|
||||
"create_time": "2025-08-02T00:00:00+08:00",
|
||||
"event_type": "事件类型,由具体业务定义",
|
||||
"resource_type": "encrypt-resource",
|
||||
"resource": { /* 加密资源对象,结构见下 */ }
|
||||
}
|
||||
```
|
||||
|
||||
`resource` 字段为加密资源对象,三种接入模式完全一致(参考官方文档:[商户](https://pay.weixin.qq.com/doc/v3/merchant/4012071382) / [品牌](https://pay.weixin.qq.com/doc/brand/4015407591) / 服务商):
|
||||
|
||||
```json
|
||||
{
|
||||
"original_type": "transaction",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "...",
|
||||
"nonce": "...",
|
||||
"associated_data": ""
|
||||
}
|
||||
```
|
||||
|
||||
- 算法:`AEAD_AES_256_GCM`,密钥:**APIv3 密钥(32 字节)—— 商户、品牌、服务商三方完全相同**
|
||||
- 从 `resource` 取 `nonce`、`ciphertext`、`associated_data` 进行解密
|
||||
- ‼️ 加密报文中的 `nonce` 与请求签名串中的随机串**没有任何关系**,是两个独立的值
|
||||
|
||||
### 回调处理要求
|
||||
|
||||
1. **必须返回 HTTP 2XX**(200 或 204),否则微信支付会重试
|
||||
2. **必须在 5 秒内应答**
|
||||
3. **必须做幂等处理**(按业务唯一标识 + `event_type` 去重)
|
||||
4. **必须验签**,防止伪造通知。验签密钥支持两种,**与接入模式无关**,取决于商户/服务商在平台的密钥配置:
|
||||
- **微信支付公钥**(推荐,2024 年后新增,公钥 ID 形如 `PUB_KEY_ID_xxxxxx`)
|
||||
- **微信支付平台证书**(旧方式,需定期下载更新,仍可继续使用)
|
||||
- 任意一种接入模式(商户 / 品牌 / 服务商)均可自由选择上述任一种验签方式
|
||||
5. 签名探测流量以 `WECHATPAY/SIGNTEST/` 开头,需正确处理
|
||||
6. 即使业务处理异常,也建议返回 200,通过告警系统人工介入
|
||||
|
||||
### 回调收不到的常见排查场景
|
||||
|
||||
#### 一、前置配置缺失
|
||||
|
||||
1. **未设置 APIv3 密钥** — 微信支付不会发送回调通知
|
||||
2. **未配置 `notify_url`** — 接口或商户平台未配置回调通知接收地址,微信支付不会发送回调通知
|
||||
|
||||
#### 二、回调地址配置类问题
|
||||
|
||||
1. **地址格式错误**
|
||||
- `notify_url` 未以 `https://` 或 `http://` 开头
|
||||
- URL 中只有域名,缺少具体路径(如 `http://www.weixin.qq.com`)
|
||||
- URL 携带了参数
|
||||
- 使用了内网地址(`127.0.0.1`、`192.168.x.x`、`localhost`)
|
||||
2. **域名未备案或解析异常**
|
||||
- 域名未完成工信部 ICP 备案(国内服务器必须)
|
||||
- DNS 解析失效(解析记录过期、未配置正确的 A/AAAA 记录)
|
||||
|
||||
#### 三、网络与服务器连通性问题
|
||||
|
||||
1. **防火墙/安全组拦截** — 未对上方「回调 IP 白名单」中的 IP 段开通入站规则
|
||||
2. **WAF/CC 防护误拦** — 安全策略将微信支付回调请求误判为恶意请求
|
||||
3. **网络链路故障** — 丢包或延迟过高(超过 3 秒)导致请求超时
|
||||
4. **CDN/反向代理配置异常** — Nginx、Cloudflare 等未将回调请求正确转发至后端服务
|
||||
|
||||
#### 四、回调处理逻辑问题
|
||||
|
||||
1. **登录态校验** — `notify_url` 的代码逻辑不能做登录态校验
|
||||
2. **未在 5 秒内应答** — 微信支付会认为通知失败并重复发送
|
||||
3. **未做幂等** — 同一通知可能多次发送,必须按业务唯一标识 + `event_type` 去重
|
||||
|
||||
### 各模式回调归属说明
|
||||
|
||||
| 模式 | 归属维度 | 区分多主体的关键字段 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| **商户** | 商户维度 | 无(回调本就属于该商户) | 一个商户一个回调地址 |
|
||||
| **品牌直连** | 品牌维度 | `brand_id` | 一个品牌一个回调地址 |
|
||||
| **服务商** | 服务商维度 | `sub_mchid` / `brand_id` | 所有子商户/品牌共用同一回调地址,**必须按字段路由到正确的子主体**,否则会出现"A 商户的订单被 B 商户业务处理"的串单事故 |
|
||||
|
||||
## 二、错误处理策略
|
||||
|
||||
|
||||
| 错误类型 | 处理策略 |
|
||||
| ---------------- | ---------------------------- |
|
||||
| 500 SYSTEM_ERROR | 使用相同请求号重试(指数退避) |
|
||||
| 400 参数错误 | 修正参数后重试 |
|
||||
| 401 签名错误 | 检查验签密钥(公钥 / 平台证书)是否与平台配置一致;服务商还需检查请求头 `Wechatpay-Serial` 是否携带正确的证书/公钥序列号 |
|
||||
| 回调超时 | 返回 200,异步补偿处理 |
|
||||
| 解密失败 | 检查 APIv3 密钥是否正确(32 字节、与商户/服务商平台配置一致)|
|
||||
|
||||
|
||||
## 三、幂等设计
|
||||
|
||||
- 所有写操作必须使用业务侧生成的唯一请求号(如 `out_trade_no`、`out_request_no`、各业务自定义的请求号)
|
||||
- 相同请求号重复请求不会创建重复资源
|
||||
- 建议格式:`{业务前缀}_{日期}_{序号}`,例如 `pay_20250801_000001`
|
||||
|
||||
## 四、请求域名
|
||||
|
||||
- 主域名: `https://api.mch.weixin.qq.com`
|
||||
- 备域名: `https://api2.mch.weixin.qq.com`
|
||||
@@ -0,0 +1,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` 调用本接口同步医保退款结果)。
|
||||
@@ -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 请求次数超过限制` | 🔴 致命 |
|
||||
@@ -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. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 报警,调用查单接口兜底 |
|
||||
| 用户客诉「钱已扣未发货」 | 优先以查单结果为准,不依赖回调记录 |
|
||||
@@ -0,0 +1,71 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Host = "https://api.mch.weixin.qq.com"
|
||||
|
||||
// SendGet 发送 GET 请求并返回已验签的应答 Body
|
||||
func SendGet(config *MchConfig, uri string) ([]byte, error) {
|
||||
return sendRequest(config, "GET", uri, nil)
|
||||
}
|
||||
|
||||
// SendPost 发送 POST 请求并返回已验签的应答 Body
|
||||
func SendPost(config *MchConfig, uri string, reqBody []byte) ([]byte, error) {
|
||||
return sendRequest(config, "POST", uri, reqBody)
|
||||
}
|
||||
|
||||
func sendRequest(config *MchConfig, method string, uri string, reqBody []byte) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if reqBody != nil {
|
||||
bodyReader = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest(method, Host+uri, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
|
||||
authorization, err := BuildAuthorization(config.MchId(), config.CertificateSerialNo(),
|
||||
config.PrivateKey(), method, uri, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
if reqBody != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody, err := ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tjfoc/gmsm/sm3"
|
||||
)
|
||||
|
||||
type MchConfig struct {
|
||||
mchId string
|
||||
certificateSerialNo string
|
||||
privateKeyFilePath string
|
||||
wechatPayPublicKeyId string
|
||||
wechatPayPublicKeyFilePath string
|
||||
privateKey *rsa.PrivateKey
|
||||
wechatPayPublicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) MchId() string {
|
||||
return c.mchId
|
||||
}
|
||||
|
||||
func (c *MchConfig) CertificateSerialNo() string {
|
||||
return c.certificateSerialNo
|
||||
}
|
||||
|
||||
func (c *MchConfig) PrivateKey() *rsa.PrivateKey {
|
||||
return c.privateKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKeyId() string {
|
||||
return c.wechatPayPublicKeyId
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKey() *rsa.PublicKey {
|
||||
return c.wechatPayPublicKey
|
||||
}
|
||||
|
||||
func CreateMchConfig(
|
||||
mchId string,
|
||||
certificateSerialNo string,
|
||||
privateKeyFilePath string,
|
||||
wechatPayPublicKeyId string,
|
||||
wechatPayPublicKeyFilePath string,
|
||||
) (*MchConfig, error) {
|
||||
mchConfig := &MchConfig{
|
||||
mchId: mchId,
|
||||
certificateSerialNo: certificateSerialNo,
|
||||
privateKeyFilePath: privateKeyFilePath,
|
||||
wechatPayPublicKeyId: wechatPayPublicKeyId,
|
||||
wechatPayPublicKeyFilePath: wechatPayPublicKeyFilePath,
|
||||
}
|
||||
privateKey, err := LoadPrivateKeyWithPath(mchConfig.privateKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.privateKey = privateKey
|
||||
wechatPayPublicKey, err := LoadPublicKeyWithPath(mchConfig.wechatPayPublicKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.wechatPayPublicKey = wechatPayPublicKey
|
||||
return mchConfig, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKey(privateKeyStr string) (privateKey *rsa.PrivateKey, err error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode private key err")
|
||||
}
|
||||
if block.Type != "PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PRVATE KEY")
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key err:%s", err.Error())
|
||||
}
|
||||
privateKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not a RSA private key")
|
||||
}
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func LoadPublicKey(publicKeyStr string) (publicKey *rsa.PublicKey, err error) {
|
||||
block, _ := pem.Decode([]byte(publicKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("decode public key error")
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PUBLIC KEY")
|
||||
}
|
||||
key, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse public key err:%s", err.Error())
|
||||
}
|
||||
publicKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not rsa public key", publicKeyStr)
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKeyWithPath(path string) (privateKey *rsa.PrivateKey, err error) {
|
||||
privateKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPrivateKey(string(privateKeyBytes))
|
||||
}
|
||||
|
||||
func LoadPublicKeyWithPath(path string) (publicKey *rsa.PublicKey, err error) {
|
||||
publicKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPublicKey(string(publicKeyBytes))
|
||||
}
|
||||
|
||||
func EncryptOAEPWithPublicKey(message string, publicKey *rsa.PublicKey) (ciphertext string, err error) {
|
||||
if publicKey == nil {
|
||||
return "", fmt.Errorf("you should input *rsa.PublicKey")
|
||||
}
|
||||
ciphertextByte, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, publicKey, []byte(message), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt message with public key err:%s", err.Error())
|
||||
}
|
||||
ciphertext = base64.StdEncoding.EncodeToString(ciphertextByte)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func DecryptAES256GCM(aesKey, associatedData, nonce, ciphertext string) (plaintext string, err error) {
|
||||
decodedCiphertext, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := aes.NewCipher([]byte(aesKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataBytes, err := gcm.Open(nil, []byte(nonce), decodedCiphertext, []byte(associatedData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(dataBytes), nil
|
||||
}
|
||||
|
||||
func SignSHA256WithRSA(source string, privateKey *rsa.PrivateKey) (signature string, err error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key should not be nil")
|
||||
}
|
||||
h := crypto.Hash.New(crypto.SHA256)
|
||||
_, err = h.Write([]byte(source))
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
hashed := h.Sum(nil)
|
||||
signatureByte, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signatureByte), nil
|
||||
}
|
||||
|
||||
func VerifySHA256WithRSA(source string, signature string, publicKey *rsa.PublicKey) error {
|
||||
if publicKey == nil {
|
||||
return fmt.Errorf("public key should not be nil")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify failed: signature is not base64 encoded")
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(source))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify signature with public key error:%s", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateNonce() (string, error) {
|
||||
const (
|
||||
NonceSymbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
NonceLength = 32
|
||||
)
|
||||
|
||||
bytes := make([]byte, NonceLength)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
symbolsByteLength := byte(len(NonceSymbols))
|
||||
for i, b := range bytes {
|
||||
bytes[i] = NonceSymbols[b%symbolsByteLength]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func BuildAuthorization(
|
||||
mchid string,
|
||||
certificateSerialNo string,
|
||||
privateKey *rsa.PrivateKey,
|
||||
method string,
|
||||
canonicalURL string,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
const (
|
||||
SignatureMessageFormat = "%s\n%s\n%d\n%s\n%s\n"
|
||||
HeaderAuthorizationFormat = "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\""
|
||||
)
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp := time.Now().Unix()
|
||||
message := fmt.Sprintf(SignatureMessageFormat, method, canonicalURL, timestamp, nonce, body)
|
||||
signature, err := SignSHA256WithRSA(message, privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorization := fmt.Sprintf(
|
||||
HeaderAuthorizationFormat,
|
||||
mchid, nonce, timestamp, certificateSerialNo, signature,
|
||||
)
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
func ExtractResponseBody(response *http.Response) ([]byte, error) {
|
||||
if response.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body err:[%s]", err.Error())
|
||||
}
|
||||
response.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
|
||||
const (
|
||||
WechatPayTimestamp = "Wechatpay-Timestamp"
|
||||
WechatPayNonce = "Wechatpay-Nonce"
|
||||
WechatPaySignature = "Wechatpay-Signature"
|
||||
WechatPaySerial = "Wechatpay-Serial"
|
||||
RequestID = "Request-Id"
|
||||
)
|
||||
|
||||
func validateWechatPaySignature(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
timestampStr := headers.Get(WechatPayTimestamp)
|
||||
serialNo := headers.Get(WechatPaySerial)
|
||||
signature := headers.Get(WechatPaySignature)
|
||||
nonce := headers.Get(WechatPayNonce)
|
||||
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
if time.Now().Sub(time.Unix(timestamp, 0)) > 5*time.Minute {
|
||||
return fmt.Errorf("timestamp expired: %d", timestamp)
|
||||
}
|
||||
|
||||
if serialNo != wechatpayPublicKeyId {
|
||||
return fmt.Errorf(
|
||||
"serial-no mismatch: got %s, expected %s",
|
||||
serialNo,
|
||||
wechatpayPublicKeyId,
|
||||
)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s\n%s\n%s\n", timestampStr, nonce, body)
|
||||
if err := VerifySHA256WithRSA(message, signature, wechatpayPublicKey); err != nil {
|
||||
return fmt.Errorf("invalid signature: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateResponse(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate response err: %w, RequestID: %s", err, headers.Get(RequestID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate notification err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
OriginalType string `json:"original_type"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime *time.Time `json:"create_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Resource *Resource `json:"resource"`
|
||||
Summary string `json:"summary"`
|
||||
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
func (c *Notification) validate() error {
|
||||
if c.Resource == nil {
|
||||
return errors.New("resource is nil")
|
||||
}
|
||||
|
||||
if c.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
||||
return fmt.Errorf("unsupported algorithm: %s", c.Resource.Algorithm)
|
||||
}
|
||||
|
||||
if c.Resource.Ciphertext == "" {
|
||||
return errors.New("ciphertext is empty")
|
||||
}
|
||||
|
||||
if c.Resource.AssociatedData == "" {
|
||||
return errors.New("associated_data is empty")
|
||||
}
|
||||
|
||||
if c.Resource.Nonce == "" {
|
||||
return errors.New("nonce is empty")
|
||||
}
|
||||
|
||||
if c.Resource.OriginalType == "" {
|
||||
return fmt.Errorf("original_type is empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Notification) decrypt(apiv3Key string) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("notification format err: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := DecryptAES256GCM(
|
||||
apiv3Key,
|
||||
c.Resource.AssociatedData,
|
||||
c.Resource.Nonce,
|
||||
c.Resource.Ciphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
c.Plaintext = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
apiv3Key string,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) (*Notification, error) {
|
||||
if err := validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notification := &Notification{}
|
||||
if err := json.Unmarshal(body, notification); err != nil {
|
||||
return nil, fmt.Errorf("parse notification err: %w", err)
|
||||
}
|
||||
|
||||
if err := notification.decrypt(apiv3Key); err != nil {
|
||||
return nil, fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
type ApiException struct {
|
||||
statusCode int
|
||||
header http.Header
|
||||
body []byte
|
||||
errorCode string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (c *ApiException) Error() string {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(fmt.Sprintf("api error:[StatusCode: %d, Body: %s", c.statusCode, string(c.body)))
|
||||
if len(c.header) > 0 {
|
||||
buf.WriteString(" Header: ")
|
||||
for key, value := range c.header {
|
||||
buf.WriteString(fmt.Sprintf("\n - %v=%v", key, value))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("]")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (c *ApiException) StatusCode() int {
|
||||
return c.statusCode
|
||||
}
|
||||
|
||||
func (c *ApiException) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *ApiException) Body() []byte {
|
||||
return c.body
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorCode() string {
|
||||
return c.errorCode
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorMessage() string {
|
||||
return c.errorMessage
|
||||
}
|
||||
|
||||
func NewApiException(statusCode int, header http.Header, body []byte) error {
|
||||
ret := &ApiException{
|
||||
statusCode: statusCode,
|
||||
header: header,
|
||||
body: body,
|
||||
}
|
||||
|
||||
bodyObject := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &bodyObject); err == nil {
|
||||
if val, ok := bodyObject["code"]; ok {
|
||||
ret.errorCode = val.(string)
|
||||
}
|
||||
if val, ok := bodyObject["message"]; ok {
|
||||
ret.errorMessage = val.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func Time(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func String(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func Bytes(b []byte) *[]byte {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Bool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Float64(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Float32(f float32) *float32 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Int64(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func Int32(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func generateHashFromStream(reader io.Reader, hashFunc func() hash.Hash, algorithmName string) (string, error) {
|
||||
hash := hashFunc()
|
||||
if _, err := io.Copy(hash, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for %s: %w", algorithmName, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func GenerateSHA256FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha256.New, "SHA256")
|
||||
}
|
||||
|
||||
func GenerateSHA1FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha1.New, "SHA1")
|
||||
}
|
||||
|
||||
func GenerateSM3FromStream(reader io.Reader) (string, error) {
|
||||
h := sm3.New()
|
||||
if _, err := io.Copy(h, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for SM3: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,需引导用户在微信「我 → 服务 → 医疗健康 → 医保电子凭证」中激活
|
||||
@@ -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` 顺序拼接(每行末尾换行)
|
||||
@@ -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 报警,调用查单接口兜底 |
|
||||
| 用户客诉「钱已扣未发货」 | 优先以查单结果为准,不依赖回调记录 |
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.java.utils;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
/**
|
||||
* 微信支付 HTTP 客户端,封装了请求签名、发送、应答验签的完整流程。
|
||||
* 依赖 WXPayUtility 提供的签名、验签、序列化等基础能力。
|
||||
*/
|
||||
public class WXPayClient {
|
||||
private static final String HOST = "https://api.mch.weixin.qq.com";
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public WXPayClient(String mchid, String certificateSerialNo, String privateKeyFilePath,
|
||||
String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 GET 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendGet(String uri) {
|
||||
return sendRequest("GET", uri, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendPost(String uri, String reqBody) {
|
||||
return sendRequest("POST", uri, reqBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公钥加密敏感信息
|
||||
*/
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
private String sendRequest(String method, String uri, String reqBody) {
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(
|
||||
mchid, certificateSerialNo, privateKey, method, uri, reqBody));
|
||||
|
||||
if (reqBody != null) {
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(method, body);
|
||||
} else {
|
||||
reqBuilder.method(method, null);
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
return respBody;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.java.utils;
|
||||
|
||||
import com.google.gson.ExclusionStrategy;
|
||||
import com.google.gson.FieldAttributes;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
import okhttp3.Headers;
|
||||
import okhttp3.Response;
|
||||
import okio.BufferedSource;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.security.MessageDigest;
|
||||
import java.io.InputStream;
|
||||
import org.bouncycastle.crypto.digests.SM3Digest;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import java.security.Security;
|
||||
|
||||
public class WXPayUtility {
|
||||
private static final Gson gson = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.addSerializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.addDeserializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.deserialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.create();
|
||||
private static final char[] SYMBOLS =
|
||||
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
|
||||
public static String toJson(Object object) {
|
||||
return gson.toJson(object);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
|
||||
return gson.fromJson(json, classOfT);
|
||||
}
|
||||
|
||||
private static String readKeyStringFromPath(String keyPath) {
|
||||
try {
|
||||
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(
|
||||
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
|
||||
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePublic(
|
||||
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromPath(String keyPath) {
|
||||
return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static String createNonce(int length) {
|
||||
char[] buf = new char[length];
|
||||
for (int i = 0; i < length; ++i) {
|
||||
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
|
||||
}
|
||||
return new String(buf);
|
||||
}
|
||||
|
||||
public static String encrypt(PublicKey publicKey, String plaintext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Plaintext is too long", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
|
||||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Ciphertext decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
|
||||
byte[] ciphertext) {
|
||||
final String transformation = "AES/GCM/NoPadding";
|
||||
final String algorithm = "AES";
|
||||
final int tagLengthBit = 128;
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(key, algorithm),
|
||||
new GCMParameterSpec(tagLengthBit, nonce));
|
||||
if (associatedData != null) {
|
||||
cipher.updateAAD(associatedData);
|
||||
}
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (InvalidKeyException
|
||||
| InvalidAlgorithmParameterException
|
||||
| BadPaddingException
|
||||
| IllegalBlockSizeException
|
||||
| NoSuchAlgorithmException
|
||||
| NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
|
||||
transformation), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sign(String message, String algorithm, PrivateKey privateKey) {
|
||||
byte[] sign;
|
||||
try {
|
||||
Signature signature = Signature.getInstance(algorithm);
|
||||
signature.initSign(privateKey);
|
||||
signature.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
sign = signature.sign();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("An error occurred during the sign process.", e);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(sign);
|
||||
}
|
||||
|
||||
public static boolean verify(String message, String signature, String algorithm,
|
||||
PublicKey publicKey) {
|
||||
try {
|
||||
Signature sign = Signature.getInstance(algorithm);
|
||||
sign.initVerify(publicKey);
|
||||
sign.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
return sign.verify(Base64.getDecoder().decode(signature));
|
||||
} catch (SignatureException e) {
|
||||
return false;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("verify uses an illegal publickey.", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String buildAuthorization(String mchid, String certificateSerialNo,
|
||||
PrivateKey privateKey,
|
||||
String method, String uri, String body) {
|
||||
String nonce = createNonce(32);
|
||||
long timestamp = Instant.now().getEpochSecond();
|
||||
|
||||
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
|
||||
body == null ? "" : body);
|
||||
|
||||
String signature = sign(message, "SHA256withRSA", privateKey);
|
||||
|
||||
return String.format(
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
|
||||
"timestamp=\"%d\",serial_no=\"%s\"",
|
||||
mchid, nonce, signature, timestamp, certificateSerialNo);
|
||||
}
|
||||
|
||||
private static String calculateHash(InputStream inputStream, String algorithm) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(algorithm + " algorithm not available", e);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-256");
|
||||
}
|
||||
|
||||
public static String sha1(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-1");
|
||||
}
|
||||
|
||||
public static String sm3(InputStream inputStream) {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
try {
|
||||
SM3Digest digest = new SM3Digest();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hashBytes, 0);
|
||||
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(String content) {
|
||||
try {
|
||||
return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(Map<String, Object> params) {
|
||||
if (params == null || params.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (Entry<String, Object> entry : params.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof List) {
|
||||
List<?> list = (List<?>) entry.getValue();
|
||||
for (Object temp : list) {
|
||||
appendParam(result, key, temp);
|
||||
}
|
||||
} else {
|
||||
appendParam(result, key, value);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder result, String key, Object value) {
|
||||
if (result.length() > 0) {
|
||||
result.append("&");
|
||||
}
|
||||
|
||||
String valueString;
|
||||
if (value instanceof String || value instanceof Number ||
|
||||
value instanceof Boolean || value instanceof Enum) {
|
||||
valueString = value.toString();
|
||||
} else {
|
||||
valueString = toJson(value);
|
||||
}
|
||||
|
||||
result.append(key)
|
||||
.append("=")
|
||||
.append(urlEncode(valueString));
|
||||
}
|
||||
|
||||
public static String extractBody(Response response) {
|
||||
if (response.body() == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedSource source = response.body().source();
|
||||
return source.readUtf8();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(String.format("An error occurred during reading response body. " +
|
||||
"Status: %d", response.code()), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
|
||||
Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
String requestId = headers.get("Request-ID");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
|
||||
"%s", wechatpayPublicKeyId, serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed,the WechatPay signature is incorrect.%n"
|
||||
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers.get("Request-ID"), headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateNotification(String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
|
||||
"Remote: %s",
|
||||
wechatpayPublicKeyId,
|
||||
serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, WechatPay signature is incorrect.\n"
|
||||
+ "responseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
|
||||
Notification notification = gson.fromJson(body, Notification.class);
|
||||
notification.decrypt(apiv3Key);
|
||||
return notification;
|
||||
}
|
||||
|
||||
public static class ApiException extends RuntimeException {
|
||||
private static final long serialVersionUID = 2261086748874802175L;
|
||||
|
||||
private final int statusCode;
|
||||
private final String body;
|
||||
private final Headers headers;
|
||||
private final String errorCode;
|
||||
private final String errorMessage;
|
||||
|
||||
public ApiException(int statusCode, String body, Headers headers) {
|
||||
super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
|
||||
body, headers));
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
|
||||
if (body != null && !body.isEmpty()) {
|
||||
JsonElement code;
|
||||
JsonElement message;
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
|
||||
code = jsonObject.get("code");
|
||||
message = jsonObject.get("message");
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
code = null;
|
||||
message = null;
|
||||
}
|
||||
this.errorCode = code == null ? null : code.getAsString();
|
||||
this.errorMessage = message == null ? null : message.getAsString();
|
||||
} else {
|
||||
this.errorCode = null;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public Headers getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Notification {
|
||||
@SerializedName("id")
|
||||
private String id;
|
||||
@SerializedName("create_time")
|
||||
private String createTime;
|
||||
@SerializedName("event_type")
|
||||
private String eventType;
|
||||
@SerializedName("resource_type")
|
||||
private String resourceType;
|
||||
@SerializedName("summary")
|
||||
private String summary;
|
||||
@SerializedName("resource")
|
||||
private Resource resource;
|
||||
private String plaintext;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public String getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public Resource getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public String getPlaintext() {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (resource == null) {
|
||||
throw new IllegalArgumentException("Missing required field `resource` in notification");
|
||||
}
|
||||
resource.validate();
|
||||
}
|
||||
|
||||
private void decrypt(String apiv3Key) {
|
||||
validate();
|
||||
|
||||
plaintext = aesAeadDecrypt(
|
||||
apiv3Key.getBytes(StandardCharsets.UTF_8),
|
||||
resource.associatedData.getBytes(StandardCharsets.UTF_8),
|
||||
resource.nonce.getBytes(StandardCharsets.UTF_8),
|
||||
Base64.getDecoder().decode(resource.ciphertext)
|
||||
);
|
||||
}
|
||||
|
||||
public static class Resource {
|
||||
@SerializedName("algorithm")
|
||||
private String algorithm;
|
||||
|
||||
@SerializedName("ciphertext")
|
||||
private String ciphertext;
|
||||
|
||||
@SerializedName("associated_data")
|
||||
private String associatedData;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("original_type")
|
||||
private String originalType;
|
||||
|
||||
public String getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String getCiphertext() {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
public String getAssociatedData() {
|
||||
return associatedData;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getOriginalType() {
|
||||
return originalType;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (algorithm == null || algorithm.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
|
||||
throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
|
||||
"Notification.Resource", algorithm));
|
||||
}
|
||||
|
||||
if (ciphertext == null || ciphertext.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (associatedData == null || associatedData.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `associatedData` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
|
||||
if (nonce == null || nonce.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (originalType == null || originalType.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `originalType` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getContentTypeByFileName(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = "";
|
||||
int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
|
||||
extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Map<String, String> contentTypeMap = new HashMap<>();
|
||||
contentTypeMap.put("png", "image/png");
|
||||
contentTypeMap.put("jpg", "image/jpeg");
|
||||
contentTypeMap.put("jpeg", "image/jpeg");
|
||||
contentTypeMap.put("gif", "image/gif");
|
||||
contentTypeMap.put("bmp", "image/bmp");
|
||||
contentTypeMap.put("webp", "image/webp");
|
||||
contentTypeMap.put("svg", "image/svg+xml");
|
||||
contentTypeMap.put("ico", "image/x-icon");
|
||||
contentTypeMap.put("pdf", "application/pdf");
|
||||
contentTypeMap.put("doc", "application/msword");
|
||||
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
contentTypeMap.put("xls", "application/vnd.ms-excel");
|
||||
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
|
||||
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
contentTypeMap.put("txt", "text/plain");
|
||||
contentTypeMap.put("html", "text/html");
|
||||
contentTypeMap.put("css", "text/css");
|
||||
contentTypeMap.put("js", "application/javascript");
|
||||
contentTypeMap.put("json", "application/json");
|
||||
contentTypeMap.put("xml", "application/xml");
|
||||
contentTypeMap.put("csv", "text/csv");
|
||||
contentTypeMap.put("mp3", "audio/mpeg");
|
||||
contentTypeMap.put("wav", "audio/wav");
|
||||
contentTypeMap.put("mp4", "video/mp4");
|
||||
contentTypeMap.put("avi", "video/x-msvideo");
|
||||
contentTypeMap.put("mov", "video/quicktime");
|
||||
contentTypeMap.put("zip", "application/zip");
|
||||
contentTypeMap.put("rar", "application/x-rar-compressed");
|
||||
contentTypeMap.put("7z", "application/x-7z-compressed");
|
||||
|
||||
return contentTypeMap.getOrDefault(extension, "application/octet-stream");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
# 商户模式接口索引
|
||||
|
||||
> 根据用户确认的开发语言加载对应文件,Java/Go 目录结构一致。
|
||||
> 本索引覆盖商户视角下的全部医保支付服务端接口、客户端拉起脚本、回调通知报文说明,以及共用 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 客户端,封装请求签名 → 发送 → 验签 |
|
||||
@@ -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 20(Request-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` 的时间间隔;超过医保规定时长(一般 5–10 分钟)的,要求前端重新走授权
|
||||
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 3339;JSON 层级错误;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` / 授权链接 / 机构渠道认证编码。
|
||||
@@ -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 医院业务处理"的串单事故。
|
||||
@@ -0,0 +1,141 @@
|
||||
# 服务商模式回调处理
|
||||
|
||||
> 本文档为微信支付**通用回调处理规范**,适用于**商户**、**品牌直连**、**服务商**三种接入模式。三方在**回调报文解密、IP 白名单、应答要求、幂等、收不到回调排查**上完全一致;仅在 **`notify_url` 配置方式**和**回调归属维度**上有差异,差异点已在文中以"模式分支"标注。
|
||||
>
|
||||
> 各业务(如商品券、营销立减金、基础支付等)的**事件类型清单、解密后业务字段、二次确认接口路径**等业务专属内容,由各业务自身的接口文档提供,不在本通用文档范围内。
|
||||
|
||||
## 一、回调处理
|
||||
|
||||
### 前提条件
|
||||
|
||||
1. **必须设置 APIv3 密钥**(32 字节),未设置不会收到任何回调
|
||||
2. **必须配置 `notify_url`**,按接入模式分支处理:
|
||||
- **商户模式**:在下单/业务请求体里直接传入 `notify_url` 字段(如 JSAPI 下单),或在商户平台「产品中心 → 开发配置」中预设
|
||||
- **品牌直连**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /brand/marketing/{业务}/notify-config`),品牌维度
|
||||
- **服务商模式**:调用对应业务的「设置事件通知地址」接口(路径形如 `POST /v3/marketing/{业务}/notify-config`),服务商维度,所有子商户/品牌共用同一地址
|
||||
3. 回调地址要求:HTTPS + 域名已 ICP 备案 + 公网可访问
|
||||
4. 不能使用内网地址(127.0.0.1 / 192.168.x.x / localhost)
|
||||
|
||||
### 回调 IP 白名单
|
||||
|
||||
商户侧对微信支付回调 IP 有防火墙策略限制的,需要对以下 IP 段开通白名单:
|
||||
|
||||
|
||||
| 出口 | 网段/IP |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| 上海电信出口网段 | 101.226.103.0/25 |
|
||||
| 上海联通出口网段 | 140.207.54.0/25 |
|
||||
| 上海CAP出口网段 | 121.51.58.128/25 |
|
||||
| 深圳电信出口网段 | 183.3.234.0/25 |
|
||||
| 深圳联通出口网段 | 58.251.80.0/25 |
|
||||
| 深圳CAP出口网段 | 121.51.30.128/25 |
|
||||
| 香港出口网段 | 203.205.219.128/25 |
|
||||
| 广州腾讯云出口IP | 81.71.199.64, 81.71.198.25, 81.71.199.59 |
|
||||
| 退款结果通知、分账动账通知IP | 175.24.214.208, 175.24.211.24, 175.24.213.135, 109.244.180.23, 114.132.203.119, 43.139.43.69 |
|
||||
|
||||
|
||||
同时关闭 WAF/CC 防护对回调 URL 的拦截,避免误将微信支付回调请求判定为恶意请求。
|
||||
|
||||
### 回调报文与解密
|
||||
|
||||
回调通知整体结构(三种接入模式完全一致):
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "通知唯一ID",
|
||||
"create_time": "2025-08-02T00:00:00+08:00",
|
||||
"event_type": "事件类型,由具体业务定义",
|
||||
"resource_type": "encrypt-resource",
|
||||
"resource": { /* 加密资源对象,结构见下 */ }
|
||||
}
|
||||
```
|
||||
|
||||
`resource` 字段为加密资源对象,三种接入模式完全一致(参考官方文档:[商户](https://pay.weixin.qq.com/doc/v3/merchant/4012071382) / [品牌](https://pay.weixin.qq.com/doc/brand/4015407591) / 服务商):
|
||||
|
||||
```json
|
||||
{
|
||||
"original_type": "transaction",
|
||||
"algorithm": "AEAD_AES_256_GCM",
|
||||
"ciphertext": "...",
|
||||
"nonce": "...",
|
||||
"associated_data": ""
|
||||
}
|
||||
```
|
||||
|
||||
- 算法:`AEAD_AES_256_GCM`,密钥:**APIv3 密钥(32 字节)—— 商户、品牌、服务商三方完全相同**
|
||||
- 从 `resource` 取 `nonce`、`ciphertext`、`associated_data` 进行解密
|
||||
- ‼️ 加密报文中的 `nonce` 与请求签名串中的随机串**没有任何关系**,是两个独立的值
|
||||
|
||||
### 回调处理要求
|
||||
|
||||
1. **必须返回 HTTP 2XX**(200 或 204),否则微信支付会重试
|
||||
2. **必须在 5 秒内应答**
|
||||
3. **必须做幂等处理**(按业务唯一标识 + `event_type` 去重)
|
||||
4. **必须验签**,防止伪造通知。验签密钥支持两种,**与接入模式无关**,取决于商户/服务商在平台的密钥配置:
|
||||
- **微信支付公钥**(推荐,2024 年后新增,公钥 ID 形如 `PUB_KEY_ID_xxxxxx`)
|
||||
- **微信支付平台证书**(旧方式,需定期下载更新,仍可继续使用)
|
||||
- 任意一种接入模式(商户 / 品牌 / 服务商)均可自由选择上述任一种验签方式
|
||||
5. 签名探测流量以 `WECHATPAY/SIGNTEST/` 开头,需正确处理
|
||||
6. 即使业务处理异常,也建议返回 200,通过告警系统人工介入
|
||||
|
||||
### 回调收不到的常见排查场景
|
||||
|
||||
#### 一、前置配置缺失
|
||||
|
||||
1. **未设置 APIv3 密钥** — 微信支付不会发送回调通知
|
||||
2. **未配置 `notify_url`** — 接口或商户平台未配置回调通知接收地址,微信支付不会发送回调通知
|
||||
|
||||
#### 二、回调地址配置类问题
|
||||
|
||||
1. **地址格式错误**
|
||||
- `notify_url` 未以 `https://` 或 `http://` 开头
|
||||
- URL 中只有域名,缺少具体路径(如 `http://www.weixin.qq.com`)
|
||||
- URL 携带了参数
|
||||
- 使用了内网地址(`127.0.0.1`、`192.168.x.x`、`localhost`)
|
||||
2. **域名未备案或解析异常**
|
||||
- 域名未完成工信部 ICP 备案(国内服务器必须)
|
||||
- DNS 解析失效(解析记录过期、未配置正确的 A/AAAA 记录)
|
||||
|
||||
#### 三、网络与服务器连通性问题
|
||||
|
||||
1. **防火墙/安全组拦截** — 未对上方「回调 IP 白名单」中的 IP 段开通入站规则
|
||||
2. **WAF/CC 防护误拦** — 安全策略将微信支付回调请求误判为恶意请求
|
||||
3. **网络链路故障** — 丢包或延迟过高(超过 3 秒)导致请求超时
|
||||
4. **CDN/反向代理配置异常** — Nginx、Cloudflare 等未将回调请求正确转发至后端服务
|
||||
|
||||
#### 四、回调处理逻辑问题
|
||||
|
||||
1. **登录态校验** — `notify_url` 的代码逻辑不能做登录态校验
|
||||
2. **未在 5 秒内应答** — 微信支付会认为通知失败并重复发送
|
||||
3. **未做幂等** — 同一通知可能多次发送,必须按业务唯一标识 + `event_type` 去重
|
||||
|
||||
### 各模式回调归属说明
|
||||
|
||||
| 模式 | 归属维度 | 区分多主体的关键字段 | 备注 |
|
||||
| --- | --- | --- | --- |
|
||||
| **商户** | 商户维度 | 无(回调本就属于该商户) | 一个商户一个回调地址 |
|
||||
| **品牌直连** | 品牌维度 | `brand_id` | 一个品牌一个回调地址 |
|
||||
| **服务商** | 服务商维度 | `sub_mchid` / `brand_id` | 所有子商户/品牌共用同一回调地址,**必须按字段路由到正确的子主体**,否则会出现"A 商户的订单被 B 商户业务处理"的串单事故 |
|
||||
|
||||
## 二、错误处理策略
|
||||
|
||||
|
||||
| 错误类型 | 处理策略 |
|
||||
| ---------------- | ---------------------------- |
|
||||
| 500 SYSTEM_ERROR | 使用相同请求号重试(指数退避) |
|
||||
| 400 参数错误 | 修正参数后重试 |
|
||||
| 401 签名错误 | 检查验签密钥(公钥 / 平台证书)是否与平台配置一致;服务商还需检查请求头 `Wechatpay-Serial` 是否携带正确的证书/公钥序列号 |
|
||||
| 回调超时 | 返回 200,异步补偿处理 |
|
||||
| 解密失败 | 检查 APIv3 密钥是否正确(32 字节、与商户/服务商平台配置一致)|
|
||||
|
||||
|
||||
## 三、幂等设计
|
||||
|
||||
- 所有写操作必须使用业务侧生成的唯一请求号(如 `out_trade_no`、`out_request_no`、各业务自定义的请求号)
|
||||
- 相同请求号重复请求不会创建重复资源
|
||||
- 建议格式:`{业务前缀}_{日期}_{序号}`,例如 `pay_20250801_000001`
|
||||
|
||||
## 四、请求域名
|
||||
|
||||
- 主域名: `https://api.mch.weixin.qq.com`
|
||||
- 备域名: `https://api2.mch.weixin.qq.com`
|
||||
@@ -0,0 +1,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 个医保金额字段之和必须为 0;4 个医保订单字段**禁填** |
|
||||
| `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 位 → MD5(32 位小写十六进制)。例:`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` 调用本接口同步医保退款结果)。
|
||||
@@ -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 商户与子商户不是受理关系`:未在服务商体系下完成子商户绑定;或服务商资质未在医保局对应城市报备 | 🔴 致命 |
|
||||
@@ -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. 仍排查不出 → 用测试公私钥按本文示例核对签名计算逻辑
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 触发重试,同时报警查单兜底 |
|
||||
@@ -0,0 +1,71 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const Host = "https://api.mch.weixin.qq.com"
|
||||
|
||||
// SendGet 发送 GET 请求并返回已验签的应答 Body
|
||||
func SendGet(config *MchConfig, uri string) ([]byte, error) {
|
||||
return sendRequest(config, "GET", uri, nil)
|
||||
}
|
||||
|
||||
// SendPost 发送 POST 请求并返回已验签的应答 Body
|
||||
func SendPost(config *MchConfig, uri string, reqBody []byte) ([]byte, error) {
|
||||
return sendRequest(config, "POST", uri, reqBody)
|
||||
}
|
||||
|
||||
func sendRequest(config *MchConfig, method string, uri string, reqBody []byte) ([]byte, error) {
|
||||
var bodyReader io.Reader
|
||||
if reqBody != nil {
|
||||
bodyReader = bytes.NewReader(reqBody)
|
||||
}
|
||||
|
||||
httpRequest, err := http.NewRequest(method, Host+uri, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("Accept", "application/json")
|
||||
httpRequest.Header.Set("Wechatpay-Serial", config.WechatPayPublicKeyId())
|
||||
|
||||
authorization, err := BuildAuthorization(config.MchId(), config.CertificateSerialNo(),
|
||||
config.PrivateKey(), method, uri, reqBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", authorization)
|
||||
|
||||
if reqBody != nil {
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
httpResponse, err := client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respBody, err := ExtractResponseBody(httpResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if httpResponse.StatusCode >= 200 && httpResponse.StatusCode < 300 {
|
||||
err = ValidateResponse(
|
||||
config.WechatPayPublicKeyId(),
|
||||
config.WechatPayPublicKey(),
|
||||
&httpResponse.Header,
|
||||
respBody,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, NewApiException(httpResponse.StatusCode, httpResponse.Header, respBody)
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package wxpay_utility
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tjfoc/gmsm/sm3"
|
||||
)
|
||||
|
||||
type MchConfig struct {
|
||||
mchId string
|
||||
certificateSerialNo string
|
||||
privateKeyFilePath string
|
||||
wechatPayPublicKeyId string
|
||||
wechatPayPublicKeyFilePath string
|
||||
privateKey *rsa.PrivateKey
|
||||
wechatPayPublicKey *rsa.PublicKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) MchId() string {
|
||||
return c.mchId
|
||||
}
|
||||
|
||||
func (c *MchConfig) CertificateSerialNo() string {
|
||||
return c.certificateSerialNo
|
||||
}
|
||||
|
||||
func (c *MchConfig) PrivateKey() *rsa.PrivateKey {
|
||||
return c.privateKey
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKeyId() string {
|
||||
return c.wechatPayPublicKeyId
|
||||
}
|
||||
|
||||
func (c *MchConfig) WechatPayPublicKey() *rsa.PublicKey {
|
||||
return c.wechatPayPublicKey
|
||||
}
|
||||
|
||||
func CreateMchConfig(
|
||||
mchId string,
|
||||
certificateSerialNo string,
|
||||
privateKeyFilePath string,
|
||||
wechatPayPublicKeyId string,
|
||||
wechatPayPublicKeyFilePath string,
|
||||
) (*MchConfig, error) {
|
||||
mchConfig := &MchConfig{
|
||||
mchId: mchId,
|
||||
certificateSerialNo: certificateSerialNo,
|
||||
privateKeyFilePath: privateKeyFilePath,
|
||||
wechatPayPublicKeyId: wechatPayPublicKeyId,
|
||||
wechatPayPublicKeyFilePath: wechatPayPublicKeyFilePath,
|
||||
}
|
||||
privateKey, err := LoadPrivateKeyWithPath(mchConfig.privateKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.privateKey = privateKey
|
||||
wechatPayPublicKey, err := LoadPublicKeyWithPath(mchConfig.wechatPayPublicKeyFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mchConfig.wechatPayPublicKey = wechatPayPublicKey
|
||||
return mchConfig, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKey(privateKeyStr string) (privateKey *rsa.PrivateKey, err error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyStr))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode private key err")
|
||||
}
|
||||
if block.Type != "PRIVATE KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PRVATE KEY")
|
||||
}
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse private key err:%s", err.Error())
|
||||
}
|
||||
privateKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not a RSA private key")
|
||||
}
|
||||
return privateKey, nil
|
||||
}
|
||||
|
||||
func LoadPublicKey(publicKeyStr string) (publicKey *rsa.PublicKey, err error) {
|
||||
block, _ := pem.Decode([]byte(publicKeyStr))
|
||||
if block == nil {
|
||||
return nil, errors.New("decode public key error")
|
||||
}
|
||||
if block.Type != "PUBLIC KEY" {
|
||||
return nil, fmt.Errorf("the kind of PEM should be PUBLIC KEY")
|
||||
}
|
||||
key, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse public key err:%s", err.Error())
|
||||
}
|
||||
publicKey, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is not rsa public key", publicKeyStr)
|
||||
}
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
func LoadPrivateKeyWithPath(path string) (privateKey *rsa.PrivateKey, err error) {
|
||||
privateKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPrivateKey(string(privateKeyBytes))
|
||||
}
|
||||
|
||||
func LoadPublicKeyWithPath(path string) (publicKey *rsa.PublicKey, err error) {
|
||||
publicKeyBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read certificate pem file err:%s", err.Error())
|
||||
}
|
||||
return LoadPublicKey(string(publicKeyBytes))
|
||||
}
|
||||
|
||||
func EncryptOAEPWithPublicKey(message string, publicKey *rsa.PublicKey) (ciphertext string, err error) {
|
||||
if publicKey == nil {
|
||||
return "", fmt.Errorf("you should input *rsa.PublicKey")
|
||||
}
|
||||
ciphertextByte, err := rsa.EncryptOAEP(sha1.New(), rand.Reader, publicKey, []byte(message), nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encrypt message with public key err:%s", err.Error())
|
||||
}
|
||||
ciphertext = base64.StdEncoding.EncodeToString(ciphertextByte)
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
func DecryptAES256GCM(aesKey, associatedData, nonce, ciphertext string) (plaintext string, err error) {
|
||||
decodedCiphertext, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
c, err := aes.NewCipher([]byte(aesKey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dataBytes, err := gcm.Open(nil, []byte(nonce), decodedCiphertext, []byte(associatedData))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(dataBytes), nil
|
||||
}
|
||||
|
||||
func SignSHA256WithRSA(source string, privateKey *rsa.PrivateKey) (signature string, err error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key should not be nil")
|
||||
}
|
||||
h := crypto.Hash.New(crypto.SHA256)
|
||||
_, err = h.Write([]byte(source))
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
hashed := h.Sum(nil)
|
||||
signatureByte, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signatureByte), nil
|
||||
}
|
||||
|
||||
func VerifySHA256WithRSA(source string, signature string, publicKey *rsa.PublicKey) error {
|
||||
if publicKey == nil {
|
||||
return fmt.Errorf("public key should not be nil")
|
||||
}
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify failed: signature is not base64 encoded")
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(source))
|
||||
err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hashed[:], sigBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify signature with public key error:%s", err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GenerateNonce() (string, error) {
|
||||
const (
|
||||
NonceSymbols = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
NonceLength = 32
|
||||
)
|
||||
|
||||
bytes := make([]byte, NonceLength)
|
||||
_, err := rand.Read(bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
symbolsByteLength := byte(len(NonceSymbols))
|
||||
for i, b := range bytes {
|
||||
bytes[i] = NonceSymbols[b%symbolsByteLength]
|
||||
}
|
||||
return string(bytes), nil
|
||||
}
|
||||
|
||||
func BuildAuthorization(
|
||||
mchid string,
|
||||
certificateSerialNo string,
|
||||
privateKey *rsa.PrivateKey,
|
||||
method string,
|
||||
canonicalURL string,
|
||||
body []byte,
|
||||
) (string, error) {
|
||||
const (
|
||||
SignatureMessageFormat = "%s\n%s\n%d\n%s\n%s\n"
|
||||
HeaderAuthorizationFormat = "WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",timestamp=\"%d\",serial_no=\"%s\",signature=\"%s\""
|
||||
)
|
||||
|
||||
nonce, err := GenerateNonce()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
timestamp := time.Now().Unix()
|
||||
message := fmt.Sprintf(SignatureMessageFormat, method, canonicalURL, timestamp, nonce, body)
|
||||
signature, err := SignSHA256WithRSA(message, privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorization := fmt.Sprintf(
|
||||
HeaderAuthorizationFormat,
|
||||
mchid, nonce, timestamp, certificateSerialNo, signature,
|
||||
)
|
||||
return authorization, nil
|
||||
}
|
||||
|
||||
func ExtractResponseBody(response *http.Response) ([]byte, error) {
|
||||
if response.Body == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body err:[%s]", err.Error())
|
||||
}
|
||||
response.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
return body, nil
|
||||
}
|
||||
|
||||
const (
|
||||
WechatPayTimestamp = "Wechatpay-Timestamp"
|
||||
WechatPayNonce = "Wechatpay-Nonce"
|
||||
WechatPaySignature = "Wechatpay-Signature"
|
||||
WechatPaySerial = "Wechatpay-Serial"
|
||||
RequestID = "Request-Id"
|
||||
)
|
||||
|
||||
func validateWechatPaySignature(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
timestampStr := headers.Get(WechatPayTimestamp)
|
||||
serialNo := headers.Get(WechatPaySerial)
|
||||
signature := headers.Get(WechatPaySignature)
|
||||
nonce := headers.Get(WechatPayNonce)
|
||||
|
||||
timestamp, err := strconv.ParseInt(timestampStr, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid timestamp: %w", err)
|
||||
}
|
||||
if time.Now().Sub(time.Unix(timestamp, 0)) > 5*time.Minute {
|
||||
return fmt.Errorf("timestamp expired: %d", timestamp)
|
||||
}
|
||||
|
||||
if serialNo != wechatpayPublicKeyId {
|
||||
return fmt.Errorf(
|
||||
"serial-no mismatch: got %s, expected %s",
|
||||
serialNo,
|
||||
wechatpayPublicKeyId,
|
||||
)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("%s\n%s\n%s\n", timestampStr, nonce, body)
|
||||
if err := VerifySHA256WithRSA(message, signature, wechatpayPublicKey); err != nil {
|
||||
return fmt.Errorf("invalid signature: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateResponse(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate response err: %w, RequestID: %s", err, headers.Get(RequestID))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) error {
|
||||
if err := validateWechatPaySignature(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return fmt.Errorf("validate notification err: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Resource struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
AssociatedData string `json:"associated_data"`
|
||||
Nonce string `json:"nonce"`
|
||||
OriginalType string `json:"original_type"`
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime *time.Time `json:"create_time"`
|
||||
EventType string `json:"event_type"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
Resource *Resource `json:"resource"`
|
||||
Summary string `json:"summary"`
|
||||
|
||||
Plaintext string
|
||||
}
|
||||
|
||||
func (c *Notification) validate() error {
|
||||
if c.Resource == nil {
|
||||
return errors.New("resource is nil")
|
||||
}
|
||||
|
||||
if c.Resource.Algorithm != "AEAD_AES_256_GCM" {
|
||||
return fmt.Errorf("unsupported algorithm: %s", c.Resource.Algorithm)
|
||||
}
|
||||
|
||||
if c.Resource.Ciphertext == "" {
|
||||
return errors.New("ciphertext is empty")
|
||||
}
|
||||
|
||||
if c.Resource.AssociatedData == "" {
|
||||
return errors.New("associated_data is empty")
|
||||
}
|
||||
|
||||
if c.Resource.Nonce == "" {
|
||||
return errors.New("nonce is empty")
|
||||
}
|
||||
|
||||
if c.Resource.OriginalType == "" {
|
||||
return fmt.Errorf("original_type is empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Notification) decrypt(apiv3Key string) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return fmt.Errorf("notification format err: %w", err)
|
||||
}
|
||||
|
||||
plaintext, err := DecryptAES256GCM(
|
||||
apiv3Key,
|
||||
c.Resource.AssociatedData,
|
||||
c.Resource.Nonce,
|
||||
c.Resource.Ciphertext,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
c.Plaintext = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseNotification(
|
||||
wechatpayPublicKeyId string,
|
||||
wechatpayPublicKey *rsa.PublicKey,
|
||||
apiv3Key string,
|
||||
headers *http.Header,
|
||||
body []byte,
|
||||
) (*Notification, error) {
|
||||
if err := validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
notification := &Notification{}
|
||||
if err := json.Unmarshal(body, notification); err != nil {
|
||||
return nil, fmt.Errorf("parse notification err: %w", err)
|
||||
}
|
||||
|
||||
if err := notification.decrypt(apiv3Key); err != nil {
|
||||
return nil, fmt.Errorf("notification decrypt err: %w", err)
|
||||
}
|
||||
|
||||
return notification, nil
|
||||
}
|
||||
|
||||
type ApiException struct {
|
||||
statusCode int
|
||||
header http.Header
|
||||
body []byte
|
||||
errorCode string
|
||||
errorMessage string
|
||||
}
|
||||
|
||||
func (c *ApiException) Error() string {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
buf.WriteString(fmt.Sprintf("api error:[StatusCode: %d, Body: %s", c.statusCode, string(c.body)))
|
||||
if len(c.header) > 0 {
|
||||
buf.WriteString(" Header: ")
|
||||
for key, value := range c.header {
|
||||
buf.WriteString(fmt.Sprintf("\n - %v=%v", key, value))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("]")
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (c *ApiException) StatusCode() int {
|
||||
return c.statusCode
|
||||
}
|
||||
|
||||
func (c *ApiException) Header() http.Header {
|
||||
return c.header
|
||||
}
|
||||
|
||||
func (c *ApiException) Body() []byte {
|
||||
return c.body
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorCode() string {
|
||||
return c.errorCode
|
||||
}
|
||||
|
||||
func (c *ApiException) ErrorMessage() string {
|
||||
return c.errorMessage
|
||||
}
|
||||
|
||||
func NewApiException(statusCode int, header http.Header, body []byte) error {
|
||||
ret := &ApiException{
|
||||
statusCode: statusCode,
|
||||
header: header,
|
||||
body: body,
|
||||
}
|
||||
|
||||
bodyObject := map[string]interface{}{}
|
||||
if err := json.Unmarshal(body, &bodyObject); err == nil {
|
||||
if val, ok := bodyObject["code"]; ok {
|
||||
ret.errorCode = val.(string)
|
||||
}
|
||||
if val, ok := bodyObject["message"]; ok {
|
||||
ret.errorMessage = val.(string)
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func Time(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func String(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func Bytes(b []byte) *[]byte {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Bool(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func Float64(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Float32(f float32) *float32 {
|
||||
return &f
|
||||
}
|
||||
|
||||
func Int64(i int64) *int64 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func Int32(i int32) *int32 {
|
||||
return &i
|
||||
}
|
||||
|
||||
func generateHashFromStream(reader io.Reader, hashFunc func() hash.Hash, algorithmName string) (string, error) {
|
||||
hash := hashFunc()
|
||||
if _, err := io.Copy(hash, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for %s: %w", algorithmName, err)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func GenerateSHA256FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha256.New, "SHA256")
|
||||
}
|
||||
|
||||
func GenerateSHA1FromStream(reader io.Reader) (string, error) {
|
||||
return generateHashFromStream(reader, sha1.New, "SHA1")
|
||||
}
|
||||
|
||||
func GenerateSM3FromStream(reader io.Reader) (string, error) {
|
||||
h := sm3.New()
|
||||
if _, err := io.Copy(h, reader); err != nil {
|
||||
return "", fmt.Errorf("failed to read stream for SM3: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,但**查单**和**回调路由**必须带
|
||||
@@ -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` 路由到对应业务系统
|
||||
@@ -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 触发重试,同时报警查单兜底 |
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.java.utils;
|
||||
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
|
||||
/**
|
||||
* 微信支付 HTTP 客户端,封装了请求签名、发送、应答验签的完整流程。
|
||||
* 依赖 WXPayUtility 提供的签名、验签、序列化等基础能力。
|
||||
*/
|
||||
public class WXPayClient {
|
||||
private static final String HOST = "https://api.mch.weixin.qq.com";
|
||||
|
||||
private final String mchid;
|
||||
private final String certificateSerialNo;
|
||||
private final PrivateKey privateKey;
|
||||
private final String wechatPayPublicKeyId;
|
||||
private final PublicKey wechatPayPublicKey;
|
||||
|
||||
public WXPayClient(String mchid, String certificateSerialNo, String privateKeyFilePath,
|
||||
String wechatPayPublicKeyId, String wechatPayPublicKeyFilePath) {
|
||||
this.mchid = mchid;
|
||||
this.certificateSerialNo = certificateSerialNo;
|
||||
this.privateKey = WXPayUtility.loadPrivateKeyFromPath(privateKeyFilePath);
|
||||
this.wechatPayPublicKeyId = wechatPayPublicKeyId;
|
||||
this.wechatPayPublicKey = WXPayUtility.loadPublicKeyFromPath(wechatPayPublicKeyFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 GET 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendGet(String uri) {
|
||||
return sendRequest("GET", uri, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送 POST 请求,返回已验签的应答 Body
|
||||
*/
|
||||
public String sendPost(String uri, String reqBody) {
|
||||
return sendRequest("POST", uri, reqBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用公钥加密敏感信息
|
||||
*/
|
||||
public String encrypt(String plainText) {
|
||||
return WXPayUtility.encrypt(this.wechatPayPublicKey, plainText);
|
||||
}
|
||||
|
||||
private String sendRequest(String method, String uri, String reqBody) {
|
||||
Request.Builder reqBuilder = new Request.Builder().url(HOST + uri);
|
||||
reqBuilder.addHeader("Accept", "application/json");
|
||||
reqBuilder.addHeader("Wechatpay-Serial", wechatPayPublicKeyId);
|
||||
reqBuilder.addHeader("Authorization", WXPayUtility.buildAuthorization(
|
||||
mchid, certificateSerialNo, privateKey, method, uri, reqBody));
|
||||
|
||||
if (reqBody != null) {
|
||||
reqBuilder.addHeader("Content-Type", "application/json");
|
||||
RequestBody body = RequestBody.create(
|
||||
MediaType.parse("application/json; charset=utf-8"), reqBody);
|
||||
reqBuilder.method(method, body);
|
||||
} else {
|
||||
reqBuilder.method(method, null);
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder().build();
|
||||
try (Response httpResponse = client.newCall(reqBuilder.build()).execute()) {
|
||||
String respBody = WXPayUtility.extractBody(httpResponse);
|
||||
if (httpResponse.code() >= 200 && httpResponse.code() < 300) {
|
||||
WXPayUtility.validateResponse(wechatPayPublicKeyId, wechatPayPublicKey,
|
||||
httpResponse.headers(), respBody);
|
||||
return respBody;
|
||||
} else {
|
||||
throw new WXPayUtility.ApiException(httpResponse.code(), respBody, httpResponse.headers());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("Sending request to " + uri + " failed.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package com.java.utils;
|
||||
|
||||
import com.google.gson.ExclusionStrategy;
|
||||
import com.google.gson.FieldAttributes;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.annotations.Expose;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import java.util.List;
|
||||
import java.util.Map.Entry;
|
||||
import okhttp3.Headers;
|
||||
import okhttp3.Response;
|
||||
import okio.BufferedSource;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.Signature;
|
||||
import java.security.SignatureException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.DateTimeException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.security.MessageDigest;
|
||||
import java.io.InputStream;
|
||||
import org.bouncycastle.crypto.digests.SM3Digest;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import java.security.Security;
|
||||
|
||||
public class WXPayUtility {
|
||||
private static final Gson gson = new GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.addSerializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.serialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.addDeserializationExclusionStrategy(new ExclusionStrategy() {
|
||||
@Override
|
||||
public boolean shouldSkipField(FieldAttributes fieldAttributes) {
|
||||
final Expose expose = fieldAttributes.getAnnotation(Expose.class);
|
||||
return expose != null && !expose.deserialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldSkipClass(Class<?> aClass) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.create();
|
||||
private static final char[] SYMBOLS =
|
||||
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
|
||||
private static final SecureRandom random = new SecureRandom();
|
||||
|
||||
public static String toJson(Object object) {
|
||||
return gson.toJson(object);
|
||||
}
|
||||
|
||||
public static <T> T fromJson(String json, Class<T> classOfT) throws JsonSyntaxException {
|
||||
return gson.fromJson(json, classOfT);
|
||||
}
|
||||
|
||||
private static String readKeyStringFromPath(String keyPath) {
|
||||
try {
|
||||
return new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(
|
||||
new PKCS8EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PrivateKey loadPrivateKeyFromPath(String keyPath) {
|
||||
return loadPrivateKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromString(String keyString) {
|
||||
try {
|
||||
keyString = keyString.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
return KeyFactory.getInstance("RSA").generatePublic(
|
||||
new X509EncodedKeySpec(Base64.getDecoder().decode(keyString)));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(e);
|
||||
} catch (InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static PublicKey loadPublicKeyFromPath(String keyPath) {
|
||||
return loadPublicKeyFromString(readKeyStringFromPath(keyPath));
|
||||
}
|
||||
|
||||
public static String createNonce(int length) {
|
||||
char[] buf = new char[length];
|
||||
for (int i = 0; i < length; ++i) {
|
||||
buf[i] = SYMBOLS[random.nextInt(SYMBOLS.length)];
|
||||
}
|
||||
return new String(buf);
|
||||
}
|
||||
|
||||
public static String encrypt(PublicKey publicKey, String plaintext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, publicKey);
|
||||
return Base64.getEncoder().encodeToString(cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA encryption using an illegal publicKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Plaintext is too long", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String rsaOaepDecrypt(PrivateKey privateKey, String ciphertext) {
|
||||
final String transformation = "RSA/ECB/OAEPWithSHA-1AndMGF1Padding";
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
|
||||
return new String(decryptedBytes, StandardCharsets.UTF_8);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException("The current Java environment does not support " + transformation, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("RSA decryption using an illegal privateKey", e);
|
||||
} catch (BadPaddingException | IllegalBlockSizeException e) {
|
||||
throw new IllegalArgumentException("Ciphertext decryption failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String aesAeadDecrypt(byte[] key, byte[] associatedData, byte[] nonce,
|
||||
byte[] ciphertext) {
|
||||
final String transformation = "AES/GCM/NoPadding";
|
||||
final String algorithm = "AES";
|
||||
final int tagLengthBit = 128;
|
||||
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(transformation);
|
||||
cipher.init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
new SecretKeySpec(key, algorithm),
|
||||
new GCMParameterSpec(tagLengthBit, nonce));
|
||||
if (associatedData != null) {
|
||||
cipher.updateAAD(associatedData);
|
||||
}
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (InvalidKeyException
|
||||
| InvalidAlgorithmParameterException
|
||||
| BadPaddingException
|
||||
| IllegalBlockSizeException
|
||||
| NoSuchAlgorithmException
|
||||
| NoSuchPaddingException e) {
|
||||
throw new IllegalArgumentException(String.format("AesAeadDecrypt with %s Failed",
|
||||
transformation), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sign(String message, String algorithm, PrivateKey privateKey) {
|
||||
byte[] sign;
|
||||
try {
|
||||
Signature signature = Signature.getInstance(algorithm);
|
||||
signature.initSign(privateKey);
|
||||
signature.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
sign = signature.sign();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support " + algorithm, e);
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException(algorithm + " signature uses an illegal privateKey.", e);
|
||||
} catch (SignatureException e) {
|
||||
throw new RuntimeException("An error occurred during the sign process.", e);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(sign);
|
||||
}
|
||||
|
||||
public static boolean verify(String message, String signature, String algorithm,
|
||||
PublicKey publicKey) {
|
||||
try {
|
||||
Signature sign = Signature.getInstance(algorithm);
|
||||
sign.initVerify(publicKey);
|
||||
sign.update(message.getBytes(StandardCharsets.UTF_8));
|
||||
return sign.verify(Base64.getDecoder().decode(signature));
|
||||
} catch (SignatureException e) {
|
||||
return false;
|
||||
} catch (InvalidKeyException e) {
|
||||
throw new IllegalArgumentException("verify uses an illegal publickey.", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException("The current Java environment does not support" + algorithm, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String buildAuthorization(String mchid, String certificateSerialNo,
|
||||
PrivateKey privateKey,
|
||||
String method, String uri, String body) {
|
||||
String nonce = createNonce(32);
|
||||
long timestamp = Instant.now().getEpochSecond();
|
||||
|
||||
String message = String.format("%s\n%s\n%d\n%s\n%s\n", method, uri, timestamp, nonce,
|
||||
body == null ? "" : body);
|
||||
|
||||
String signature = sign(message, "SHA256withRSA", privateKey);
|
||||
|
||||
return String.format(
|
||||
"WECHATPAY2-SHA256-RSA2048 mchid=\"%s\",nonce_str=\"%s\",signature=\"%s\"," +
|
||||
"timestamp=\"%d\",serial_no=\"%s\"",
|
||||
mchid, nonce, signature, timestamp, certificateSerialNo);
|
||||
}
|
||||
|
||||
private static String calculateHash(InputStream inputStream, String algorithm) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = digest.digest();
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new UnsupportedOperationException(algorithm + " algorithm not available", e);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-256");
|
||||
}
|
||||
|
||||
public static String sha1(InputStream inputStream) {
|
||||
return calculateHash(inputStream, "SHA-1");
|
||||
}
|
||||
|
||||
public static String sm3(InputStream inputStream) {
|
||||
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
try {
|
||||
SM3Digest digest = new SM3Digest();
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] hashBytes = new byte[digest.getDigestSize()];
|
||||
digest.doFinal(hashBytes, 0);
|
||||
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Error reading from input stream", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(String content) {
|
||||
try {
|
||||
return URLEncoder.encode(content, StandardCharsets.UTF_8.name());
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String urlEncode(Map<String, Object> params) {
|
||||
if (params == null || params.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (Entry<String, Object> entry : params.entrySet()) {
|
||||
if (entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
if (value instanceof List) {
|
||||
List<?> list = (List<?>) entry.getValue();
|
||||
for (Object temp : list) {
|
||||
appendParam(result, key, temp);
|
||||
}
|
||||
} else {
|
||||
appendParam(result, key, value);
|
||||
}
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static void appendParam(StringBuilder result, String key, Object value) {
|
||||
if (result.length() > 0) {
|
||||
result.append("&");
|
||||
}
|
||||
|
||||
String valueString;
|
||||
if (value instanceof String || value instanceof Number ||
|
||||
value instanceof Boolean || value instanceof Enum) {
|
||||
valueString = value.toString();
|
||||
} else {
|
||||
valueString = toJson(value);
|
||||
}
|
||||
|
||||
result.append(key)
|
||||
.append("=")
|
||||
.append(urlEncode(valueString));
|
||||
}
|
||||
|
||||
public static String extractBody(Response response) {
|
||||
if (response.body() == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
BufferedSource source = response.body().source();
|
||||
return source.readUtf8();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(String.format("An error occurred during reading response body. " +
|
||||
"Status: %d", response.code()), e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateResponse(String wechatpayPublicKeyId, PublicKey wechatpayPublicKey,
|
||||
Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
String requestId = headers.get("Request-ID");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is expired, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, timestamp[%s] is invalid, request-id[%s]",
|
||||
timestamp, requestId));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed, Invalid Wechatpay-Serial, Local: %s, Remote: " +
|
||||
"%s", wechatpayPublicKeyId, serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate response failed,the WechatPay signature is incorrect.%n"
|
||||
+ "Request-ID[%s]\tresponseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers.get("Request-ID"), headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateNotification(String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
String timestamp = headers.get("Wechatpay-Timestamp");
|
||||
try {
|
||||
Instant responseTime = Instant.ofEpochSecond(Long.parseLong(timestamp));
|
||||
if (Duration.between(responseTime, Instant.now()).abs().toMinutes() >= 5) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is expired", timestamp));
|
||||
}
|
||||
} catch (DateTimeException | NumberFormatException e) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, timestamp[%s] is invalid", timestamp));
|
||||
}
|
||||
String serialNumber = headers.get("Wechatpay-Serial");
|
||||
if (!Objects.equals(serialNumber, wechatpayPublicKeyId)) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, Invalid Wechatpay-Serial, Local: %s, " +
|
||||
"Remote: %s",
|
||||
wechatpayPublicKeyId,
|
||||
serialNumber));
|
||||
}
|
||||
|
||||
String signature = headers.get("Wechatpay-Signature");
|
||||
String message = String.format("%s\n%s\n%s\n", timestamp, headers.get("Wechatpay-Nonce"),
|
||||
body == null ? "" : body);
|
||||
|
||||
boolean success = verify(message, signature, "SHA256withRSA", wechatpayPublicKey);
|
||||
if (!success) {
|
||||
throw new IllegalArgumentException(
|
||||
String.format("Validate notification failed, WechatPay signature is incorrect.\n"
|
||||
+ "responseHeader[%s]\tresponseBody[%.1024s]",
|
||||
headers, body));
|
||||
}
|
||||
}
|
||||
|
||||
public static Notification parseNotification(String apiv3Key, String wechatpayPublicKeyId,
|
||||
PublicKey wechatpayPublicKey, Headers headers,
|
||||
String body) {
|
||||
validateNotification(wechatpayPublicKeyId, wechatpayPublicKey, headers, body);
|
||||
Notification notification = gson.fromJson(body, Notification.class);
|
||||
notification.decrypt(apiv3Key);
|
||||
return notification;
|
||||
}
|
||||
|
||||
public static class ApiException extends RuntimeException {
|
||||
private static final long serialVersionUID = 2261086748874802175L;
|
||||
|
||||
private final int statusCode;
|
||||
private final String body;
|
||||
private final Headers headers;
|
||||
private final String errorCode;
|
||||
private final String errorMessage;
|
||||
|
||||
public ApiException(int statusCode, String body, Headers headers) {
|
||||
super(String.format("微信支付API访问失败,StatusCode: [%s], Body: [%s], Headers: [%s]", statusCode,
|
||||
body, headers));
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
this.headers = headers;
|
||||
|
||||
if (body != null && !body.isEmpty()) {
|
||||
JsonElement code;
|
||||
JsonElement message;
|
||||
|
||||
try {
|
||||
JsonObject jsonObject = gson.fromJson(body, JsonObject.class);
|
||||
code = jsonObject.get("code");
|
||||
message = jsonObject.get("message");
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
code = null;
|
||||
message = null;
|
||||
}
|
||||
this.errorCode = code == null ? null : code.getAsString();
|
||||
this.errorMessage = message == null ? null : message.getAsString();
|
||||
} else {
|
||||
this.errorCode = null;
|
||||
this.errorMessage = null;
|
||||
}
|
||||
}
|
||||
|
||||
public int getStatusCode() {
|
||||
return statusCode;
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public Headers getHeaders() {
|
||||
return headers;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Notification {
|
||||
@SerializedName("id")
|
||||
private String id;
|
||||
@SerializedName("create_time")
|
||||
private String createTime;
|
||||
@SerializedName("event_type")
|
||||
private String eventType;
|
||||
@SerializedName("resource_type")
|
||||
private String resourceType;
|
||||
@SerializedName("summary")
|
||||
private String summary;
|
||||
@SerializedName("resource")
|
||||
private Resource resource;
|
||||
private String plaintext;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getCreateTime() {
|
||||
return createTime;
|
||||
}
|
||||
|
||||
public String getEventType() {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
public String getResourceType() {
|
||||
return resourceType;
|
||||
}
|
||||
|
||||
public String getSummary() {
|
||||
return summary;
|
||||
}
|
||||
|
||||
public Resource getResource() {
|
||||
return resource;
|
||||
}
|
||||
|
||||
public String getPlaintext() {
|
||||
return plaintext;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (resource == null) {
|
||||
throw new IllegalArgumentException("Missing required field `resource` in notification");
|
||||
}
|
||||
resource.validate();
|
||||
}
|
||||
|
||||
private void decrypt(String apiv3Key) {
|
||||
validate();
|
||||
|
||||
plaintext = aesAeadDecrypt(
|
||||
apiv3Key.getBytes(StandardCharsets.UTF_8),
|
||||
resource.associatedData.getBytes(StandardCharsets.UTF_8),
|
||||
resource.nonce.getBytes(StandardCharsets.UTF_8),
|
||||
Base64.getDecoder().decode(resource.ciphertext)
|
||||
);
|
||||
}
|
||||
|
||||
public static class Resource {
|
||||
@SerializedName("algorithm")
|
||||
private String algorithm;
|
||||
|
||||
@SerializedName("ciphertext")
|
||||
private String ciphertext;
|
||||
|
||||
@SerializedName("associated_data")
|
||||
private String associatedData;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("original_type")
|
||||
private String originalType;
|
||||
|
||||
public String getAlgorithm() {
|
||||
return algorithm;
|
||||
}
|
||||
|
||||
public String getCiphertext() {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
public String getAssociatedData() {
|
||||
return associatedData;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getOriginalType() {
|
||||
return originalType;
|
||||
}
|
||||
|
||||
private void validate() {
|
||||
if (algorithm == null || algorithm.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `algorithm` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
if (!Objects.equals(algorithm, "AEAD_AES_256_GCM")) {
|
||||
throw new IllegalArgumentException(String.format("Unsupported `algorithm`[%s] in " +
|
||||
"Notification.Resource", algorithm));
|
||||
}
|
||||
|
||||
if (ciphertext == null || ciphertext.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `ciphertext` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (associatedData == null || associatedData.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `associatedData` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
|
||||
if (nonce == null || nonce.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `nonce` in Notification" +
|
||||
".Resource");
|
||||
}
|
||||
|
||||
if (originalType == null || originalType.isEmpty()) {
|
||||
throw new IllegalArgumentException("Missing required field `originalType` in " +
|
||||
"Notification.Resource");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static String getContentTypeByFileName(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
String extension = "";
|
||||
int lastDotIndex = fileName.lastIndexOf('.');
|
||||
if (lastDotIndex > 0 && lastDotIndex < fileName.length() - 1) {
|
||||
extension = fileName.substring(lastDotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
Map<String, String> contentTypeMap = new HashMap<>();
|
||||
contentTypeMap.put("png", "image/png");
|
||||
contentTypeMap.put("jpg", "image/jpeg");
|
||||
contentTypeMap.put("jpeg", "image/jpeg");
|
||||
contentTypeMap.put("gif", "image/gif");
|
||||
contentTypeMap.put("bmp", "image/bmp");
|
||||
contentTypeMap.put("webp", "image/webp");
|
||||
contentTypeMap.put("svg", "image/svg+xml");
|
||||
contentTypeMap.put("ico", "image/x-icon");
|
||||
contentTypeMap.put("pdf", "application/pdf");
|
||||
contentTypeMap.put("doc", "application/msword");
|
||||
contentTypeMap.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||
contentTypeMap.put("xls", "application/vnd.ms-excel");
|
||||
contentTypeMap.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
contentTypeMap.put("ppt", "application/vnd.ms-powerpoint");
|
||||
contentTypeMap.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation");
|
||||
contentTypeMap.put("txt", "text/plain");
|
||||
contentTypeMap.put("html", "text/html");
|
||||
contentTypeMap.put("css", "text/css");
|
||||
contentTypeMap.put("js", "application/javascript");
|
||||
contentTypeMap.put("json", "application/json");
|
||||
contentTypeMap.put("xml", "application/xml");
|
||||
contentTypeMap.put("csv", "text/csv");
|
||||
contentTypeMap.put("mp3", "audio/mpeg");
|
||||
contentTypeMap.put("wav", "audio/wav");
|
||||
contentTypeMap.put("mp4", "video/mp4");
|
||||
contentTypeMap.put("avi", "video/x-msvideo");
|
||||
contentTypeMap.put("mov", "video/quicktime");
|
||||
contentTypeMap.put("zip", "application/zip");
|
||||
contentTypeMap.put("rar", "application/x-rar-compressed");
|
||||
contentTypeMap.put("7z", "application/x-7z-compressed");
|
||||
|
||||
return contentTypeMap.getOrDefault(extension, "application/octet-stream");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,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 客户端,封装请求签名 → 发送 → 验签 |
|
||||
@@ -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 20(Request-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 3339;mix_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`。
|
||||
Reference in New Issue
Block a user