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