first commit
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// Package wechat 封装微信小程序登录与微信支付 V3 的真实对接能力,仅依赖 Go 标准库。
|
||||
//
|
||||
// 安全说明:
|
||||
// - AppSecret / APIv3Key / 商户私钥均来自配置(禁止硬编码)。
|
||||
// - 仅访问微信固定域名(api.weixin.qq.com / api.mch.weixin.qq.com),无 SSRF 风险。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
)
|
||||
|
||||
// code2SessionURL 微信小程序登录凭证校验接口。
|
||||
const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
// code2SessionResp 微信 code2session 响应。
|
||||
type code2SessionResp struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// Code2SessionResult code2session 结果。
|
||||
type Code2SessionResult struct {
|
||||
OpenID string
|
||||
SessionKey string
|
||||
UnionID string
|
||||
}
|
||||
|
||||
// miniHTTPClient 小程序接口 HTTP 客户端(较短超时,微信接口通常很快)。
|
||||
var miniHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// Code2Session 使用小程序登录 code 换取 openid / session_key。
|
||||
//
|
||||
// 依赖配置 global.WechatMiniSetting.AppID / AppSecret。
|
||||
// 常见错误:40029 code 无效、45011 频率限制、40163 code 已使用。
|
||||
func Code2Session(ctx context.Context, code string) (*Code2SessionResult, error) {
|
||||
if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" {
|
||||
return nil, fmt.Errorf("wechat mini not configured")
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("appid", global.WechatMiniSetting.AppID)
|
||||
q.Set("secret", global.WechatMiniSetting.AppSecret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
|
||||
reqURL := code2SessionURL + "?" + q.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session build request: %w", err)
|
||||
}
|
||||
|
||||
response, err := miniHTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session do request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session read response: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("code2session http status %d: %s", response.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var r code2SessionResp
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return nil, fmt.Errorf("code2session decode: %w", err)
|
||||
}
|
||||
if r.ErrCode != 0 {
|
||||
return nil, &Code2SessionError{Code: r.ErrCode, Msg: r.ErrMsg}
|
||||
}
|
||||
if r.OpenID == "" {
|
||||
return nil, fmt.Errorf("code2session empty openid")
|
||||
}
|
||||
|
||||
return &Code2SessionResult{
|
||||
OpenID: r.OpenID,
|
||||
SessionKey: r.SessionKey,
|
||||
UnionID: r.UnionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Code2SessionError 微信 code2session 业务错误,携带 errcode 便于上层区分 code 无效等场景。
|
||||
type Code2SessionError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *Code2SessionError) Error() string {
|
||||
return fmt.Sprintf("code2session errcode=%d, errmsg=%s", e.Code, e.Msg)
|
||||
}
|
||||
|
||||
// IsInvalidCode 判断是否为「code 无效 / 已使用 / 过期」类错误。
|
||||
func (e *Code2SessionError) IsInvalidCode() bool {
|
||||
switch e.Code {
|
||||
case 40029, // js_code 无效
|
||||
40163, // code been used
|
||||
41008: // missing code
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
|
||||
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/utils"
|
||||
)
|
||||
|
||||
// payClient 微信支付客户端,基于官方 SDK(自动平台证书轮换 + 请求签名 + 应答/回调验签)。
|
||||
type payClient struct {
|
||||
appID string
|
||||
mchID string
|
||||
notifyURL string
|
||||
refundNotify string
|
||||
client *core.Client
|
||||
notifyHandler *notify.Handler
|
||||
}
|
||||
|
||||
var (
|
||||
defaultPayClient *payClient
|
||||
payClientMu sync.RWMutex
|
||||
)
|
||||
|
||||
// InitPayClient 初始化全局微信支付客户端。
|
||||
//
|
||||
// 使用官方 SDK 的 WithWechatPayAutoAuthCipher:加载商户私钥后自动注册平台证书下载器
|
||||
// (定期轮换),并具备请求签名、应答自动验签能力;同时构建回调处理器用于回调验签 + 解密。
|
||||
func InitPayClient(s *setting.WechatPaySettingS) error {
|
||||
if s == nil {
|
||||
return errors.New("wechat pay setting is nil")
|
||||
}
|
||||
if s.AppID == "" || s.MchID == "" || s.SerialNo == "" || s.APIv3Key == "" || s.PrivateKeyPath == "" {
|
||||
return errors.New("wechat pay setting incomplete")
|
||||
}
|
||||
if len(s.APIv3Key) != 32 {
|
||||
return fmt.Errorf("wechat pay APIv3Key must be 32 bytes, got %d", len(s.APIv3Key))
|
||||
}
|
||||
|
||||
privateKey, err := utils.LoadPrivateKeyWithPath(s.PrivateKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load merchant private key: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// 创建客户端:内部注册平台证书自动下载器(轮换)、请求签名器与应答验签器。
|
||||
client, err := core.NewClient(
|
||||
ctx,
|
||||
option.WithWechatPayAutoAuthCipher(s.MchID, s.SerialNo, privateKey, s.APIv3Key),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new wechat pay client: %w", err)
|
||||
}
|
||||
|
||||
// 回调处理器:使用平台证书访问器进行回调验签,APIv3Key 进行解密。
|
||||
certVisitor := downloader.MgrInstance().GetCertificateVisitor(s.MchID)
|
||||
notifyHandler, err := notify.NewRSANotifyHandler(
|
||||
s.APIv3Key,
|
||||
verifiers.NewSHA256WithRSAVerifier(certVisitor),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new wechat notify handler: %w", err)
|
||||
}
|
||||
|
||||
c := &payClient{
|
||||
appID: s.AppID,
|
||||
mchID: s.MchID,
|
||||
notifyURL: s.NotifyURL,
|
||||
refundNotify: s.RefundNotifyURL,
|
||||
client: client,
|
||||
notifyHandler: notifyHandler,
|
||||
}
|
||||
|
||||
payClientMu.Lock()
|
||||
defaultPayClient = c
|
||||
payClientMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPayClient 获取已初始化的支付客户端。
|
||||
func getPayClient() (*payClient, error) {
|
||||
payClientMu.RLock()
|
||||
defer payClientMu.RUnlock()
|
||||
if defaultPayClient == nil {
|
||||
return nil, errors.New("wechat pay client not initialized")
|
||||
}
|
||||
return defaultPayClient, nil
|
||||
}
|
||||
|
||||
// MiniPayParams 小程序调起支付所需参数(对应 dto.WechatPayParams)。
|
||||
type MiniPayParams struct {
|
||||
TimeStamp string
|
||||
NonceStr string
|
||||
Package string
|
||||
SignType string
|
||||
PaySign string
|
||||
}
|
||||
|
||||
// JsapiPrepay 调用微信 JSAPI 下单,一步返回小程序调起支付所需的签名参数。
|
||||
// - openid:支付用户在小程序 AppID 下的 openid
|
||||
// - orderNo:商户订单号(out_trade_no)
|
||||
// - description:商品描述
|
||||
// - amount:应付金额(分)
|
||||
func JsapiPrepay(ctx context.Context, openid, orderNo, description string, amount int64) (*MiniPayParams, error) {
|
||||
c, err := getPayClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if openid == "" {
|
||||
return nil, errors.New("empty openid")
|
||||
}
|
||||
|
||||
svc := jsapi.JsapiApiService{Client: c.client}
|
||||
resp, _, err := svc.PrepayWithRequestPayment(ctx, jsapi.PrepayRequest{
|
||||
Appid: core.String(c.appID),
|
||||
Mchid: core.String(c.mchID),
|
||||
Description: core.String(description),
|
||||
OutTradeNo: core.String(orderNo),
|
||||
NotifyUrl: core.String(c.notifyURL),
|
||||
Amount: &jsapi.Amount{
|
||||
Total: core.Int64(amount),
|
||||
Currency: core.String("CNY"),
|
||||
},
|
||||
Payer: &jsapi.Payer{
|
||||
Openid: core.String(openid),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jsapi prepay: %w", err)
|
||||
}
|
||||
|
||||
return &MiniPayParams{
|
||||
TimeStamp: strDeref(resp.TimeStamp),
|
||||
NonceStr: strDeref(resp.NonceStr),
|
||||
Package: strDeref(resp.Package),
|
||||
SignType: strDeref(resp.SignType),
|
||||
PaySign: strDeref(resp.PaySign),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseOrder 关闭微信侧待支付订单,防止本地取消后用户仍完成支付。
|
||||
// - orderNo:商户订单号(out_trade_no)
|
||||
func CloseOrder(ctx context.Context, orderNo string) error {
|
||||
c, err := getPayClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if orderNo == "" {
|
||||
return errors.New("empty order no")
|
||||
}
|
||||
|
||||
svc := jsapi.JsapiApiService{Client: c.client}
|
||||
if _, err := svc.CloseOrder(ctx, jsapi.CloseOrderRequest{
|
||||
OutTradeNo: core.String(orderNo),
|
||||
Mchid: core.String(c.mchID),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("jsapi close order: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefundResult 退款受理结果。
|
||||
type RefundResult struct {
|
||||
RefundID string // 微信退款单号
|
||||
Status string // SUCCESS/CLOSED/PROCESSING/ABNORMAL
|
||||
}
|
||||
|
||||
// Refund 申请退款。
|
||||
// - orderNo:原商户订单号
|
||||
// - refundNo:商户退款单号
|
||||
// - total:原订单总金额(分)
|
||||
// - refund:本次退款金额(分)
|
||||
func Refund(ctx context.Context, orderNo, refundNo string, total, refund int64, reason string) (*RefundResult, error) {
|
||||
c, err := getPayClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req := refunddomestic.CreateRequest{
|
||||
OutTradeNo: core.String(orderNo),
|
||||
OutRefundNo: core.String(refundNo),
|
||||
Amount: &refunddomestic.AmountReq{
|
||||
Refund: core.Int64(refund),
|
||||
Total: core.Int64(total),
|
||||
Currency: core.String("CNY"),
|
||||
},
|
||||
}
|
||||
if c.refundNotify != "" {
|
||||
req.NotifyUrl = core.String(c.refundNotify)
|
||||
}
|
||||
if reason != "" {
|
||||
req.Reason = core.String(reason)
|
||||
}
|
||||
|
||||
svc := refunddomestic.RefundsApiService{Client: c.client}
|
||||
resp, _, err := svc.Create(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("refund create: %w", err)
|
||||
}
|
||||
|
||||
res := &RefundResult{RefundID: strDeref(resp.RefundId)}
|
||||
if resp.Status != nil {
|
||||
res.Status = string(*resp.Status)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ---- 回调验签 + 解密 ----
|
||||
|
||||
// PayNotifyData 支付成功回调解密后的关键字段。
|
||||
type PayNotifyData struct {
|
||||
Raw string // 回调原始报文(验签前捕获,用于审计持久化)
|
||||
OutTradeNo string
|
||||
TransactionID string
|
||||
TradeState string // SUCCESS 表示支付成功
|
||||
Amount struct {
|
||||
Total int64
|
||||
PayerTotal int64
|
||||
Currency string
|
||||
}
|
||||
SuccessTime string
|
||||
}
|
||||
|
||||
// RefundNotifyData 退款回调解密后的关键字段。
|
||||
type RefundNotifyData struct {
|
||||
Raw string // 回调原始报文(验签前捕获,用于审计持久化)
|
||||
OutTradeNo string
|
||||
OutRefundNo string
|
||||
RefundID string
|
||||
RefundStatus string // SUCCESS/CLOSED/ABNORMAL
|
||||
SuccessTime string
|
||||
Amount struct {
|
||||
Total int64
|
||||
Refund int64
|
||||
}
|
||||
}
|
||||
|
||||
// refundNotifyContent 退款回调解密后的原始结构(退款通知使用 refund_status 字段)。
|
||||
type refundNotifyContent struct {
|
||||
OutTradeNo string `json:"out_trade_no"`
|
||||
OutRefundNo string `json:"out_refund_no"`
|
||||
RefundID string `json:"refund_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
RefundStatus string `json:"refund_status"`
|
||||
SuccessTime string `json:"success_time"`
|
||||
Amount struct {
|
||||
Total int64 `json:"total"`
|
||||
Refund int64 `json:"refund"`
|
||||
PayerTotal int64 `json:"payer_total"`
|
||||
PayerRefund int64 `json:"payer_refund"`
|
||||
} `json:"amount"`
|
||||
}
|
||||
|
||||
// VerifyAndDecryptPayNotify 校验并解密支付回调请求,返回订单数据。
|
||||
//
|
||||
// 由官方 SDK 完成:校验签名头 → 平台证书验签 → APIv3Key 解密 → 反序列化,杜绝伪造/篡改回调。
|
||||
func VerifyAndDecryptPayNotify(ctx context.Context, request *http.Request) (*PayNotifyData, error) {
|
||||
c, err := getPayClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。
|
||||
raw, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read pay notify body: %w", err)
|
||||
}
|
||||
request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
|
||||
tx := new(payments.Transaction)
|
||||
if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, tx); err != nil {
|
||||
return nil, fmt.Errorf("parse pay notify: %w", err)
|
||||
}
|
||||
|
||||
d := &PayNotifyData{
|
||||
Raw: string(raw),
|
||||
OutTradeNo: strDeref(tx.OutTradeNo),
|
||||
TransactionID: strDeref(tx.TransactionId),
|
||||
TradeState: strDeref(tx.TradeState),
|
||||
SuccessTime: strDeref(tx.SuccessTime),
|
||||
}
|
||||
if tx.Amount != nil {
|
||||
d.Amount.Total = int64Deref(tx.Amount.Total)
|
||||
d.Amount.PayerTotal = int64Deref(tx.Amount.PayerTotal)
|
||||
d.Amount.Currency = strDeref(tx.Amount.Currency)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// VerifyAndDecryptRefundNotify 校验并解密退款回调请求。
|
||||
func VerifyAndDecryptRefundNotify(ctx context.Context, request *http.Request) (*RefundNotifyData, error) {
|
||||
c, err := getPayClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 验签前捕获原始报文用于审计持久化;ParseNotifyRequest 会消费 request.Body。
|
||||
raw, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read refund notify body: %w", err)
|
||||
}
|
||||
request.Body = io.NopCloser(bytes.NewReader(raw))
|
||||
|
||||
content := new(refundNotifyContent)
|
||||
if _, err := c.notifyHandler.ParseNotifyRequest(ctx, request, content); err != nil {
|
||||
return nil, fmt.Errorf("parse refund notify: %w", err)
|
||||
}
|
||||
|
||||
d := &RefundNotifyData{
|
||||
Raw: string(raw),
|
||||
OutTradeNo: content.OutTradeNo,
|
||||
OutRefundNo: content.OutRefundNo,
|
||||
RefundID: content.RefundID,
|
||||
RefundStatus: content.RefundStatus,
|
||||
SuccessTime: content.SuccessTime,
|
||||
}
|
||||
d.Amount.Total = content.Amount.Total
|
||||
d.Amount.Refund = content.Amount.Refund
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ---- 内部辅助 ----
|
||||
|
||||
func strDeref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
func int64Deref(i *int64) int64 {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return *i
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
// Package wechat 微信小程序虚拟支付(XPay)客户端。
|
||||
//
|
||||
// 与普通微信支付(V3)完全独立:
|
||||
// - 服务器 API 前缀 /xpay/,网关 https://api.weixin.qq.com;
|
||||
// - 签名用 HMAC-SHA256(appKey):pay_sig = hex(hmac_sha256(appKey, uri + "&" + postBody)),
|
||||
// uri 为接口路径(不含 query),postBody 与真实请求体逐字节一致;
|
||||
// - 前端调起 wx.requestVirtualPayment 由服务端生成 signData / paySig / signature,
|
||||
// 其中 paySig 的 uri 固定为 "requestVirtualPayment",signature 用用户 session_key;
|
||||
// - 结果通知走小程序消息推送(Event 通道),非 V3 的 RSA 验签 + AES 解密回调,
|
||||
// 应答格式为 {"ErrCode":0,"ErrMsg":"success"}。
|
||||
//
|
||||
// 安全说明:AppKey / AppSecret 均来自配置(禁止硬编码),仅访问微信固定域名。
|
||||
//
|
||||
// 注意:/xpay/ 各接口的字段以微信官方「虚拟支付」服务端 API 文档为准,
|
||||
// 本文件已按官方文档与社区 SDK 对齐;微信后台未开通前,接口调用需联调时再核对字段。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/setting"
|
||||
)
|
||||
|
||||
// xpay 网关与接口路径。
|
||||
const (
|
||||
xpayBaseURL = "https://api.weixin.qq.com"
|
||||
stableTokenURL = "https://api.weixin.qq.com/cgi-bin/stable_token"
|
||||
|
||||
xpayQueryOrderPath = "/xpay/query_order"
|
||||
xpayRefundOrderPath = "/xpay/refund_order"
|
||||
|
||||
// requestVirtualPaymentURI 前端调起支付时计算 paySig 使用的固定 uri。
|
||||
requestVirtualPaymentURI = "requestVirtualPayment"
|
||||
)
|
||||
|
||||
// 虚拟支付环境。
|
||||
const (
|
||||
XPayEnvProd = 0 // 现网(正式环境)
|
||||
XPayEnvSandbox = 1 // 沙箱(iOS 不支持沙箱,默认不启用)
|
||||
)
|
||||
|
||||
// virtualPayClient 虚拟支付客户端(全局单例)。
|
||||
type virtualPayClient struct {
|
||||
offerID string // 虚拟支付 OfferID(即小程序 AppID)
|
||||
appKey string // 激活环境的 AppKey(按配置自动选择:现网优先,仅填沙箱时用沙箱 key)
|
||||
env int // 激活环境 0=现网 1=沙箱
|
||||
token string // 消息推送验签 Token(配置回调地址时自定义)
|
||||
|
||||
appID string // 获取 access_token 用的小程序 AppID
|
||||
appSecret string // 小程序 AppSecret
|
||||
|
||||
httpClient *http.Client
|
||||
|
||||
accessMu sync.Mutex
|
||||
accessToken string
|
||||
accessExp time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
defaultVirtualPayClient *virtualPayClient
|
||||
virtualPayMu sync.RWMutex
|
||||
)
|
||||
|
||||
// resolveEnvKey 按配置决定激活环境与 AppKey:
|
||||
// - EnvAppKey 非空 -> 现网(env=0),两把都填时现网优先;
|
||||
// - EnvAppKey 空且 SandboxAppKey 非空 -> 沙箱(env=1);
|
||||
// - 两把都空 -> 返回错误。
|
||||
func resolveEnvKey(envAppKey, sandboxAppKey string) (appKey string, env int, err error) {
|
||||
if envAppKey != "" {
|
||||
return envAppKey, XPayEnvProd, nil
|
||||
}
|
||||
if sandboxAppKey != "" {
|
||||
return sandboxAppKey, XPayEnvSandbox, nil
|
||||
}
|
||||
return "", 0, errors.New("virtual pay app key not configured: both env_app_key and sandbox_app_key are empty")
|
||||
}
|
||||
|
||||
// InitVirtualPayClient 初始化全局虚拟支付客户端。
|
||||
// 复用 global.WechatMiniSetting 的 AppID/AppSecret 获取 access_token(同一小程序)。
|
||||
// 环境选择:只填沙箱 AppKey 走沙箱(env=1,测试不真实扣款);只填现网或两把都填走现网(env=0)。
|
||||
func InitVirtualPayClient(s *setting.VirtualPaySettingS) error {
|
||||
if s == nil || !s.Enable {
|
||||
return errors.New("virtual pay setting is nil or disabled")
|
||||
}
|
||||
if s.OfferID == "" {
|
||||
return errors.New("virtual pay setting incomplete: offer_id is required")
|
||||
}
|
||||
appKey, env, err := resolveEnvKey(s.EnvAppKey, s.SandboxAppKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" {
|
||||
return errors.New("wechat mini setting incomplete, cannot fetch virtual pay access_token")
|
||||
}
|
||||
|
||||
c := &virtualPayClient{
|
||||
offerID: s.OfferID,
|
||||
appKey: appKey,
|
||||
env: env,
|
||||
token: s.Token,
|
||||
appID: global.WechatMiniSetting.AppID,
|
||||
appSecret: global.WechatMiniSetting.AppSecret,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
virtualPayMu.Lock()
|
||||
defaultVirtualPayClient = c
|
||||
virtualPayMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetVirtualPayEnv 返回当前激活的虚拟支付环境(0=现网 1=沙箱)。
|
||||
func GetVirtualPayEnv() (int, error) {
|
||||
c, err := getVirtualPayClient()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return c.env, nil
|
||||
}
|
||||
|
||||
// getVirtualPayClient 获取已初始化的虚拟支付客户端。
|
||||
func getVirtualPayClient() (*virtualPayClient, error) {
|
||||
virtualPayMu.RLock()
|
||||
defer virtualPayMu.RUnlock()
|
||||
if defaultVirtualPayClient == nil {
|
||||
return nil, errors.New("virtual pay client not initialized")
|
||||
}
|
||||
return defaultVirtualPayClient, nil
|
||||
}
|
||||
|
||||
// IsVirtualPayEnabled 虚拟支付是否已初始化(供上层判断是否可用)。
|
||||
func IsVirtualPayEnabled() bool {
|
||||
virtualPayMu.RLock()
|
||||
defer virtualPayMu.RUnlock()
|
||||
return defaultVirtualPayClient != nil
|
||||
}
|
||||
|
||||
// ---- 签名与工具 ----
|
||||
|
||||
// hmacSHA256Hex 计算 HMAC-SHA256 并返回小写 hex。
|
||||
func hmacSHA256Hex(key, data string) string {
|
||||
h := hmac.New(sha256.New, []byte(key))
|
||||
_, _ = h.Write([]byte(data))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// xpayAPIError xpay 接口业务错误。
|
||||
type xpayAPIError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *xpayAPIError) Error() string {
|
||||
return fmt.Sprintf("xpay errcode=%d, errmsg=%s", e.Code, e.Msg)
|
||||
}
|
||||
|
||||
// stableTokenResp stable_token 响应。
|
||||
type stableTokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// getAccessToken 获取并缓存 access_token(提前 300 秒刷新)。
|
||||
func (c *virtualPayClient) getAccessToken(ctx context.Context) (string, error) {
|
||||
c.accessMu.Lock()
|
||||
defer c.accessMu.Unlock()
|
||||
|
||||
if c.accessToken != "" && time.Now().Before(c.accessExp.Add(-300*time.Second)) {
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"grant_type": "client_credential",
|
||||
"appid": c.appID,
|
||||
"secret": c.appSecret,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, stableTokenURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stable_token build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stable_token do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stable_token read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("stable_token http status %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var r stableTokenResp
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return "", fmt.Errorf("stable_token decode: %w", err)
|
||||
}
|
||||
if r.ErrCode != 0 || r.AccessToken == "" {
|
||||
return "", &xpayAPIError{Code: r.ErrCode, Msg: r.ErrMsg}
|
||||
}
|
||||
|
||||
c.accessToken = r.AccessToken
|
||||
expire := r.ExpiresIn
|
||||
if expire <= 0 {
|
||||
expire = 7200
|
||||
}
|
||||
c.accessExp = time.Now().Add(time.Duration(expire) * time.Second)
|
||||
return c.accessToken, nil
|
||||
}
|
||||
|
||||
// callXPay 向 /xpay/ 接口发起 POST 请求并解码响应。
|
||||
// 签名使用激活环境的 AppKey(现网或沙箱,按配置自动选择);body 为请求体结构,
|
||||
// out 为响应结构(可为 nil 表示忽略响应体)。
|
||||
func (c *virtualPayClient) callXPay(ctx context.Context, uri string, body any, out any) error {
|
||||
bodyBytes, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpay marshal body: %w", err)
|
||||
}
|
||||
|
||||
token, err := c.getAccessToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// pay_sig = hex(hmac_sha256(appKey, uri + "&" + body)),body 与真实请求体逐字节一致。
|
||||
paySig := hmacSHA256Hex(c.appKey, uri+"&"+string(bodyBytes))
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("access_token", token)
|
||||
q.Set("pay_sig", paySig)
|
||||
|
||||
reqURL := xpayBaseURL + uri + "?" + q.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpay build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpay do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("xpay read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("xpay http status %d: %s", resp.StatusCode, string(data))
|
||||
}
|
||||
|
||||
// 先解一层通用错误码(若存在),避免把业务错误误判为成功。
|
||||
var errChk struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &errChk); err == nil && errChk.ErrCode != 0 {
|
||||
return &xpayAPIError{Code: errChk.ErrCode, Msg: errChk.ErrMsg}
|
||||
}
|
||||
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return fmt.Errorf("xpay decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 订单查询 / 退款 ----
|
||||
|
||||
// XPayOrderInfo 虚拟支付订单查询结果。
|
||||
type XPayOrderInfo struct {
|
||||
OrderID string `json:"order_id"` // 商户订单号
|
||||
WxOrderID string `json:"wx_order_id"` // 微信订单号
|
||||
OpenID string `json:"openid"` // 用户 openid
|
||||
ProductID string `json:"product_id"` // 道具 ID
|
||||
Quantity int64 `json:"quantity"` // 购买数量
|
||||
PayFee int64 `json:"pay_fee"` // 实付金额(分)
|
||||
Status string `json:"status"` // 订单状态(以官方枚举为准)
|
||||
RefundStatus string `json:"refund_status"` // 退款状态(以官方枚举为准,待确认字段名)
|
||||
RefundFee int64 `json:"refund_fee"` // 退款金额(分,待确认字段名)
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// 虚拟支付订单支付状态(query_order 返回的 status 字段,以微信官方枚举为准,联调时校正)。
|
||||
const (
|
||||
XPayOrderStatusPaid = "PAID" // 已支付
|
||||
)
|
||||
|
||||
// IsOrderPaidStatus 判断 query_order 返回的订单状态是否为「已支付」。
|
||||
// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未支付,
|
||||
// 供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。
|
||||
func IsOrderPaidStatus(status string) bool {
|
||||
return status == XPayOrderStatusPaid
|
||||
}
|
||||
|
||||
// 虚拟支付订单退款状态(query_order 返回的 refund_status 字段,以微信官方枚举为准,联调时校正)。
|
||||
const (
|
||||
XPayOrderRefundedStatus = "REFUNDED" // 已退款
|
||||
)
|
||||
|
||||
// IsOrderRefundedStatus 判断 query_order 返回的订单退款状态是否为「已退款」。
|
||||
// 白名单判断:仅命中已知成功枚举返回 true;未知状态/空串一律视为未退款,
|
||||
// 供退款链路 fail-closed 使用(判定为未退款的推送会被拒绝并交由微信重试)。
|
||||
func IsOrderRefundedStatus(status string) bool {
|
||||
return status == XPayOrderRefundedStatus
|
||||
}
|
||||
|
||||
// 虚拟支付退款推送的退款状态(RefundStatus 字段,以微信官方枚举为准,联调时校正)。
|
||||
const (
|
||||
XPayRefundStatusSuccess = "SUCCESS" // 退款成功
|
||||
)
|
||||
|
||||
// QueryOrder 查询虚拟支付订单(orderID 与 wxOrderID 二选一,非空者优先;作用于激活环境)。
|
||||
func QueryOrder(ctx context.Context, openid, orderID, wxOrderID string) (*XPayOrderInfo, error) {
|
||||
c, err := getVirtualPayClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := map[string]any{"openid": openid, "env": c.env}
|
||||
if orderID != "" {
|
||||
body["order_id"] = orderID
|
||||
} else {
|
||||
body["wx_order_id"] = wxOrderID
|
||||
}
|
||||
out := &XPayOrderInfo{}
|
||||
if err := c.callXPay(ctx, xpayQueryOrderPath, body, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RefundOrder 启动虚拟支付退款任务(仅启动任务,结果需轮询 QueryOrder;作用于激活环境)。
|
||||
// - leftFee:剩余金额(分),即退款前的订单实付金额;
|
||||
// - refundFee:本次退款金额(分)。
|
||||
func RefundOrder(ctx context.Context, openid, orderID, refundOrderID string, leftFee, refundFee int64, reason string) error {
|
||||
c, err := getVirtualPayClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := map[string]any{
|
||||
"openid": openid,
|
||||
"env": c.env,
|
||||
"order_id": orderID,
|
||||
"refund_order_id": refundOrderID,
|
||||
"left_fee": leftFee,
|
||||
"refund_fee": refundFee,
|
||||
"refund_reason": reason,
|
||||
}
|
||||
return c.callXPay(ctx, xpayRefundOrderPath, body, nil)
|
||||
}
|
||||
|
||||
// ---- 前端调起签名 ----
|
||||
|
||||
// VirtualPaySign 返回给前端调起 wx.requestVirtualPayment 的签名数据。
|
||||
type VirtualPaySign struct {
|
||||
SignData string `json:"sign_data"` // 参与签名的 JSON 字符串(原样传给前端,勿重新序列化)
|
||||
PaySig string `json:"pay_sig"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// BuildPaymentSign 生成前端调起虚拟支付的 signData / paySig / signature。
|
||||
//
|
||||
// signData 字段按字典序排列(微信侧按字典序重组验签,顺序敏感),offerId 必须为字符串;
|
||||
// env 与签名 key 使用客户端激活环境(现网或沙箱)。
|
||||
// - paySig = hex(hmac_sha256(appKey, "requestVirtualPayment" + "&" + signData))
|
||||
// - signature = hex(hmac_sha256(sessionKey, signData))
|
||||
func BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey string, goodsPrice int64) (*VirtualPaySign, error) {
|
||||
if offerID == "" || productID == "" || outTradeNo == "" {
|
||||
return nil, errors.New("virtual pay sign: offer_id, product_id, out_trade_no are required")
|
||||
}
|
||||
c, err := getVirtualPayClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := map[string]any{
|
||||
"offerId": offerID,
|
||||
"buyQuantity": 1,
|
||||
"currencyType": "CNY",
|
||||
"env": c.env,
|
||||
"goodsPrice": goodsPrice,
|
||||
"productId": productID,
|
||||
"outTradeNo": outTradeNo,
|
||||
}
|
||||
if attach != "" {
|
||||
fields["attach"] = attach
|
||||
}
|
||||
|
||||
// 按字典序排序字段名后序列化,保证与微信侧重组结果一致。
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var b bytes.Buffer
|
||||
b.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
kb, _ := json.Marshal(k)
|
||||
vb, _ := json.Marshal(fields[k])
|
||||
b.Write(kb)
|
||||
b.WriteByte(':')
|
||||
b.Write(vb)
|
||||
}
|
||||
b.WriteByte('}')
|
||||
signData := b.String()
|
||||
|
||||
paySig := hmacSHA256Hex(c.appKey, requestVirtualPaymentURI+"&"+signData)
|
||||
signature := hmacSHA256Hex(sessionKey, signData)
|
||||
|
||||
return &VirtualPaySign{SignData: signData, PaySig: paySig, Signature: signature}, nil
|
||||
}
|
||||
|
||||
// ---- 消息推送 ----
|
||||
|
||||
// 虚拟支付消息推送 Event 类型。
|
||||
const (
|
||||
EventGoodsDeliverNotify = "xpay_goods_deliver_notify" // 道具发货推送
|
||||
EventCoinPayNotify = "xpay_coin_pay_notify" // 代币支付推送
|
||||
EventRefundNotify = "xpay_refund_notify" // 退款推送
|
||||
EventComplaintNotify = "xpay_complaint_notify" // 用户投诉推送
|
||||
EventWxpayCallbackNotify = "xpay_wxpay_callback_notify" // 微信支付风控事件
|
||||
EventIOSRefundQueryNotify = "xpay_subscribe_ios_refund_query_notify" // iOS 退款问询推送
|
||||
)
|
||||
|
||||
// XPayNotifyBase 消息推送公共字段。
|
||||
type XPayNotifyBase struct {
|
||||
ToUserName string `json:"ToUserName"`
|
||||
FromUserName string `json:"FromUserName"`
|
||||
CreateTime int64 `json:"CreateTime"`
|
||||
MsgType string `json:"MsgType"`
|
||||
Event string `json:"Event"`
|
||||
OpenID string `json:"OpenId"`
|
||||
OutTradeNo string `json:"OutTradeNo"`
|
||||
Env int `json:"Env"`
|
||||
}
|
||||
|
||||
// XPayGoodsDeliverNotify 道具发货推送。
|
||||
type XPayGoodsDeliverNotify struct {
|
||||
XPayNotifyBase
|
||||
WeChatPayInfo struct {
|
||||
MchOrderNo string `json:"MchOrderNo"` // 微信支付商户单号
|
||||
TransactionID string `json:"TransactionId"` // 微信支付交易单号
|
||||
PaidTime int64 `json:"PaidTime"` // 支付时间(Linux 秒级时间戳)
|
||||
} `json:"WeChatPayInfo"`
|
||||
GoodsInfo struct {
|
||||
ProductID string `json:"ProductId"` // 道具 ID
|
||||
Quantity int64 `json:"Quantity"` // 购买数量
|
||||
OrigPrice int64 `json:"OrigPrice"` // 原始价格(分)
|
||||
ActualPrice int64 `json:"ActualPrice"` // 实际支付价格(分)
|
||||
Attach string `json:"Attach"` // 透传信息
|
||||
} `json:"GoodsInfo"`
|
||||
}
|
||||
|
||||
// XPayRefundNotify 退款推送。
|
||||
type XPayRefundNotify struct {
|
||||
XPayNotifyBase
|
||||
RefundStatus string `json:"RefundStatus"` // 退款状态(以官方枚举为准)
|
||||
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
|
||||
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号
|
||||
}
|
||||
|
||||
// XPayIOSRefundQueryNotify iOS 退款问询推送。
|
||||
type XPayIOSRefundQueryNotify struct {
|
||||
XPayNotifyBase
|
||||
// 字段以官方文档为准;此处解析退款问询所需的最小信息。
|
||||
RefundOrderID string `json:"RefundOrderId"`
|
||||
RefundFee int64 `json:"RefundFee"`
|
||||
}
|
||||
|
||||
// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。
|
||||
// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。
|
||||
func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, errors.New("empty xpay notify body")
|
||||
}
|
||||
base := &XPayNotifyBase{}
|
||||
if err := json.Unmarshal(raw, base); err != nil {
|
||||
return nil, fmt.Errorf("parse xpay notify: %w", err)
|
||||
}
|
||||
if base.Event == "" {
|
||||
return nil, errors.New("xpay notify missing event")
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// BuildNotifyResponse 构造消息推送应答(成功/失败)。
|
||||
func BuildNotifyResponse(success bool) []byte {
|
||||
if success {
|
||||
return []byte(`{"ErrCode":0,"ErrMsg":"success"}`)
|
||||
}
|
||||
return []byte(`{"ErrCode":1,"ErrMsg":"处理失败"}`)
|
||||
}
|
||||
|
||||
// BuildIOSRefundQueryResponse 构造 iOS 退款问询应答(3 秒内必须返回)。
|
||||
// resultCode: 0 建议退款、1 拒绝退款;evidence 为决策凭据。
|
||||
// 字段名以官方文档为准(result_code / evidence)。
|
||||
// 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。
|
||||
func BuildIOSRefundQueryResponse(resultCode int, evidence string) []byte {
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"result_code": resultCode,
|
||||
"evidence": evidence,
|
||||
})
|
||||
return b
|
||||
}
|
||||
|
||||
// CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。
|
||||
// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。
|
||||
func CheckURLSignature(token, timestamp, nonce, signature string) bool {
|
||||
if token == "" || signature == "" {
|
||||
return false
|
||||
}
|
||||
arr := []string{token, timestamp, nonce}
|
||||
sort.Strings(arr)
|
||||
joined := arr[0] + arr[1] + arr[2]
|
||||
h := sha1.Sum([]byte(joined))
|
||||
return hex.EncodeToString(h[:]) == signature
|
||||
}
|
||||
|
||||
// VerifyNotifyURL 校验消息推送 URL 配置时的 GET 验签(使用已初始化的 Token)。
|
||||
func VerifyNotifyURL(timestamp, nonce, signature string) bool {
|
||||
c, err := getVirtualPayClient()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return CheckURLSignature(c.token, timestamp, nonce, signature)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestHMACSHA256Hex 用 RFC 4231 测试向量校验 HMAC-SHA256 实现。
|
||||
func TestHMACSHA256Hex(t *testing.T) {
|
||||
key := strings.Repeat("\x0b", 20) // 0x0b * 20
|
||||
data := "Hi There"
|
||||
want := "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
|
||||
if got := hmacSHA256Hex(key, data); got != want {
|
||||
t.Fatalf("hmacSHA256Hex mismatch: got %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPaymentSign 校验 signData 字典序、paySig 与 signature 的内部一致性。
|
||||
func TestBuildPaymentSign(t *testing.T) {
|
||||
const (
|
||||
appKey = "test-app-key"
|
||||
sessionKey = "test-session-key"
|
||||
offerID = "wx1234567890"
|
||||
productID = "monthly_vip"
|
||||
outTradeNo = "OD20260908120000abcdef"
|
||||
attach = "uid=42"
|
||||
)
|
||||
|
||||
virtualPayMu.Lock()
|
||||
defaultVirtualPayClient = &virtualPayClient{appKey: appKey, env: XPayEnvProd}
|
||||
virtualPayMu.Unlock()
|
||||
defer func() {
|
||||
virtualPayMu.Lock()
|
||||
defaultVirtualPayClient = nil
|
||||
virtualPayMu.Unlock()
|
||||
}()
|
||||
|
||||
sign, err := BuildPaymentSign(offerID, productID, outTradeNo, attach, sessionKey, 1900)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPaymentSign error: %v", err)
|
||||
}
|
||||
|
||||
// signData 字段必须按字典序排列(微信侧按字典序重组验签)。
|
||||
wantOrder := []string{"attach", "buyQuantity", "currencyType", "env", "goodsPrice", "offerId", "outTradeNo", "productId"}
|
||||
last := -1
|
||||
for _, key := range wantOrder {
|
||||
idx := strings.Index(sign.SignData, `"`+key+`"`)
|
||||
if idx < 0 {
|
||||
t.Fatalf("signData missing key %q: %s", key, sign.SignData)
|
||||
}
|
||||
if idx < last {
|
||||
t.Fatalf("signData key order wrong at %q: %s", key, sign.SignData)
|
||||
}
|
||||
last = idx
|
||||
}
|
||||
|
||||
// signData 必须是合法 JSON,且 offerId 为字符串。
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(sign.SignData), &m); err != nil {
|
||||
t.Fatalf("signData is not valid JSON: %v", err)
|
||||
}
|
||||
if _, ok := m["offerId"].(string); !ok {
|
||||
t.Fatalf("offerId must be string, got %T", m["offerId"])
|
||||
}
|
||||
if v, ok := m["goodsPrice"].(float64); !ok || v != 1900 {
|
||||
t.Fatalf("goodsPrice mismatch: %v", m["goodsPrice"])
|
||||
}
|
||||
|
||||
// paySig = hmac(appKey, "requestVirtualPayment&"+signData);signature = hmac(sessionKey, signData)。
|
||||
wantPaySig := hmacSHA256Hex(appKey, requestVirtualPaymentURI+"&"+sign.SignData)
|
||||
wantSignature := hmacSHA256Hex(sessionKey, sign.SignData)
|
||||
if sign.PaySig != wantPaySig {
|
||||
t.Fatalf("paySig mismatch: got %s, want %s", sign.PaySig, wantPaySig)
|
||||
}
|
||||
if sign.Signature != wantSignature {
|
||||
t.Fatalf("signature mismatch: got %s, want %s", sign.Signature, wantSignature)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckURLSignature 校验消息推送 URL 验签(SHA1 字典序拼接)。
|
||||
func TestCheckURLSignature(t *testing.T) {
|
||||
token := "my-token"
|
||||
timestamp := "1756000000"
|
||||
nonce := "abc123"
|
||||
|
||||
// 独立计算期望值:["1756000000","abc123","my-token"] 字典序拼接后做 SHA1。
|
||||
arr := []string{token, timestamp, nonce}
|
||||
sort.Strings(arr)
|
||||
sum := sha1.Sum([]byte(arr[0] + arr[1] + arr[2]))
|
||||
wantSig := hex.EncodeToString(sum[:])
|
||||
|
||||
if !CheckURLSignature(token, timestamp, nonce, wantSig) {
|
||||
t.Fatal("CheckURLSignature should pass with correct signature")
|
||||
}
|
||||
if CheckURLSignature(token, timestamp, nonce, "bad") {
|
||||
t.Fatal("CheckURLSignature should fail with wrong signature")
|
||||
}
|
||||
if CheckURLSignature("", timestamp, nonce, wantSig) {
|
||||
t.Fatal("CheckURLSignature should fail with empty token")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNotifyAndResponse 校验发货推送解析与应答构造。
|
||||
func TestParseNotifyAndResponse(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"ToUserName":"gh_test",
|
||||
"FromUserName":"official_openid",
|
||||
"CreateTime":1756000000,
|
||||
"MsgType":"event",
|
||||
"Event":"xpay_goods_deliver_notify",
|
||||
"OpenId":"o_user",
|
||||
"OutTradeNo":"OD20260908120000abcdef",
|
||||
"Env":0,
|
||||
"WeChatPayInfo":{"MchOrderNo":"mch_123","TransactionId":"wx_123","PaidTime":1756000000},
|
||||
"GoodsInfo":{"ProductId":"monthly_vip","Quantity":1,"OrigPrice":1900,"ActualPrice":1900,"Attach":"uid=42"}
|
||||
}`)
|
||||
|
||||
base, err := ParseNotify(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseNotify error: %v", err)
|
||||
}
|
||||
if base.Event != EventGoodsDeliverNotify {
|
||||
t.Fatalf("unexpected event: %s", base.Event)
|
||||
}
|
||||
if base.OutTradeNo != "OD20260908120000abcdef" {
|
||||
t.Fatalf("unexpected out_trade_no: %s", base.OutTradeNo)
|
||||
}
|
||||
|
||||
var deliver XPayGoodsDeliverNotify
|
||||
if err := json.Unmarshal(raw, &deliver); err != nil {
|
||||
t.Fatalf("unmarshal deliver notify: %v", err)
|
||||
}
|
||||
if deliver.GoodsInfo.ProductID != "monthly_vip" || deliver.GoodsInfo.ActualPrice != 1900 {
|
||||
t.Fatalf("deliver goods info mismatch: %+v", deliver.GoodsInfo)
|
||||
}
|
||||
|
||||
okBody := BuildNotifyResponse(true)
|
||||
if !strings.Contains(string(okBody), `"ErrCode":0`) {
|
||||
t.Fatalf("success response missing ErrCode 0: %s", okBody)
|
||||
}
|
||||
failBody := BuildNotifyResponse(false)
|
||||
if !strings.Contains(string(failBody), `"ErrCode":1`) {
|
||||
t.Fatalf("fail response missing ErrCode 1: %s", failBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsOrderPaidStatus 校验订单支付状态白名单判断:仅已知成功枚举为已支付,未知/空串按未支付。
|
||||
func TestIsOrderPaidStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
status string
|
||||
want bool
|
||||
}{
|
||||
{XPayOrderStatusPaid, true},
|
||||
{"NOTPAY", false},
|
||||
{"CLOSED", false},
|
||||
{"UNKNOWN", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsOrderPaidStatus(c.status); got != c.want {
|
||||
t.Fatalf("IsOrderPaidStatus(%q) = %v, want %v", c.status, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveEnvKey 校验环境自动选择规则:只填现网/只填沙箱/两把都填/两把都空。
|
||||
func TestResolveEnvKey(t *testing.T) {
|
||||
// 只填现网 -> 现网
|
||||
key, env, err := resolveEnvKey("prod-key", "")
|
||||
if err != nil || key != "prod-key" || env != XPayEnvProd {
|
||||
t.Fatalf("only env key: key=%q env=%d err=%v", key, env, err)
|
||||
}
|
||||
// 只填沙箱 -> 沙箱
|
||||
key, env, err = resolveEnvKey("", "sandbox-key")
|
||||
if err != nil || key != "sandbox-key" || env != XPayEnvSandbox {
|
||||
t.Fatalf("only sandbox key: key=%q env=%d err=%v", key, env, err)
|
||||
}
|
||||
// 两把都填 -> 现网优先
|
||||
key, env, err = resolveEnvKey("prod-key", "sandbox-key")
|
||||
if err != nil || key != "prod-key" || env != XPayEnvProd {
|
||||
t.Fatalf("both keys: key=%q env=%d err=%v", key, env, err)
|
||||
}
|
||||
// 两把都空 -> 报错
|
||||
if _, _, err := resolveEnvKey("", ""); err == nil {
|
||||
t.Fatal("empty keys should return error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user