Compare commits

..

1 Commits

Author SHA1 Message Date
lirui 69a4cc893a feat(supplier): 添加微信企业付及额度卡API客户端与回调处理
- 新增奥塞斯开放平台客户端,支持订单确认、企业付授权、购卡、额度卡查询及二维码展码接口
- 实现请求签名与响应验签,支持真实网关和本地mock模式
- 增加奥塞斯订阅回调处理,包含微信额度卡授权结果及交易通知解析与验签
- 完善回调请求验证与响应签名,保障消息安全可靠
- 编写客户端单元测试,覆盖签名验签及错误处理,保障接口稳定性
- 增加示例配置文件和前端页面支持微信企业付功能演示
2026-09-04 11:40:21 +08:00
11 changed files with 1534 additions and 0 deletions

View File

@ -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`:解析后的业务字段 JSONauth: `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 不一致拒绝、验签拒绝等场景。

View File

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

View File

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

View File

@ -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 解析后的业务字段 JSONauth: bizUserId/statustrade: cardNo/amount/name/tradeTime/tradeType
// - Extra 完整原始通知报文(含公共参数与签名,供审计)
// - Message 人类可读摘要TradeNo=cardNotrade授权通知无订单标识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.2Return 为应答 JSON 文本
reply, err := config.buildNotifyReply()
if err != nil {
return nil, err
}
return &proto.NotifyResponse{
Result: result,
Return: reply,
Headers: `{"Content-Type":["application/json"]}`,
}, nil
}

View File

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

View File

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

View File

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

View File

@ -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.1sign 不参与签名
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=falseJSON 字符串解包为内容;对象/数组保留原始文本
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_nobizOrderNo不能为空")
}
if order.Account == "" {
return nil, proto.ErrorParamFail("order.accountbizUserId不能为空")
}
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.accountbizUserId不能为空")
}
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 解析后的业务字段 JSONauth: bizUserId/statustrade: 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, &notify); 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, &params); 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
}

View File

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

157
utils/osais/sign.go Normal file
View File

@ -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 SubjectPublicKeyInfoPKIX奥塞斯公钥
// 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
}

123
utils/osais/sign_test.go Normal file
View File

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