Files
zt-closet-backend/pkg/wechat/virtualpay.go
T
2026-09-11 01:41:52 +08:00

554 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}