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

586 lines
21 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
}
// ---- 订单查询 / 退款 ----
// XPayOrder query_order 响应中的 order 子对象(现金单)。
// 注意两点官方约定(易错):订单信息包在 order 里(不是平铺在顶层);status 为数字枚举。
type XPayOrder struct {
OrderID string `json:"order_id"` // 订单号(即下单时的 outTradeNo)
WxOrderID string `json:"wx_order_id"` // 微信内部单号
Status int `json:"status"` // 当前状态,见 XPayOrderStatus* 常量
BizType int `json:"biz_type"` // 业务类型 0=短剧
OrderFee int64 `json:"order_fee"` // 订单金额(分)
PaidFee int64 `json:"paid_fee"` // 用户支付金额(分)
OrderType int `json:"order_type"` // 订单类型,见 XPayOrderType* 常量
RefundFee int64 `json:"refund_fee"` // 退款单类型时的退款金额(分)
LeftFee int64 `json:"left_fee"` // 支付单退款后剩余金额(分)
PaidTime int64 `json:"paid_time"` // 支付/退款时间(秒)
ProvideTime int64 `json:"provide_time"` // 发货时间(秒)
BizMeta string `json:"biz_meta"` // 订单创建时透传的信息(下单时的 attach)
EnvType int `json:"env_type"` // 环境类型 1=现网 2=沙箱
ChannelOrderID string `json:"channel_order_id"` // 渠道单号(用户支付详情页的商户单号)
WxpayOrderID string `json:"wxpay_order_id"` // 微信支付交易单号
SettTime int64 `json:"sett_time"` // 结算时间(秒,>0 表示结算成功)
SettState int `json:"sett_state"` // 结算状态 0/3=未开始或待结算 1=结算中 2=结算成功
}
// XPayOrderInfo query_order 响应体。
type XPayOrderInfo struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Order XPayOrder `json:"order"`
}
// 虚拟支付订单状态(query_order 返回的 order.status,数字枚举)。
const (
XPayOrderStatusInit = 0 // 订单初始化(未创建成功,不可支付)
XPayOrderStatusCreated = 1 // 订单创建成功(未支付)
XPayOrderStatusPaid = 2 // 订单已支付,待发货
XPayOrderStatusProviding = 3 // 订单发货中
XPayOrderStatusProvided = 4 // 订单已发货
XPayOrderStatusRefunded = 5 // 订单已退款
XPayOrderStatusClosed = 6 // 订单已关闭
XPayOrderStatusRefundFailed = 7 // 订单退款失败
XPayOrderStatusUserRefunded = 8 // 用户退款完成
XPayOrderStatusAdRecovered = 9 // 回收广告金完成
XPayOrderStatusSettleBack = 10 // 分账回退完成
)
// 虚拟支付订单类型(query_order 返回的 order.order_type)。
const (
XPayOrderTypeNormal = 0 // 普通虚拟支付
XPayOrderTypeRefund = 1 // 普通退款
XPayOrderTypeIOSPay = 7 // 苹果 iOS 支付
XPayOrderTypeIOSRefund = 8 // 苹果 iOS 退款
)
// IsOrderPaidStatus 判断微信侧订单是否已支付(2 已支付待发货 / 3 发货中 / 4 已发货)。
// 白名单判断:仅命中「已支付及之后」的枚举返回 true,其余(未支付/已退款/已关闭/未知)
// 一律视为未支付,供发货链路 fail-closed 使用(判定为未支付的推送会被拒绝并交由微信重试)。
// 3/4 纳入白名单是为了让「已发货后的重试推送」仍能通过反查(幂等补发货场景)。
func IsOrderPaidStatus(status int) bool {
switch status {
case XPayOrderStatusPaid, XPayOrderStatusProviding, XPayOrderStatusProvided:
return true
default:
return false
}
}
// IsOrderRefundedStatus 判断微信侧订单是否已退款(5 订单已退款 / 8 用户退款完成)。
// 白名单判断,fail-closed,供退款链路使用(判定为未退款的推送会被拒绝并交由微信重试)。
func IsOrderRefundedStatus(status int) bool {
return status == XPayOrderStatusRefunded || status == XPayOrderStatusUserRefunded
}
// 虚拟支付退款推送的退款状态(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)
}