Compare commits
1 Commits
main
...
feature/v4
| Author | SHA1 | Date |
|---|---|---|
|
|
69a4cc893a |
|
|
@ -0,0 +1,111 @@
|
||||||
|
# osais_wechat_qiyema 插件
|
||||||
|
|
||||||
|
奥塞斯开放平台(微信企业付/企业码)插件,遵循 plugins 仓库规范,基于 `gitea.cdlsxd.cn/sdk/plugin` 实现。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `Order` | 3.2.8 微信企业付购卡(`osais.wxbizpay.quota.card`)+ 3.2.4 确认订单(`osais.order.confirm`)。购卡返回成功后自动确认,确认成功(`confirmStatus=1`,扣企业账户余额)后额度卡才能使用 |
|
||||||
|
| `Query` | 3.2.9 微信企业付额度卡查询(`osais.wxbizpay.card.query`) |
|
||||||
|
| `Notify` | 3.3.5 授权结果通知 / 3.3.6 交易通知(验签 + 签名应答) |
|
||||||
|
|
||||||
|
> 3.2.7 授权接口由业务方另行处理(引导用户授权后,通过 3.3.5 授权通知感知授权结果)。
|
||||||
|
|
||||||
|
## 通信信息
|
||||||
|
|
||||||
|
- `Tag`: `osais_wechat_qiyema`
|
||||||
|
- `Version`: `1`
|
||||||
|
- `CookieKey`: `osais_wechat_qiyema`
|
||||||
|
- `CookieValue`: `osais_wechat_qiyema`
|
||||||
|
|
||||||
|
## 配置(Config JSON,由平台按 tag 下发)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "https://gateway.test2.osais.cc",
|
||||||
|
"app_id": "1j0m408660091832",
|
||||||
|
"enterprise_code": "lansexd",
|
||||||
|
"sign_key": "对接方私钥(PKCS8 Base64):请求签名 + 回调应答签名",
|
||||||
|
"verify_key": "奥塞斯公钥(PKIX Base64):响应验签 + 回调验签"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| `base_url` | 是 | 奥塞斯网关地址(测试 `https://gateway.test2.osais.cc` / 生产以对接文档为准) |
|
||||||
|
| `app_id` | 是 | 开放平台 appid |
|
||||||
|
| `enterprise_code` | 是 | 企业编号 |
|
||||||
|
| `sign_key` | 是 | 对接方 RSA 私钥(PKCS8 格式,Base64),用于请求签名与回调应答签名 |
|
||||||
|
| `verify_key` | 是 | 奥塞斯 RSA 公钥(PKIX 格式,Base64),用于响应验签与回调验签 |
|
||||||
|
|
||||||
|
## 入参约定
|
||||||
|
|
||||||
|
### Order(购卡 + 确认)
|
||||||
|
|
||||||
|
| 入参 | 用途 | 必填 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| `order.order_no` | bizOrderNo(调用方订单号,保持唯一,购卡与确认共用) | 是 |
|
||||||
|
| `order.account` | bizUserId(用户编号) | 是 |
|
||||||
|
| `order.amount` | 购卡金额(单位:元,范围 1-1000,接口内部转分) | 是 |
|
||||||
|
| `order.quantity` | 不使用 | - |
|
||||||
|
| `product` | 不使用(授权由业务方另行处理) | - |
|
||||||
|
|
||||||
|
返回:
|
||||||
|
|
||||||
|
- 成功:`Status=SUCCESS`,`TradeNo=cardNo`,`Data` 含 `card`(cardNo/bizOrderNo/orderNo)与 `confirmStatus=1`
|
||||||
|
- 确认失败:`Status=FAIL`,`TradeNo=cardNo`(卡已购但不可用,需人工处理)
|
||||||
|
- 购卡失败:报错(网关业务错误 code/msg 透出)
|
||||||
|
|
||||||
|
### Query(额度卡查询)
|
||||||
|
|
||||||
|
| 入参 | 用途 | 必填 |
|
||||||
|
| ---- | ---- | ---- |
|
||||||
|
| `order.account` | bizUserId | 是 |
|
||||||
|
| `order.trade_no` | cardNo(为空查询用户全部额度卡) | 否 |
|
||||||
|
| `order.order_no` | 原路回显 | - |
|
||||||
|
|
||||||
|
返回:`Status=SUCCESS`,`Data` 为 `{cardList:[{cardNo,amount,balance,status}]}`
|
||||||
|
|
||||||
|
### Notify(异步通知)
|
||||||
|
|
||||||
|
平台收到通知报文后调用本插件,插件验签并解析;验签失败/报文 appid 与配置不一致时返回错误(网关侧应答非 code=0,奥塞斯会重试);验签通过即返回签名应答 `Return`(`{code,msg,data,sign}`,code=0 表示接收成功),平台应原样返回给网关。
|
||||||
|
|
||||||
|
| 通知 | method | 结果映射 |
|
||||||
|
| ---- | ------ | ------ |
|
||||||
|
| 3.3.5 授权结果 | `osais.wxbizpay.auth` | `status=1` → `SUCCESS`;`status=0` → `FAIL`(业务结果经 `Result.Status` 表达,应答仍 code=0) |
|
||||||
|
| 3.3.6 交易通知 | `osais.wxbizpay.trade` | `tradeType=2`(退款) → `REFUND`;消费 → `WRITE_OFF`,`TradeNo=cardNo` |
|
||||||
|
|
||||||
|
返回约定:
|
||||||
|
|
||||||
|
- `Result.Data`:解析后的业务字段 JSON(auth: `bizUserId/status`;trade: `cardNo/amount/name/tradeTime/tradeType`),不含公共参数
|
||||||
|
- `Result.Extra`:完整原始通知报文(含公共参数与签名,供审计/重放比对)
|
||||||
|
- `Result.Message`:人类可读摘要(授权成功/失败、额度卡消费/退款及卡号金额)
|
||||||
|
- 授权/交易通知均不含营销订单号,`OrderNo` 留空;交易通知以 `TradeNo=cardNo` 供平台关联充值记录
|
||||||
|
|
||||||
|
> 3.3.5 授权通知由业务方另行处理(不落营销充值单状态机);若误投营销充值单回调链路,营销侧将识别该 method 并仅应答、不查单不落库,避免重试风暴。
|
||||||
|
|
||||||
|
## 营销平台接入登记(上线 Checklist)
|
||||||
|
|
||||||
|
1. **插件登记**:上传 `pkg/linux/osais_wechat_qiyema.so` 至 OSS,将 URL 与 `pluginconfig`(version=1/cookie_key/cookie_value=`osais_wechat_qiyema`)登记进插件清单(Nacos `CONFIG_MERKETING_PLUGIN_LIST`,样例见营销仓 `app/supplier/service/configs/plugin.example.yaml`),或在 admin 上传注册。
|
||||||
|
2. **供应商绑定**:供应商配置该插件,Config 需包含 `base_url/app_id/enterprise_code/sign_key/verify_key`,并配置 `notify_url`(营销网关会强制改写为 `/v1/Notify/` 前缀路由到充值单回调)。
|
||||||
|
3. **回调特判**:3.3.5 授权结果通知在营销侧仅验签应答、不关联充值单(`NotifyOrder` 内 osais tag + method=`osais.wxbizpay.auth` 特判),无需充值单存在。
|
||||||
|
4. **商品类型与授权归属**:额度卡商品在下单/核销/退款状态机中映射为哪种商品类型(Voucher/Card…)、3.2.7 授权由哪个业务系统发起并维护授权状态,需在商品配置/产品评审中明确(本插件与营销充值单仅覆盖购卡+确认+查询+通知)。
|
||||||
|
5. **部署白名单**:若 supplier 部署配置启用了 `wechat_plugin_tags` 白名单(OrderQuery 消费/核销定时查询),需包含本 tag `osais_wechat_qiyema`。
|
||||||
|
|
||||||
|
## 编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-linux name=osais_wechat_qiyema
|
||||||
|
make build-mac name=osais_wechat_qiyema
|
||||||
|
```
|
||||||
|
|
||||||
|
产物输出到 `pkg/linux/osais_wechat_qiyema.so`、`pkg/mac/osais_wechat_qiyema.so`。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/osais_wechat_qiyema && go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
测试使用 httptest 模拟网关(含签名/验签、业务失败、回调推送),覆盖购卡+确认、确认失败、金额越界、网关业务错误、查卡、授权成功/失败通知(含应答验签与 Data/Extra 结构)、消费/退款通知、appid 不一致拒绝、验签拒绝等场景。
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
module plugins/osais_wechat_qiyema
|
||||||
|
|
||||||
|
go 1.22.2
|
||||||
|
|
||||||
|
replace plugins/utils => ../../utils
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.cdlsxd.cn/sdk/plugin v1.0.28
|
||||||
|
github.com/go-playground/validator/v10 v10.22.0
|
||||||
|
github.com/hashicorp/go-plugin v1.6.1
|
||||||
|
plugins/utils v1.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fatih/color v1.7.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
|
github.com/go-kratos/kratos/v2 v2.8.2 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
|
github.com/hashicorp/go-hclog v0.14.1 // indirect
|
||||||
|
github.com/hashicorp/yamux v0.1.1 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.10 // indirect
|
||||||
|
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 // indirect
|
||||||
|
github.com/oklog/run v1.0.0 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
golang.org/x/crypto v0.25.0 // indirect
|
||||||
|
golang.org/x/net v0.27.0 // indirect
|
||||||
|
golang.org/x/sys v0.22.0 // indirect
|
||||||
|
golang.org/x/text v0.16.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
|
||||||
|
google.golang.org/grpc v1.64.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
gitea.cdlsxd.cn/sdk/plugin v1.0.28 h1:D73owLaPfI/LL4MEnzPKjZKURZyBpOVzyfvW9Fd3v6k=
|
||||||
|
gitea.cdlsxd.cn/sdk/plugin v1.0.28/go.mod h1:4fLMp/xB9GEBa3nJi62kXpHh7wnb9Lwjf0I8Vjaasx0=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||||
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
|
github.com/go-kratos/kratos/v2 v2.8.2 h1:EsEA7AmPQ2YQQ0FZrDWO2HgBNqeWM8z/mWKzS5UkQaQ=
|
||||||
|
github.com/go-kratos/kratos/v2 v2.8.2/go.mod h1:+Vfe3FzF0d+BfMdajA11jT0rAyJWublRE/seZQNZVxE=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
||||||
|
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/hashicorp/go-hclog v0.14.1 h1:nQcJDQwIAGnmoUWp8ubocEX40cCml/17YkF6csQLReU=
|
||||||
|
github.com/hashicorp/go-hclog v0.14.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
|
||||||
|
github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI=
|
||||||
|
github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0=
|
||||||
|
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
|
||||||
|
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
|
||||||
|
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW10=
|
||||||
|
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
|
||||||
|
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77 h1:7GoSOOW2jpsfkntVKaS2rAr1TJqfcxotyaUcuxoZSzg=
|
||||||
|
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||||
|
github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
|
||||||
|
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
|
||||||
|
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||||
|
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||||
|
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||||
|
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||||
|
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
|
||||||
|
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
// Package internal 实现奥塞斯开放平台(微信企业付/企业码)插件。
|
||||||
|
// 覆盖接口:
|
||||||
|
// - Order 3.2.8 微信企业付购卡 + 3.2.4 确认订单(扣企业账户余额,确认成功后才可展码)
|
||||||
|
// - Query 3.2.9 微信企业付额度卡查询
|
||||||
|
// - Notify 3.3.5 授权结果通知 / 3.3.6 交易通知(验签 + 应答)
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.cdlsxd.cn/sdk/plugin/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 插件通信信息,若与平台配置不对应则会报错 panic
|
||||||
|
const (
|
||||||
|
Tag = "osais_wechat_qiyema"
|
||||||
|
Version = 1
|
||||||
|
CookieKey = "osais_wechat_qiyema"
|
||||||
|
CookieValue = "osais_wechat_qiyema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 异步通知接口名称(文档 3.3 节)
|
||||||
|
const (
|
||||||
|
NotifyMethodAuth = "osais.wxbizpay.auth" // 3.3.5 授权结果通知
|
||||||
|
NotifyMethodTrade = "osais.wxbizpay.trade" // 3.3.6 交易通知
|
||||||
|
)
|
||||||
|
|
||||||
|
// OsaisWechatQiyemaService 企业码插件服务
|
||||||
|
type OsaisWechatQiyemaService struct{}
|
||||||
|
|
||||||
|
// Order 3.2.8 购卡 + 3.2.4 确认订单:
|
||||||
|
// 购卡返回成功后必须调用确认订单接口,确认成功(confirmStatus=1,扣企业账户余额)后额度卡才能使用。
|
||||||
|
// 入参约定:
|
||||||
|
// - order.order_no → bizOrderNo(调用方订单号,保持唯一,购卡与确认共用)
|
||||||
|
// - order.account → bizUserId(用户编号)
|
||||||
|
// - order.amount → 购卡金额(单位:元,范围 1-1000,接口内部转分)
|
||||||
|
// - product → 不使用(授权 3.2.7 由业务方另行处理)
|
||||||
|
func (p *OsaisWechatQiyemaService) Order(ctx context.Context, request *proto.OrderRequest) (resp *proto.OrderResponse, respErr error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
respErr = proto.ErrorPanic(fmt.Sprintf("panic: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
config, err := transConfig(request.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.2.8 购卡
|
||||||
|
buyResp, err := config.BuyCard(ctx, request.GetOrder())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3.2.4 确认订单:购卡成功后必须确认,扣款成功额度卡才可用
|
||||||
|
confirmResp, err := config.ConfirmOrder(ctx, buyResp.BizOrderNo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, proto.ErrorRequestFail("购卡成功但确认订单失败(3.2.4): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(map[string]any{
|
||||||
|
"card": buyResp,
|
||||||
|
"confirmStatus": confirmResp.ConfirmStatus,
|
||||||
|
})
|
||||||
|
|
||||||
|
if confirmResp.ConfirmStatus != 1 {
|
||||||
|
return &proto.OrderResponse{
|
||||||
|
Result: &proto.Result{
|
||||||
|
Status: proto.Status_FAIL,
|
||||||
|
OrderNo: request.GetOrder().GetOrderNo(),
|
||||||
|
TradeNo: buyResp.CardNo,
|
||||||
|
Message: fmt.Sprintf("确认订单失败(3.2.4): confirmStatus=%d, 额度卡暂不可用", confirmResp.ConfirmStatus),
|
||||||
|
Data: data,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &proto.OrderResponse{
|
||||||
|
Result: &proto.Result{
|
||||||
|
Status: proto.Status_SUCCESS,
|
||||||
|
OrderNo: request.GetOrder().GetOrderNo(),
|
||||||
|
TradeNo: buyResp.CardNo,
|
||||||
|
Message: "购卡并确认成功",
|
||||||
|
Data: data,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query 3.2.9 微信企业付额度卡查询。
|
||||||
|
// 入参约定:
|
||||||
|
// - order.account → bizUserId(必填)
|
||||||
|
// - order.trade_no → cardNo(可选,为空查询用户全部额度卡)
|
||||||
|
// - order.order_no → 原路回显
|
||||||
|
func (p *OsaisWechatQiyemaService) Query(ctx context.Context, request *proto.QueryRequest) (resp *proto.QueryResponse, respErr error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
respErr = proto.ErrorPanic(fmt.Sprintf("panic: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
config, err := transConfig(request.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cardResp, err := config.QueryCards(ctx, request.GetOrder())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.Marshal(cardResp)
|
||||||
|
return &proto.QueryResponse{
|
||||||
|
Result: &proto.Result{
|
||||||
|
Status: proto.Status_SUCCESS,
|
||||||
|
OrderNo: request.GetOrder().GetOrderNo(),
|
||||||
|
TradeNo: request.GetOrder().GetTradeNo(),
|
||||||
|
Message: "查询成功",
|
||||||
|
Data: data,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify 3.3.5 授权结果通知 / 3.3.6 交易通知:
|
||||||
|
// 验签通过后解析 bizContent,按 method 分发;应答按文档 2.4.2 组装并签名。
|
||||||
|
// 结果约定:
|
||||||
|
// - Data 解析后的业务字段 JSON(auth: bizUserId/status;trade: cardNo/amount/name/tradeTime/tradeType)
|
||||||
|
// - Extra 完整原始通知报文(含公共参数与签名,供审计)
|
||||||
|
// - Message 人类可读摘要;TradeNo=cardNo(trade);授权通知无订单标识,OrderNo 留空
|
||||||
|
// - 3.3.5 授权通知由业务方另行处理,若误投营销充值单回调链路将被识别并仅应答、不落库
|
||||||
|
func (p *OsaisWechatQiyemaService) Notify(ctx context.Context, request *proto.NotifyRequest) (resp *proto.NotifyResponse, respErr error) {
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
respErr = proto.ErrorPanic(fmt.Sprintf("panic: %v", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
config, err := transConfig(request.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := config.parseNotify(request.GetBody())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应答报文:code/msg/data 拼接后签名(文档 2.4.2),Return 为应答 JSON 文本
|
||||||
|
reply, err := config.buildNotifyReply()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &proto.NotifyResponse{
|
||||||
|
Result: result,
|
||||||
|
Return: reply,
|
||||||
|
Headers: `{"Content-Type":["application/json"]}`,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.cdlsxd.cn/sdk/plugin/proto"
|
||||||
|
"plugins/utils/osais"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试密钥对:模拟网关用网关私钥签名(奥塞斯角色),config.VerifyKey 用其公钥
|
||||||
|
func testKeyPair(t *testing.T) (priB64, pubB64 string) {
|
||||||
|
t.Helper()
|
||||||
|
pri, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成密钥失败: %v", err)
|
||||||
|
}
|
||||||
|
priDER, err := x509.MarshalPKCS8PrivateKey(pri)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(&pri.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码公钥失败: %v", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(priDER),
|
||||||
|
base64.StdEncoding.EncodeToString(pubDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockGateway 模拟奥塞斯网关:按 method 分发,成功响应 data 为 JSON 对象(字段按字典序),签名后返回
|
||||||
|
type mockGateway struct {
|
||||||
|
priB64 string
|
||||||
|
ts *httptest.Server
|
||||||
|
// 可控行为
|
||||||
|
confirmStatus int // 确认订单返回的 confirmStatus
|
||||||
|
failMethod string
|
||||||
|
failCode string
|
||||||
|
failMsg string
|
||||||
|
// 观测值
|
||||||
|
lastBuyAmount int64 // 最近一次购卡请求金额(分)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockGateway(t *testing.T, priB64 string) *mockGateway {
|
||||||
|
g := &mockGateway{priB64: priB64, confirmStatus: 1}
|
||||||
|
g.ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
BizContent json.RawMessage `json:"bizContent"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
|
||||||
|
if g.failMethod == req.Method {
|
||||||
|
// 业务失败:真实网关不签名(sign 为 null)
|
||||||
|
b, _ := json.Marshal(map[string]any{"code": g.failCode, "msg": g.failMsg, "data": nil, "sign": nil})
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var data any
|
||||||
|
switch req.Method {
|
||||||
|
case MethodBuyCard:
|
||||||
|
var bizStr string
|
||||||
|
_ = json.Unmarshal(req.BizContent, &bizStr) // bizContent 为 JSON 字符串包裹的 JSON
|
||||||
|
var biz struct {
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(bizStr), &biz)
|
||||||
|
g.lastBuyAmount = biz.Amount
|
||||||
|
data = map[string]string{
|
||||||
|
"bizOrderNo": "TEST-BIZ-001",
|
||||||
|
"cardNo": "QYMTEST000001",
|
||||||
|
"orderNo": "OS-TEST-001",
|
||||||
|
}
|
||||||
|
case MethodOrderConf:
|
||||||
|
data = map[string]any{
|
||||||
|
"bizOrderNo": "TEST-BIZ-001",
|
||||||
|
"confirmStatus": g.confirmStatus, // 字段按字典序排列(bizOrderNo < confirmStatus < orderNo)
|
||||||
|
"orderNo": "OS-TEST-002",
|
||||||
|
}
|
||||||
|
case MethodCardQry:
|
||||||
|
data = map[string]any{
|
||||||
|
"cardList": []map[string]any{
|
||||||
|
{"amount": 10000, "balance": 10000, "cardNo": "QYMTEST000001", "status": "1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown method", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataJSON, _ := json.Marshal(data)
|
||||||
|
// 成功响应签名:code/msg/data 拼接(data 用 JSON 字符串形式,与文档示例一致)
|
||||||
|
params := map[string]any{"code": "0", "msg": "success", "data": string(dataJSON)}
|
||||||
|
content, _ := osais.BuildContent(params)
|
||||||
|
sig, _ := osais.Sign([]byte(content), g.priB64)
|
||||||
|
b, _ := json.Marshal(map[string]any{"code": "0", "msg": "success", "data": string(dataJSON), "sign": sig})
|
||||||
|
_, _ = w.Write(b)
|
||||||
|
}))
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfig(baseURL, verifyKey, signKey string) []byte {
|
||||||
|
c := map[string]any{
|
||||||
|
"base_url": baseURL,
|
||||||
|
"app_id": "1j0m408660091832",
|
||||||
|
"enterprise_code": "lansexd",
|
||||||
|
"sign_key": signKey,
|
||||||
|
"verify_key": verifyKey,
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(c)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderBuyCardAndConfirm(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
g := newMockGateway(t, gatewayPri)
|
||||||
|
defer g.ts.Close()
|
||||||
|
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
resp, err := svc.Order(context.Background(), &proto.OrderRequest{
|
||||||
|
Config: testConfig(g.ts.URL, gatewayPub, merchantPri),
|
||||||
|
Order: &proto.OrderRequest_Order{
|
||||||
|
OrderNo: "QYM-ORDER-001",
|
||||||
|
Account: "u-13800000000",
|
||||||
|
Amount: 100, // 元
|
||||||
|
},
|
||||||
|
Product: &proto.OrderRequest_Product{ProductNo: "100"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Order 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_SUCCESS {
|
||||||
|
t.Fatalf("期望 SUCCESS, got %s, msg=%s", resp.Result.Status, resp.Result.Message)
|
||||||
|
}
|
||||||
|
if resp.Result.TradeNo != "QYMTEST000001" {
|
||||||
|
t.Fatalf("期望 tradeNo=QYMTEST000001, got %s", resp.Result.TradeNo)
|
||||||
|
}
|
||||||
|
if resp.Result.OrderNo != "QYM-ORDER-001" {
|
||||||
|
t.Fatalf("期望 orderNo 原路返回, got %s", resp.Result.OrderNo)
|
||||||
|
}
|
||||||
|
var data struct {
|
||||||
|
ConfirmStatus int `json:"confirmStatus"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(resp.Result.Data, &data); err != nil {
|
||||||
|
t.Fatalf("解析 data 失败: %v", err)
|
||||||
|
}
|
||||||
|
if data.ConfirmStatus != 1 {
|
||||||
|
t.Fatalf("期望 confirmStatus=1, got %d", data.ConfirmStatus)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderConfirmFailed(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
g := newMockGateway(t, gatewayPri)
|
||||||
|
g.confirmStatus = 0 // 确认失败
|
||||||
|
defer g.ts.Close()
|
||||||
|
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
resp, err := svc.Order(context.Background(), &proto.OrderRequest{
|
||||||
|
Config: testConfig(g.ts.URL, gatewayPub, merchantPri),
|
||||||
|
Order: &proto.OrderRequest_Order{
|
||||||
|
OrderNo: "QYM-ORDER-002",
|
||||||
|
Account: "u-13800000000",
|
||||||
|
Amount: 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Order 不应报错(确认失败应返回 FAIL 结果): %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_FAIL {
|
||||||
|
t.Fatalf("期望 FAIL, got %s", resp.Result.Status)
|
||||||
|
}
|
||||||
|
if resp.Result.TradeNo != "QYMTEST000001" {
|
||||||
|
t.Fatalf("确认失败也应返回 cardNo, got %s", resp.Result.TradeNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderBuyCardDecimalAmount(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
g := newMockGateway(t, gatewayPri)
|
||||||
|
defer g.ts.Close()
|
||||||
|
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
resp, err := svc.Order(context.Background(), &proto.OrderRequest{
|
||||||
|
Config: testConfig(g.ts.URL, gatewayPub, merchantPri),
|
||||||
|
Order: &proto.OrderRequest_Order{
|
||||||
|
OrderNo: "QYM-ORDER-DEC",
|
||||||
|
Account: "u-13800000000",
|
||||||
|
Amount: 19.9, // 元:浮点误差下 19.9*100=1989.999...,必须四舍五入为 1990
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Order 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_SUCCESS {
|
||||||
|
t.Fatalf("期望 SUCCESS, got %s, msg=%s", resp.Result.Status, resp.Result.Message)
|
||||||
|
}
|
||||||
|
if g.lastBuyAmount != 1990 {
|
||||||
|
t.Fatalf("19.9 元应转为 1990 分, got %d", g.lastBuyAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderAmountRange(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
g := newMockGateway(t, gatewayPri)
|
||||||
|
defer g.ts.Close()
|
||||||
|
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
_, err := svc.Order(context.Background(), &proto.OrderRequest{
|
||||||
|
Config: testConfig(g.ts.URL, gatewayPub, merchantPri),
|
||||||
|
Order: &proto.OrderRequest_Order{
|
||||||
|
OrderNo: "QYM-ORDER-003",
|
||||||
|
Account: "u-13800000000",
|
||||||
|
Amount: 0.5, // 0.5 元 < 1 元
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("金额越界应报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrderGatewayBizError(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
g := newMockGateway(t, gatewayPri)
|
||||||
|
g.failMethod = MethodBuyCard
|
||||||
|
g.failCode = "2202"
|
||||||
|
g.failMsg = "微信企业付未授权"
|
||||||
|
defer g.ts.Close()
|
||||||
|
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
_, err := svc.Order(context.Background(), &proto.OrderRequest{
|
||||||
|
Config: testConfig(g.ts.URL, gatewayPub, merchantPri),
|
||||||
|
Order: &proto.OrderRequest_Order{
|
||||||
|
OrderNo: "QYM-ORDER-004",
|
||||||
|
Account: "u-13800000000",
|
||||||
|
Amount: 100,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("网关业务失败应报错")
|
||||||
|
}
|
||||||
|
if !contains(err.Error(), "2202") {
|
||||||
|
t.Fatalf("错误信息应包含响应码 2202, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryCards(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
g := newMockGateway(t, gatewayPri)
|
||||||
|
defer g.ts.Close()
|
||||||
|
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
resp, err := svc.Query(context.Background(), &proto.QueryRequest{
|
||||||
|
Config: testConfig(g.ts.URL, gatewayPub, merchantPri),
|
||||||
|
Order: &proto.QueryRequest_Order{
|
||||||
|
OrderNo: "QYM-ORDER-001",
|
||||||
|
TradeNo: "QYMTEST000001",
|
||||||
|
Account: "u-13800000000",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_SUCCESS {
|
||||||
|
t.Fatalf("期望 SUCCESS, got %s", resp.Result.Status)
|
||||||
|
}
|
||||||
|
if !contains(string(resp.Result.Data), "QYMTEST000001") {
|
||||||
|
t.Fatalf("data 应包含卡号, got: %s", resp.Result.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNotify 构造签名通知报文(模拟奥塞斯推送),appid 默认与配置一致
|
||||||
|
func buildNotify(t *testing.T, priB64, method string, biz map[string]any) []byte {
|
||||||
|
return buildNotifyWithAppid(t, priB64, method, biz, "1j0m408660091832")
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildNotifyWithAppid(t *testing.T, priB64, method string, biz map[string]any, appid string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
bizJSON, _ := json.Marshal(biz)
|
||||||
|
params := map[string]any{
|
||||||
|
"appid": appid,
|
||||||
|
"method": method,
|
||||||
|
"timestamp": fmt.Sprintf("%d", 1504147887642),
|
||||||
|
"version": "1.0",
|
||||||
|
"bizContent": string(bizJSON),
|
||||||
|
}
|
||||||
|
content, _ := osais.BuildContent(params)
|
||||||
|
sig, _ := osais.Sign([]byte(content), priB64)
|
||||||
|
params["sign"] = sig
|
||||||
|
b, _ := json.Marshal(params)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyAuthSuccess(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, merchantPub := testKeyPair(t)
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
|
||||||
|
body := buildNotify(t, gatewayPri, NotifyMethodAuth, map[string]any{"bizUserId": "u-1", "status": 1})
|
||||||
|
resp, err := svc.Notify(context.Background(), &proto.NotifyRequest{
|
||||||
|
Config: testConfig("https://gateway.test2.osais.cc", gatewayPub, merchantPri),
|
||||||
|
Headers: []byte(`{}`),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notify 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_SUCCESS {
|
||||||
|
t.Fatalf("授权成功应返回 SUCCESS, got %s", resp.Result.Status)
|
||||||
|
}
|
||||||
|
if !contains(resp.Result.Message, "授权成功") {
|
||||||
|
t.Fatalf("Message 应含授权成功摘要, got: %s", resp.Result.Message)
|
||||||
|
}
|
||||||
|
// Data 应为解析后的业务字段(不含公共参数)
|
||||||
|
if !contains(string(resp.Result.Data), `"bizUserId":"u-1"`) || !contains(string(resp.Result.Data), `"status":1`) {
|
||||||
|
t.Fatalf("Data 应为业务字段 JSON, got: %s", resp.Result.Data)
|
||||||
|
}
|
||||||
|
if contains(string(resp.Result.Data), "appid") || contains(string(resp.Result.Data), "sign") {
|
||||||
|
t.Fatalf("Data 不应含公共参数, got: %s", resp.Result.Data)
|
||||||
|
}
|
||||||
|
// Extra 保留完整原始报文
|
||||||
|
if !bytes.Equal(resp.Result.Extra, body) {
|
||||||
|
t.Fatal("Extra 应为完整原始通知报文")
|
||||||
|
}
|
||||||
|
if !contains(resp.Return, `"code":"0"`) {
|
||||||
|
t.Fatalf("应答应包含 code=0, got: %s", resp.Return)
|
||||||
|
}
|
||||||
|
// 应答签名可验(对接方私钥签名,用对接方公钥验)
|
||||||
|
var reply struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(resp.Return), &reply); err != nil {
|
||||||
|
t.Fatalf("应答解析失败: %v", err)
|
||||||
|
}
|
||||||
|
content, _ := osais.BuildContent(map[string]any{"code": reply.Code, "msg": "success", "data": "{}"})
|
||||||
|
ok, err := osais.Verify([]byte(content), reply.Sign, merchantPub)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("应答验签失败: err=%v ok=%v", err, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 授权失败通知(status=0)应返回 FAIL 结果,应答仍为 code=0(接收成功)
|
||||||
|
func TestNotifyAuthFailed(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
|
||||||
|
body := buildNotify(t, gatewayPri, NotifyMethodAuth, map[string]any{"bizUserId": "u-1", "status": 0})
|
||||||
|
resp, err := svc.Notify(context.Background(), &proto.NotifyRequest{
|
||||||
|
Config: testConfig("https://gateway.test2.osais.cc", gatewayPub, merchantPri),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notify 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_FAIL {
|
||||||
|
t.Fatalf("授权失败应返回 FAIL, got %s", resp.Result.Status)
|
||||||
|
}
|
||||||
|
if !contains(resp.Result.Message, "授权失败") {
|
||||||
|
t.Fatalf("Message 应含授权失败摘要, got: %s", resp.Result.Message)
|
||||||
|
}
|
||||||
|
if !contains(resp.Return, `"code":"0"`) {
|
||||||
|
t.Fatalf("验签通过即应答 code=0, got: %s", resp.Return)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 消费通知(tradeType=1)应返回 WRITE_OFF,退款(tradeType=2)返回 REFUND
|
||||||
|
func TestNotifyTradeConsume(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
|
||||||
|
body := buildNotify(t, gatewayPri, NotifyMethodTrade, map[string]any{
|
||||||
|
"bizUserId": "u-1", "cardNo": "QYMTEST000001", "amount": 1200,
|
||||||
|
"name": "示例商户", "tradeTime": "2026-09-01 12:00:00", "tradeType": 1,
|
||||||
|
})
|
||||||
|
resp, err := svc.Notify(context.Background(), &proto.NotifyRequest{
|
||||||
|
Config: testConfig("https://gateway.test2.osais.cc", gatewayPub, merchantPri),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notify 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_WRITE_OFF {
|
||||||
|
t.Fatalf("消费应返回 WRITE_OFF, got %s", resp.Result.Status)
|
||||||
|
}
|
||||||
|
if resp.Result.TradeNo != "QYMTEST000001" {
|
||||||
|
t.Fatalf("期望 tradeNo=cardNo, got %s", resp.Result.TradeNo)
|
||||||
|
}
|
||||||
|
if !contains(resp.Result.Message, "消费") || !contains(string(resp.Result.Data), `"tradeType":1`) {
|
||||||
|
t.Fatalf("Message/Data 应含消费摘要, msg=%s data=%s", resp.Result.Message, resp.Result.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知 appid 与配置不一致时应拒绝(防跨应用误投)
|
||||||
|
func TestNotifyAppIdMismatch(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
|
||||||
|
body := buildNotifyWithAppid(t, gatewayPri, NotifyMethodTrade, map[string]any{"bizUserId": "u-1", "tradeType": 1}, "other-app-id")
|
||||||
|
_, err := svc.Notify(context.Background(), &proto.NotifyRequest{
|
||||||
|
Config: testConfig("https://gateway.test2.osais.cc", gatewayPub, merchantPri),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("appid 不一致应被拒绝")
|
||||||
|
}
|
||||||
|
if !contains(err.Error(), "appid") {
|
||||||
|
t.Fatalf("错误信息应提及 appid, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyTradeRefund(t *testing.T) {
|
||||||
|
gatewayPri, gatewayPub := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
|
||||||
|
body := buildNotify(t, gatewayPri, NotifyMethodTrade, map[string]any{
|
||||||
|
"bizUserId": "u-1", "cardNo": "QYMTEST000001", "amount": 100,
|
||||||
|
"name": "示例商户", "tradeTime": "2026-09-01 12:00:00", "tradeType": 2,
|
||||||
|
})
|
||||||
|
resp, err := svc.Notify(context.Background(), &proto.NotifyRequest{
|
||||||
|
Config: testConfig("https://gateway.test2.osais.cc", gatewayPub, merchantPri),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Notify 失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Result.Status != proto.Status_REFUND {
|
||||||
|
t.Fatalf("退款应返回 REFUND, got %s", resp.Result.Status)
|
||||||
|
}
|
||||||
|
if resp.Result.TradeNo != "QYMTEST000001" {
|
||||||
|
t.Fatalf("期望 tradeNo=cardNo, got %s", resp.Result.TradeNo)
|
||||||
|
}
|
||||||
|
if !contains(resp.Result.Message, "退款") {
|
||||||
|
t.Fatalf("Message 应含退款摘要, got: %s", resp.Result.Message)
|
||||||
|
}
|
||||||
|
if !contains(string(resp.Result.Data), `"amount":100`) {
|
||||||
|
t.Fatalf("Data 应含业务字段 amount, got: %s", resp.Result.Data)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(resp.Result.Extra, body) {
|
||||||
|
t.Fatal("Extra 应为完整原始通知报文")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyVerifyRejected(t *testing.T) {
|
||||||
|
gatewayPri, _ := testKeyPair(t)
|
||||||
|
merchantPri, _ := testKeyPair(t)
|
||||||
|
_, otherPub := testKeyPair(t) // 错误的公钥
|
||||||
|
svc := &OsaisWechatQiyemaService{}
|
||||||
|
|
||||||
|
body := buildNotify(t, gatewayPri, NotifyMethodAuth, map[string]any{"bizUserId": "u-1", "status": 1})
|
||||||
|
_, err := svc.Notify(context.Background(), &proto.NotifyRequest{
|
||||||
|
Config: testConfig("https://gateway.test2.osais.cc", otherPub, merchantPri),
|
||||||
|
Body: body,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("错误公钥验签应被拒绝")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return strings.Contains(s, sub)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package po
|
||||||
|
|
||||||
|
// BuyCardResp 3.2.8 购卡 响应 data
|
||||||
|
type BuyCardResp struct {
|
||||||
|
CardNo string `json:"cardNo"` // 卡号
|
||||||
|
BizOrderNo string `json:"bizOrderNo"` // 客户订单号
|
||||||
|
OrderNo string `json:"orderNo"` // 奥塞斯订单号
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderConfirmResp 3.2.4 确认订单 响应 data
|
||||||
|
type OrderConfirmResp struct {
|
||||||
|
OrderNo string `json:"orderNo"` // 订单号
|
||||||
|
BizOrderNo string `json:"bizOrderNo"` // 客户订单号
|
||||||
|
ConfirmStatus int `json:"confirmStatus"` // 确认状态:1 成功,0 失败
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package po
|
||||||
|
|
||||||
|
// CardQueryResp 3.2.9 额度卡查询 响应 data
|
||||||
|
type CardQueryResp struct {
|
||||||
|
CardList []CardInfo `json:"cardList"` // 额度卡集合
|
||||||
|
}
|
||||||
|
|
||||||
|
// CardInfo 额度卡信息
|
||||||
|
type CardInfo struct {
|
||||||
|
CardNo string `json:"cardNo"` // 卡号
|
||||||
|
Amount int64 `json:"amount"` // 面值(分)
|
||||||
|
Balance int64 `json:"balance"` // 余额(分)
|
||||||
|
Status string `json:"status"` // 1 有效 / 0 失效
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,368 @@
|
||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.cdlsxd.cn/sdk/plugin/proto"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"plugins/osais_wechat_qiyema/internal/po"
|
||||||
|
"plugins/utils/osais"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 插件配置(由平台按 tag 下发,随 OrderRequest.Config 传入)。
|
||||||
|
// 字段说明:
|
||||||
|
// - base_url 奥塞斯网关地址(测试/生产)
|
||||||
|
// - app_id 开放平台 appid
|
||||||
|
// - enterprise_code 企业编号
|
||||||
|
// - sign_key 对接方私钥(PKCS8 Base64):请求签名 + 回调应答签名
|
||||||
|
// - verify_key 奥塞斯公钥(PKIX Base64):响应验签 + 回调验签
|
||||||
|
type Config struct {
|
||||||
|
BaseUrl string `json:"base_url" validate:"required"`
|
||||||
|
AppId string `json:"app_id" validate:"required"`
|
||||||
|
EnterpriseCode string `json:"enterprise_code" validate:"required"`
|
||||||
|
SignKey string `json:"sign_key" validate:"required"`
|
||||||
|
VerifyKey string `json:"verify_key" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 业务接口名称(文档 3.2 节)
|
||||||
|
const (
|
||||||
|
MethodBuyCard = "osais.wxbizpay.quota.card" // 3.2.8 购卡(网关实测正确拼写为 quota)
|
||||||
|
MethodCardQry = "osais.wxbizpay.card.query" // 3.2.9 额度卡查询
|
||||||
|
MethodOrderConf = "osais.order.confirm" // 3.2.4 确认订单
|
||||||
|
)
|
||||||
|
|
||||||
|
func transConfig(config []byte) (*Config, error) {
|
||||||
|
var c Config
|
||||||
|
if err := json.Unmarshal(config, &c); err != nil {
|
||||||
|
return nil, proto.ErrorConfigFail("配置解析失败: %v", err)
|
||||||
|
}
|
||||||
|
if err := validator.New().Struct(c); err != nil {
|
||||||
|
return nil, proto.ErrorConfigFail("配置校验失败: %v", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 奥塞斯网关客户端 ----
|
||||||
|
|
||||||
|
// request 组装公共参数、签名并 POST,返回响应 data 文本。
|
||||||
|
// 真实网关行为(实测):成功响应(code=0)携带签名且 data 内部字段按字典序
|
||||||
|
// 排列参与签名;业务失败响应(code!=0)不签名(sign 为 null),直接透出业务错误。
|
||||||
|
func (c *Config) request(ctx context.Context, method string, bizContent any) (string, error) {
|
||||||
|
bizBytes, err := json.Marshal(bizContent)
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorRequestFail("序列化 bizContent 失败: %v", err)
|
||||||
|
}
|
||||||
|
bizStr := string(bizBytes)
|
||||||
|
|
||||||
|
// 公共请求参数(文档 2.4.1),sign 不参与签名
|
||||||
|
params := map[string]any{
|
||||||
|
"appid": c.AppId,
|
||||||
|
"method": method,
|
||||||
|
"timestamp": fmt.Sprintf("%d", time.Now().UnixMilli()),
|
||||||
|
"version": "1.0",
|
||||||
|
"bizContent": bizStr,
|
||||||
|
}
|
||||||
|
signContent, err := osais.BuildContent(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sig, err := osais.Sign([]byte(signContent), c.SignKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorRequestFail("请求签名失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reqBody := map[string]any{
|
||||||
|
"appid": c.AppId,
|
||||||
|
"method": method,
|
||||||
|
"timestamp": params["timestamp"],
|
||||||
|
"version": "1.0",
|
||||||
|
"bizContent": bizStr,
|
||||||
|
"sign": sig,
|
||||||
|
}
|
||||||
|
jsonBody, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseUrl, bytes.NewReader(jsonBody))
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorRequestFail("构造请求失败: %v", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorRequestFail("请求奥塞斯网关失败: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorRequestFail("读取响应失败: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", proto.ErrorRequestFail("奥塞斯网关返回异常状态码 %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析公共响应参数
|
||||||
|
var common struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &common); err != nil {
|
||||||
|
return "", proto.ErrorResponseFail("解析响应失败: %v, body=%s", err, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应验签(文档 2.4.2):仅成功响应签名;data 为 null 不参与拼接
|
||||||
|
if common.Code == "0" {
|
||||||
|
params := map[string]any{"code": common.Code, "msg": common.Msg}
|
||||||
|
if dataVal, ok := dataValue(common.Data); ok {
|
||||||
|
// data 对象内部字段按字典序重排后参与拼接(网关实测行为)
|
||||||
|
params["data"] = string(osais.SortJSONKeys([]byte(dataVal)))
|
||||||
|
}
|
||||||
|
verifyContent, err := osais.BuildContent(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ok, err := osais.Verify([]byte(verifyContent), common.Sign, c.VerifyKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorSignFail("响应验签失败: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", proto.ErrorSignFail("响应验签失败: 签名不匹配, content=%s, body=%s", verifyContent, string(respBody))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.Code != "0" {
|
||||||
|
return "", proto.ErrorRequestFail("奥塞斯返回错误: code=%s msg=%s", common.Code, common.Msg)
|
||||||
|
}
|
||||||
|
dataVal, _ := dataValue(common.Data)
|
||||||
|
return dataVal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataValue 返回 data 文本:null 或缺失时 ok=false;JSON 字符串解包为内容;对象/数组保留原始文本
|
||||||
|
func dataValue(raw json.RawMessage) (string, bool) {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(trimmed, &s); err == nil {
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
return string(trimmed), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyCard 3.2.8 购卡
|
||||||
|
func (c *Config) BuyCard(ctx context.Context, order *proto.OrderRequest_Order) (*po.BuyCardResp, error) {
|
||||||
|
if order == nil {
|
||||||
|
return nil, proto.ErrorParamFail("order 不能为空")
|
||||||
|
}
|
||||||
|
if order.OrderNo == "" {
|
||||||
|
return nil, proto.ErrorParamFail("order.order_no(bizOrderNo)不能为空")
|
||||||
|
}
|
||||||
|
if order.Account == "" {
|
||||||
|
return nil, proto.ErrorParamFail("order.account(bizUserId)不能为空")
|
||||||
|
}
|
||||||
|
amount := int64(math.Round(float64(order.Amount) * 100)) // 元 → 分(四舍五入,避免浮点误差截断)
|
||||||
|
if amount < 100 || amount > 100000 {
|
||||||
|
return nil, proto.ErrorParamFail("购卡金额需在 1元-1000元 之间, 当前: %.2f 元", order.Amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := c.request(ctx, MethodBuyCard, map[string]any{
|
||||||
|
"bizUserId": order.Account,
|
||||||
|
"amount": amount,
|
||||||
|
"bizOrderNo": order.OrderNo,
|
||||||
|
"enterpriseCode": c.EnterpriseCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp po.BuyCardResp
|
||||||
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||||
|
return nil, proto.ErrorResponseFail("解析购卡响应失败: %v", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfirmOrder 3.2.4 确认订单:购卡成功后必须调用,确认成功(扣企业账户余额)后额度卡才能使用
|
||||||
|
func (c *Config) ConfirmOrder(ctx context.Context, bizOrderNo string) (*po.OrderConfirmResp, error) {
|
||||||
|
data, err := c.request(ctx, MethodOrderConf, map[string]any{
|
||||||
|
"bizOrderNo": bizOrderNo,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp po.OrderConfirmResp
|
||||||
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||||
|
return nil, proto.ErrorResponseFail("解析确认订单响应失败: %v", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryCards 3.2.9 额度卡查询:cardNo 为空查询用户全部额度卡
|
||||||
|
func (c *Config) QueryCards(ctx context.Context, order *proto.QueryRequest_Order) (*po.CardQueryResp, error) {
|
||||||
|
if order == nil {
|
||||||
|
return nil, proto.ErrorParamFail("order 不能为空")
|
||||||
|
}
|
||||||
|
if order.Account == "" {
|
||||||
|
return nil, proto.ErrorParamFail("order.account(bizUserId)不能为空")
|
||||||
|
}
|
||||||
|
data, err := c.request(ctx, MethodCardQry, map[string]any{
|
||||||
|
"bizUserId": order.Account,
|
||||||
|
"cardNo": order.TradeNo,
|
||||||
|
"enterpriseCode": c.EnterpriseCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var resp po.CardQueryResp
|
||||||
|
if err := json.Unmarshal([]byte(data), &resp); err != nil {
|
||||||
|
return nil, proto.ErrorResponseFail("解析额度卡查询响应失败: %v", err)
|
||||||
|
}
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 回调处理 ----
|
||||||
|
|
||||||
|
// parseNotify 验签并解析异步通知(3.3.5 / 3.3.6),返回业务结果。
|
||||||
|
// 通知报文沿用公共请求参数结构:appid/method/timestamp/version/bizContent/sign。
|
||||||
|
// 返回约定:
|
||||||
|
// - Result.Data 解析后的业务字段 JSON(auth: bizUserId/status;trade: cardNo/amount/name/tradeTime/tradeType)
|
||||||
|
// - Result.Extra 完整原始通知报文(含公共参数与签名,供审计/重放比对)
|
||||||
|
// - Result.Message 人类可读摘要
|
||||||
|
func (c *Config) parseNotify(body []byte) (*proto.Result, error) {
|
||||||
|
var notify struct {
|
||||||
|
AppId string `json:"appid"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
BizContent json.RawMessage `json:"bizContent"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, ¬ify); err != nil {
|
||||||
|
return nil, proto.ErrorParamFail("通知报文解析失败: %v", err)
|
||||||
|
}
|
||||||
|
if notify.Method == "" || notify.Sign == "" {
|
||||||
|
return nil, proto.ErrorParamFail("通知报文缺少 method/sign")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验签:删除 sign 后其余字段字典序拼接
|
||||||
|
params := map[string]any{}
|
||||||
|
if err := json.Unmarshal(body, ¶ms); err != nil {
|
||||||
|
return nil, proto.ErrorParamFail("通知报文解析失败: %v", err)
|
||||||
|
}
|
||||||
|
delete(params, "sign")
|
||||||
|
signContent, err := osais.BuildContent(params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ok, err := osais.Verify([]byte(signContent), notify.Sign, c.VerifyKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, proto.ErrorSignFail("通知验签失败: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, proto.ErrorSignFail("通知验签失败: 签名不匹配, content=%s", signContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appid 一致性校验:防止他方应用通知误投(报文携带 appid 时必须与配置一致)
|
||||||
|
if notify.AppId != "" && notify.AppId != c.AppId {
|
||||||
|
return nil, proto.ErrorParamFail("通知 appid[%s]与配置 app_id[%s]不一致", notify.AppId, c.AppId)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch notify.Method {
|
||||||
|
case NotifyMethodAuth: // 3.3.5 授权结果通知
|
||||||
|
var biz struct {
|
||||||
|
BizUserID string `json:"bizUserId"`
|
||||||
|
Status int `json:"status"` // 1 成功 0 失败
|
||||||
|
}
|
||||||
|
if err := unmarshalBiz(notify.BizContent, &biz); err != nil {
|
||||||
|
return nil, proto.ErrorParamFail("授权通知 bizContent 解析失败: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(biz)
|
||||||
|
status := proto.Status_FAIL
|
||||||
|
msg := fmt.Sprintf("授权失败 bizUserId=%s", biz.BizUserID)
|
||||||
|
if biz.Status == 1 {
|
||||||
|
status = proto.Status_SUCCESS
|
||||||
|
msg = fmt.Sprintf("授权成功 bizUserId=%s", biz.BizUserID)
|
||||||
|
}
|
||||||
|
return &proto.Result{
|
||||||
|
Status: status,
|
||||||
|
Message: msg,
|
||||||
|
Data: data,
|
||||||
|
Extra: body,
|
||||||
|
}, nil
|
||||||
|
case NotifyMethodTrade: // 3.3.6 交易通知
|
||||||
|
var biz struct {
|
||||||
|
BizUserID string `json:"bizUserId"`
|
||||||
|
CardNo string `json:"cardNo"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TradeTime string `json:"tradeTime"`
|
||||||
|
TradeType int `json:"tradeType"` // 1 消费 2 退款
|
||||||
|
}
|
||||||
|
if err := unmarshalBiz(notify.BizContent, &biz); err != nil {
|
||||||
|
return nil, proto.ErrorParamFail("交易通知 bizContent 解析失败: %v", err)
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(biz)
|
||||||
|
status := proto.Status_WRITE_OFF
|
||||||
|
msg := fmt.Sprintf("额度卡消费 cardNo=%s 金额=%d 商户=%s", biz.CardNo, biz.Amount, biz.Name)
|
||||||
|
if biz.TradeType == 2 {
|
||||||
|
status = proto.Status_REFUND
|
||||||
|
msg = fmt.Sprintf("额度卡退款 cardNo=%s 金额=%d 商户=%s", biz.CardNo, biz.Amount, biz.Name)
|
||||||
|
}
|
||||||
|
return &proto.Result{
|
||||||
|
Status: status,
|
||||||
|
TradeNo: biz.CardNo,
|
||||||
|
Message: msg,
|
||||||
|
Data: data,
|
||||||
|
Extra: body,
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, proto.ErrorParamFail("不支持的通知 method: %s", notify.Method)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalBiz 解包 bizContent:报文中的 bizContent 为 JSON 字符串(字符串包裹的 JSON),
|
||||||
|
// 先解包为 string 再解析为业务结构。
|
||||||
|
func unmarshalBiz(raw json.RawMessage, v any) error {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(raw, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(s), v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNotifyReply 组装应答报文并签名(文档 2.4.2):{code,msg,data,sign}
|
||||||
|
func (c *Config) buildNotifyReply() (string, error) {
|
||||||
|
params := map[string]any{
|
||||||
|
"code": "0",
|
||||||
|
"msg": "success",
|
||||||
|
"data": "{}",
|
||||||
|
}
|
||||||
|
content, err := osais.BuildContent(params)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
sig, err := osais.Sign([]byte(content), c.SignKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", proto.ErrorRequestFail("应答签名失败: %v", err)
|
||||||
|
}
|
||||||
|
reply := map[string]any{
|
||||||
|
"code": "0",
|
||||||
|
"msg": "success",
|
||||||
|
"data": "{}",
|
||||||
|
"sign": sig,
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(reply)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.cdlsxd.cn/sdk/plugin/shared"
|
||||||
|
"github.com/hashicorp/go-plugin"
|
||||||
|
"plugins/osais_wechat_qiyema/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
plugin.Serve(&plugin.ServeConfig{
|
||||||
|
HandshakeConfig: shared.HandshakeConfig(internal.Version, internal.CookieKey, internal.CookieValue),
|
||||||
|
Plugins: shared.PluginSet(shared.NewPlugin(&internal.OsaisWechatQiyemaService{}, internal.Tag)),
|
||||||
|
GRPCServer: plugin.DefaultGRPCServer,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
// Package osais 实现奥塞斯开放平台(微信企业付/企业码)RSA(SHA256WithRSA) 双向签名,
|
||||||
|
// 逻辑对齐《开放平台接口文档v1.2》第 2.4 节签名规范与第 8 节加签示例代码。
|
||||||
|
package osais
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuildContent 将所有参数按参数名 ASCII 码升序(字典序)拼接为 key1=value1&key2=value2 字符串。
|
||||||
|
// 规则(文档 2.4.3):
|
||||||
|
// - 参数值为 null 不参与排序,空字符串参与排序
|
||||||
|
// - 参数名区分大小写
|
||||||
|
// - sign 不参与签名
|
||||||
|
// - 接口可能增加字段,验签时必须支持增加的字段
|
||||||
|
func BuildContent(params map[string]any) (string, error) {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(params))
|
||||||
|
for k, v := range params {
|
||||||
|
if v == nil {
|
||||||
|
continue // null 不参与排序
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
content := ""
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
content += "&"
|
||||||
|
}
|
||||||
|
content += k + "=" + fmt.Sprintf("%v", params[k])
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortJSONKeys 将 JSON 文本的键按 ASCII 字典序递归重排,用于响应验签的 data 归一化:
|
||||||
|
// 网关对 data 内部字段也按字典序排列后参与签名(实测验证),且值为 null 的字段
|
||||||
|
// 不参与(规范 2.4.3 "参数的值为 null 不参与排序" 递归应用到 data 内部,实测验证)。
|
||||||
|
// 非对象(字符串/数字/null/数组元素)原样保留;对象键排序并剔除 null 字段;数组元素逐个递归排序。
|
||||||
|
func SortJSONKeys(raw []byte) []byte {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
switch trimmed[0] {
|
||||||
|
case '{':
|
||||||
|
var obj map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(trimmed, &obj); err != nil {
|
||||||
|
return trimmed // 解析失败按原样返回
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(obj))
|
||||||
|
for k, v := range obj {
|
||||||
|
if bytes.Equal(bytes.TrimSpace(v), []byte("null")) {
|
||||||
|
continue // 值为 null 的字段不参与拼接
|
||||||
|
}
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteByte('{')
|
||||||
|
for i, k := range keys {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteByte(',')
|
||||||
|
}
|
||||||
|
kb, _ := json.Marshal(k)
|
||||||
|
buf.Write(kb)
|
||||||
|
buf.WriteByte(':')
|
||||||
|
buf.Write(SortJSONKeys(obj[k])) // 递归处理嵌套值
|
||||||
|
}
|
||||||
|
buf.WriteByte('}')
|
||||||
|
return buf.Bytes()
|
||||||
|
case '[':
|
||||||
|
var arr []json.RawMessage
|
||||||
|
if err := json.Unmarshal(trimmed, &arr); err != nil {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.WriteByte('[')
|
||||||
|
for i, e := range arr {
|
||||||
|
if i > 0 {
|
||||||
|
buf.WriteByte(',')
|
||||||
|
}
|
||||||
|
buf.Write(SortJSONKeys(e))
|
||||||
|
}
|
||||||
|
buf.WriteByte(']')
|
||||||
|
return buf.Bytes()
|
||||||
|
default:
|
||||||
|
return trimmed // 字符串/数字/null 等原样返回
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign 使用私钥(PKCS8 Base64)对 data 做 SHA256withRSA 签名,返回 Base64 签名值。
|
||||||
|
func Sign(data []byte, privateKeyBase64 string) (string, error) {
|
||||||
|
keyBytes, err := base64.StdEncoding.DecodeString(privateKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解码私钥失败: %w", err)
|
||||||
|
}
|
||||||
|
priKey, err := x509.ParsePKCS8PrivateKey(keyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("解析 PKCS8 私钥失败: %w", err)
|
||||||
|
}
|
||||||
|
rsaKey, ok := priKey.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("私钥不是 RSA 私钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := sha256.Sum256(data)
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("签名失败: %w", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 使用公钥验证 SHA256withRSA 签名。
|
||||||
|
// 公钥支持两种编码:X.509 SubjectPublicKeyInfo(PKIX,奥塞斯公钥)与
|
||||||
|
// PKCS#1 RSAPublicKey(对接方公钥,mock 场景),自动识别。
|
||||||
|
func Verify(data []byte, signBase64, publicKeyBase64 string) (bool, error) {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(signBase64)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("解码签名值失败: %w", err)
|
||||||
|
}
|
||||||
|
keyBytes, err := base64.StdEncoding.DecodeString(publicKeyBase64)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("解码公钥失败: %w", err)
|
||||||
|
}
|
||||||
|
var pubKey any
|
||||||
|
if pk, err := x509.ParsePKIXPublicKey(keyBytes); err == nil {
|
||||||
|
pubKey = pk
|
||||||
|
} else if pk1, err1 := x509.ParsePKCS1PublicKey(keyBytes); err1 == nil {
|
||||||
|
pubKey = pk1
|
||||||
|
} else {
|
||||||
|
return false, fmt.Errorf("解析公钥失败(非 PKIX/PKCS1 格式): %w", err)
|
||||||
|
}
|
||||||
|
rsaKey, ok := pubKey.(*rsa.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return false, errors.New("公钥不是 RSA 公钥")
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := sha256.Sum256(data)
|
||||||
|
if err := rsa.VerifyPKCS1v15(rsaKey, crypto.SHA256, digest[:], sig); err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
package osais
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 生成测试密钥对(PKCS8 私钥 / PKIX 公钥,Base64)
|
||||||
|
func testKeyPair(t *testing.T) (priB64, pubB64 string) {
|
||||||
|
t.Helper()
|
||||||
|
pri, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成密钥失败: %v", err)
|
||||||
|
}
|
||||||
|
priDER, err := x509.MarshalPKCS8PrivateKey(pri)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
priB64 = base64.StdEncoding.EncodeToString(priDER)
|
||||||
|
|
||||||
|
pubDER, err := x509.MarshalPKIXPublicKey(&pri.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("编码公钥失败: %v", err)
|
||||||
|
}
|
||||||
|
pubB64 = base64.StdEncoding.EncodeToString(pubDER)
|
||||||
|
return priB64, pubB64
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignVerify(t *testing.T) {
|
||||||
|
priB64, pubB64 := testKeyPair(t)
|
||||||
|
data := []byte("appid=1j0m408660091832&bizContent={\"bizUserId\":\"demo-user-001\"}&method=osais.wxbizpay.auth×tamp=1504147887642&version=1.0")
|
||||||
|
|
||||||
|
sig, err := Sign(data, priB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("签名失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := Verify(data, sig, pubB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("验签出错: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("验签失败:签名不匹配")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 篡改数据后验签必须失败
|
||||||
|
ok, _ = Verify([]byte(string(data[:len(data)-2])+"99"), sig, pubB64)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("篡改数据后验签仍通过,不安全")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerifyPKCS1PublicKey(t *testing.T) {
|
||||||
|
// 兼容 PKCS#1 公钥格式(对接方公钥为该格式,mock 场景)
|
||||||
|
pri, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("生成密钥失败: %v", err)
|
||||||
|
}
|
||||||
|
priDER, _ := x509.MarshalPKCS8PrivateKey(pri)
|
||||||
|
priB64 := base64.StdEncoding.EncodeToString(priDER)
|
||||||
|
pubB64 := base64.StdEncoding.EncodeToString(x509.MarshalPKCS1PublicKey(&pri.PublicKey))
|
||||||
|
|
||||||
|
data := []byte("code=0&data={}&msg=success")
|
||||||
|
sig, err := Sign(data, priB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("签名失败: %v", err)
|
||||||
|
}
|
||||||
|
ok, err := Verify(data, sig, pubB64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PKCS1 公钥验签出错: %v", err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("PKCS1 公钥验签失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildContent(t *testing.T) {
|
||||||
|
params := map[string]any{
|
||||||
|
"version": "1.0",
|
||||||
|
"appid": "abc",
|
||||||
|
"timestamp": "1504147887642",
|
||||||
|
"bizContent": "{\"a\":1}",
|
||||||
|
"nilField": nil, // null 不参与拼接
|
||||||
|
"empty": "", // 空字符串参与拼接
|
||||||
|
}
|
||||||
|
content, err := BuildContent(params)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildContent 失败: %v", err)
|
||||||
|
}
|
||||||
|
want := "appid=abc&bizContent={\"a\":1}&empty=×tamp=1504147887642&version=1.0"
|
||||||
|
if content != want {
|
||||||
|
t.Fatalf("拼接结果不符:\n got: %s\nwant: %s", content, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSortJSONKeys(t *testing.T) {
|
||||||
|
// 乱序 + null 字段:键按字典序重排,null 剔除
|
||||||
|
in := []byte(`{"mpQuery":null,"authUrl":"https://a.com/x","expireTime":"2026-09-01 12:00:00"}`)
|
||||||
|
got := string(SortJSONKeys(in))
|
||||||
|
want := `{"authUrl":"https://a.com/x","expireTime":"2026-09-01 12:00:00"}`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("排序结果不符:\n got: %s\nwant: %s", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 嵌套对象递归排序
|
||||||
|
in2 := []byte(`{"b":{"d":1,"c":2},"a":3}`)
|
||||||
|
got2 := string(SortJSONKeys(in2))
|
||||||
|
want2 := `{"a":3,"b":{"c":2,"d":1}}`
|
||||||
|
if got2 != want2 {
|
||||||
|
t.Fatalf("嵌套排序结果不符:\n got: %s\nwant: %s", got2, want2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数组元素递归排序
|
||||||
|
in3 := []byte(`[{"z":1,"a":2},{"q":3}]`)
|
||||||
|
got3 := string(SortJSONKeys(in3))
|
||||||
|
want3 := `[{"a":2,"z":1},{"q":3}]`
|
||||||
|
if got3 != want3 {
|
||||||
|
t.Fatalf("数组排序结果不符:\n got: %s\nwant: %s", got3, want3)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue