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