first commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user