first commit

This commit is contained in:
2026-09-11 01:41:52 +08:00
commit 9f22e6ccae
263 changed files with 28935 additions and 0 deletions
+114
View File
@@ -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
}
+351
View File
@@ -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
}
+553
View File
@@ -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)
}
+191
View File
@@ -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")
}
}