- 订单状态新增「6 申请退款中」,用户申请退款原子流转并落待审核退款流水 - 退款失败回退订单至已支付并恢复被取消的权益发放任务 - 新增虚拟支付退款查单兜底任务,反查超时订单并自动订正 - 会员包来源额度不叠加,扣减/回退/回收口径统一为只取生效一份 - 退款回收流水按吊销前后有效剩余差值记账,避免无效回收记录 - 同步更新 README 与 Stitch 设计文档
719 lines
28 KiB
Go
719 lines
28 KiB
Go
// 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/base64"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strings"
|
||
"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"
|
||
xpayNotifyProvideGoodsPath = "/xpay/notify_provide_goods"
|
||
|
||
// 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(配置回调地址时自定义)
|
||
|
||
// 消息推送加解密:encodingAESKey 为空表示明文模式;aesKey 为预解码的 32 字节密钥。
|
||
encodingAESKey string
|
||
aesKey []byte
|
||
|
||
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")
|
||
}
|
||
|
||
// decodeEncodingAESKey 解码消息加解密密钥:尾部补 "=" 后 base64 解码,必须得到 32 字节。
|
||
// 空字符串表示未启用安全模式(明文模式),返回 nil 且不报错。
|
||
func decodeEncodingAESKey(encodingAESKey string) ([]byte, error) {
|
||
if encodingAESKey == "" {
|
||
return nil, nil
|
||
}
|
||
if len(encodingAESKey) != wxEncodingAESKeyLen {
|
||
return nil, fmt.Errorf("virtual pay encoding_aes_key must be %d chars, got %d",
|
||
wxEncodingAESKeyLen, len(encodingAESKey))
|
||
}
|
||
key, err := base64.StdEncoding.DecodeString(encodingAESKey + "=")
|
||
if err != nil {
|
||
return nil, fmt.Errorf("virtual pay decode encoding_aes_key: %w", err)
|
||
}
|
||
if len(key) != 32 {
|
||
return nil, fmt.Errorf("virtual pay encoding_aes_key must decode to 32 bytes, got %d", len(key))
|
||
}
|
||
return key, nil
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
|
||
aesKey, aerr := decodeEncodingAESKey(s.EncodingAESKey)
|
||
if aerr != nil {
|
||
return aerr
|
||
}
|
||
|
||
c := &virtualPayClient{
|
||
offerID: s.OfferID,
|
||
appKey: appKey,
|
||
env: env,
|
||
token: s.Token,
|
||
encodingAESKey: s.EncodingAESKey,
|
||
aesKey: aesKey,
|
||
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
|
||
}
|
||
|
||
// 虚拟支付退款推送(xpay_refund_notify)的退款结果字段:官方以 RetCode 判定,
|
||
// RetCode=0 表示退款成功,非 0 表示失败(失败原因见 RetMsg)。
|
||
const XPayRefundRetCodeSuccess = 0
|
||
|
||
// XPayRefundStatusSuccess 旧版式退款推送的 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
|
||
}
|
||
|
||
// NotifyProvideGoods 通知微信「已发货完成」(只能通知现金单,作用于激活环境)。
|
||
//
|
||
// 对应道具直购流程的 9.3:仅「发货轮询/查单补偿」分支需要调用——该分支没有推送应答的机会,
|
||
// 必须主动把微信侧订单置为已发货。走 xpay_goods_deliver_notify 推送且应答成功(ErrCode:0)
|
||
// 时,微信已视同发货完成,无需再调用本接口。
|
||
// 幂等:微信侧按订单号置状态,重复调用无副作用。
|
||
func NotifyProvideGoods(ctx context.Context, orderID string) error {
|
||
if orderID == "" {
|
||
return errors.New("notify provide goods: order_id is required")
|
||
}
|
||
c, err := getVirtualPayClient()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
body := map[string]any{"order_id": orderID, "env": c.env}
|
||
return c.callXPay(ctx, xpayNotifyProvideGoodsPath, body, 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 退款推送(xpay_refund_notify)。
|
||
//
|
||
// 字段以官方《虚拟支付:企业、个体户 → 消息推送 → 退款推送》为准:
|
||
// 官方报文中**没有** OutTradeNo / RefundStatus / Env,订单号取 MchOrderId(原支付单商户单号,
|
||
// 即本地 orders.order_no),退款结果取 RetCode(0=成功,非 0=失败,原因见 RetMsg)。
|
||
// 同时保留 RefundOrderId / RefundStatus 等旧版式字段与非官方版式的 OutTradeNo,避免版式差异导致静默失败。
|
||
type XPayRefundNotify struct {
|
||
XPayNotifyBase
|
||
WxRefundID string `json:"WxRefundId"` // 微信退款单号
|
||
MchRefundID string `json:"MchRefundId"` // 商户退款单号
|
||
WxOrderID string `json:"WxOrderId"` // 退款单对应支付单的微信单号
|
||
MchOrderID string `json:"MchOrderId"` // 退款单对应支付单的商户单号(= 本地订单号)
|
||
RefundFee int64 `json:"RefundFee"` // 退款金额(分)
|
||
RetCode *int `json:"RetCode"` // 退款结果:0=成功,非 0=失败;nil 表示报文未携带
|
||
RetMsg string `json:"RetMsg"` // 退款结果详情(失败原因)
|
||
RefundStartTimestamp int64 `json:"RefundStartTimestamp"` // 开始退款时间(秒级时间戳)
|
||
RefundSuccTimestamp int64 `json:"RefundSuccTimestamp"` // 结束退款时间(秒级时间戳)
|
||
WxpayRefundTransactionID string `json:"WxpayRefundTransactionId"` // 退款单的微信支付单号
|
||
RetryTimes int `json:"RetryTimes"` // 微信重试次数(从 0 开始)
|
||
Attach string `json:"Attach"` // iOS 退款推送附带:下单 attach(选填)
|
||
WxTransactionID string `json:"WxTransactionId"` // iOS 退款推送附带:原支付单 TransactionId(选填)
|
||
|
||
// ---- 旧版式兼容字段(官方现行报文已无,保留解析以防版式回退)----
|
||
RefundStatus string `json:"RefundStatus"` // 退款状态(旧版式,SUCCESS=成功)
|
||
RefundOrderID string `json:"RefundOrderId"` // 商户退款单号(旧版式)
|
||
}
|
||
|
||
// OrderNo 取商户订单号:官方字段 MchOrderId 优先,旧版式回退公共字段 OutTradeNo。
|
||
func (n *XPayRefundNotify) OrderNo() string {
|
||
if n.MchOrderID != "" {
|
||
return n.MchOrderID
|
||
}
|
||
return n.OutTradeNo
|
||
}
|
||
|
||
// RefundNo 取商户退款单号:官方字段 MchRefundId 优先,依次回退旧版式字段与微信退款单号(仅用于幂等锁与日志)。
|
||
func (n *XPayRefundNotify) RefundNo() string {
|
||
switch {
|
||
case n.MchRefundID != "":
|
||
return n.MchRefundID
|
||
case n.RefundOrderID != "":
|
||
return n.RefundOrderID
|
||
default:
|
||
return n.WxRefundID
|
||
}
|
||
}
|
||
|
||
// Succeeded 判定退款结果:官方以 RetCode=0 表示成功;旧版式回退 RefundStatus(大小写不敏感 SUCCESS)。
|
||
// determinable=false 表示报文未携带任何可判定字段,调用方应 fail-closed(应答失败触发微信重试)。
|
||
func (n *XPayRefundNotify) Succeeded() (ok bool, determinable bool) {
|
||
if n.RetCode != nil {
|
||
return *n.RetCode == XPayRefundRetCodeSuccess, true
|
||
}
|
||
if n.RefundStatus != "" {
|
||
return strings.EqualFold(n.RefundStatus, XPayRefundStatusSuccess), true
|
||
}
|
||
return false, false
|
||
}
|
||
|
||
// XPayIOSRefundQueryNotify iOS 退款问询推送。
|
||
type XPayIOSRefundQueryNotify struct {
|
||
XPayNotifyBase
|
||
// 字段以官方文档为准;此处解析退款问询所需的最小信息。
|
||
MchOrderID string `json:"MchOrderId"` // 兼容:部分版式用商户单号命名
|
||
RefundOrderID string `json:"RefundOrderId"`
|
||
RefundFee int64 `json:"RefundFee"`
|
||
}
|
||
|
||
// OrderNo 取商户订单号:公共字段 OutTradeNo 优先(保持既有行为),为空时回退 MchOrderId。
|
||
func (n *XPayIOSRefundQueryNotify) OrderNo() string {
|
||
if n.OutTradeNo != "" {
|
||
return n.OutTradeNo
|
||
}
|
||
return n.MchOrderID
|
||
}
|
||
|
||
// ParseNotify 解析消息推送 JSON 并返回事件类型与公共字段。
|
||
// 返回值为 base(公共字段)+ 原始 body,调用方再按 Event 分发到具体结构。
|
||
func ParseNotify(raw []byte) (*XPayNotifyBase, error) {
|
||
if len(raw) == 0 {
|
||
return nil, errors.New("empty xpay notify body")
|
||
}
|
||
// MP 后台「消息推送 → 数据格式」可选 JSON/XML(默认 XML),本实现只支持 JSON。
|
||
// 收到 XML 时给出明确提示,否则只会表现为「反复重试 + 无业务日志」,极难定位。
|
||
if trimmed := bytes.TrimLeft(raw, " \t\r\n"); len(trimmed) > 0 && trimmed[0] == '<' {
|
||
return nil, errors.New("xpay notify is XML but only JSON is supported; " +
|
||
"please set 消息推送 → 数据格式 = JSON in MP console")
|
||
}
|
||
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 退款问询应答(Apple 最多连续问询 3 次,须 3 秒内返回)。
|
||
// 应答必须包一层 IosRefundQueryResponse,并带 ErrCode/ErrMsg,字段名以官方文档为准:
|
||
// - result_code:0 放过(建议退款)、1 拦截(拒绝退款)
|
||
// - result_info:结果描述
|
||
// - evidence:决策凭据(必填,须具体,用于退款审计,不可为空或过于笼统)
|
||
//
|
||
// 使用 json.Marshal 保证字符串转义符合 JSON 规范(避免 fmt %q 的 Go 风格转义)。
|
||
func BuildIOSRefundQueryResponse(resultCode int, resultInfo, evidence string) []byte {
|
||
b, _ := json.Marshal(map[string]any{
|
||
"ErrCode": 0,
|
||
"ErrMsg": "success",
|
||
"IosRefundQueryResponse": map[string]any{
|
||
"result_code": resultCode,
|
||
"result_info": resultInfo,
|
||
"evidence": evidence,
|
||
},
|
||
})
|
||
return b
|
||
}
|
||
|
||
// sortedSHA1Hex 微信各类签名的统一算法:参与项按字典序排序后拼接,取 sha1 小写 hex。
|
||
// 用于 GET URL 验证(token/timestamp/nonce)与安全模式的接收/回包签名(多一个 Encrypt)。
|
||
func sortedSHA1Hex(parts ...string) string {
|
||
sorted := append([]string(nil), parts...)
|
||
sort.Strings(sorted)
|
||
h := sha1.Sum([]byte(strings.Join(sorted, "")))
|
||
return hex.EncodeToString(h[:])
|
||
}
|
||
|
||
// CheckURLSignature 校验消息推送 URL 配置时的 GET 验签(SHA1)。
|
||
// token 为配置回调地址时自定义的 Token;通过则返回 true 并原样回显 echostr。
|
||
func CheckURLSignature(token, timestamp, nonce, signature string) bool {
|
||
if token == "" || signature == "" {
|
||
return false
|
||
}
|
||
return sortedSHA1Hex(token, timestamp, nonce) == 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)
|
||
}
|