diff --git a/plugins/osais_wechat_qiyema/README.md b/plugins/osais_wechat_qiyema/README.md new file mode 100644 index 0000000..a4dada1 --- /dev/null +++ b/plugins/osais_wechat_qiyema/README.md @@ -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 不一致拒绝、验签拒绝等场景。 diff --git a/plugins/osais_wechat_qiyema/go.mod b/plugins/osais_wechat_qiyema/go.mod new file mode 100644 index 0000000..34209db --- /dev/null +++ b/plugins/osais_wechat_qiyema/go.mod @@ -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 +) diff --git a/plugins/osais_wechat_qiyema/go.sum b/plugins/osais_wechat_qiyema/go.sum new file mode 100644 index 0000000..9004086 --- /dev/null +++ b/plugins/osais_wechat_qiyema/go.sum @@ -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= diff --git a/plugins/osais_wechat_qiyema/internal/osais_wechat_qiyema.go b/plugins/osais_wechat_qiyema/internal/osais_wechat_qiyema.go new file mode 100644 index 0000000..0231261 --- /dev/null +++ b/plugins/osais_wechat_qiyema/internal/osais_wechat_qiyema.go @@ -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 +} diff --git a/plugins/osais_wechat_qiyema/internal/osais_wechat_qiyema_test.go b/plugins/osais_wechat_qiyema/internal/osais_wechat_qiyema_test.go new file mode 100644 index 0000000..a492212 --- /dev/null +++ b/plugins/osais_wechat_qiyema/internal/osais_wechat_qiyema_test.go @@ -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) +} diff --git a/plugins/osais_wechat_qiyema/internal/po/order.go b/plugins/osais_wechat_qiyema/internal/po/order.go new file mode 100644 index 0000000..8608f65 --- /dev/null +++ b/plugins/osais_wechat_qiyema/internal/po/order.go @@ -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 失败 +} diff --git a/plugins/osais_wechat_qiyema/internal/po/query.go b/plugins/osais_wechat_qiyema/internal/po/query.go new file mode 100644 index 0000000..641ba4c --- /dev/null +++ b/plugins/osais_wechat_qiyema/internal/po/query.go @@ -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 失效 +} diff --git a/plugins/osais_wechat_qiyema/internal/transform.go b/plugins/osais_wechat_qiyema/internal/transform.go new file mode 100644 index 0000000..eb7dc45 --- /dev/null +++ b/plugins/osais_wechat_qiyema/internal/transform.go @@ -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 +} diff --git a/plugins/osais_wechat_qiyema/main.go b/plugins/osais_wechat_qiyema/main.go new file mode 100644 index 0000000..182c4b0 --- /dev/null +++ b/plugins/osais_wechat_qiyema/main.go @@ -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, + }) +} diff --git a/utils/osais/sign.go b/utils/osais/sign.go new file mode 100644 index 0000000..e0e8fdc --- /dev/null +++ b/utils/osais/sign.go @@ -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 +} diff --git a/utils/osais/sign_test.go b/utils/osais/sign_test.go new file mode 100644 index 0000000..568ae81 --- /dev/null +++ b/utils/osais/sign_test.go @@ -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) + } +}