Files
2026-09-17 17:38:45 +08:00

303 lines
10 KiB
Go
Raw Permalink 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 logic
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"tool-api/internal/consts"
)
// ============================================================================
// 微信小程序虚拟支付(道具直购)对接层
//
// 依据:https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/
// business-capabilities/virtual-payment/person
//
// 两套签名:
// paySig —— 服务端用「现网 AppKey」对 `uri&post_body` 做 HMAC-SHA256
// C 端 uri 固定 requestVirtualPayment;B 端为接口路径 /xpay/xxx
// signature —— 服务端用「session_key」对 signData 做 HMAC-SHA256(用户态签名)
//
// 关键约束:
// - post_body 必须与实际发出的请求体「完全一致」(不格式化、不改键顺序)
// - 金额单位是「分」,全程不换算
// - env 固定 0(现网)
// ============================================================================
const (
xpayAPIBase = "https://api.weixin.qq.com"
xpayURIPayRequest = "requestVirtualPayment" // C 端下单 uri(不带问号参数)
xpayURIQueryOrder = "/xpay/query_order" // B 端查单 uri
)
// xpaySignData C 端 signData 结构。
// 字段顺序即 JSON 序列化顺序,必须与签名时的字符串完全一致,切勿随意调整。
type xpaySignData struct {
OfferId string `json:"offerId"`
BuyQuantity int `json:"buyQuantity"`
Env int `json:"env"`
CurrencyType string `json:"currencyType"`
ProductId string `json:"productId"`
GoodsPrice int64 `json:"goodsPrice"` // 单位:分
OutTradeNo string `json:"outTradeNo"`
Attach string `json:"attach"`
}
// XPayConfig 虚拟支付配置(manifest/config/config.yaml 的 wx.pay 段)
type XPayConfig struct {
AppId string
OfferId string
AppKey string
Secret string // 微信 appSecret,取 access_token 用
Env int
}
// Configured 关键参数是否齐备。缺失时下单会直接报错,避免生成无法支付的签名。
func (c *XPayConfig) Configured() bool {
return c.OfferId != "" && c.AppKey != ""
}
func xpayConfig(ctx context.Context) *XPayConfig {
appId := g.Cfg().MustGet(ctx, "wx.pay.appId", "").String()
if appId == "" {
appId = g.Cfg().MustGet(ctx, "wx.appId", "").String()
}
return &XPayConfig{
AppId: appId,
OfferId: g.Cfg().MustGet(ctx, "wx.pay.offerId", "").String(),
AppKey: g.Cfg().MustGet(ctx, "wx.pay.appKey", "").String(),
Secret: g.Cfg().MustGet(ctx, "wx.appSecret", "").String(),
Env: g.Cfg().MustGet(ctx, "wx.pay.env", consts.XPayEnvProd).Int(),
}
}
// ===== 签名 =====
// CalcPaySig 支付签名:HMAC-SHA256(appKey, uri + "&" + postBody)
func CalcPaySig(uri, postBody, appKey string) string {
mac := hmac.New(sha256.New, []byte(appKey))
mac.Write([]byte(uri + "&" + postBody))
return hex.EncodeToString(mac.Sum(nil))
}
// CalcSignature 用户态签名:HMAC-SHA256(sessionKey, postBody)
func CalcSignature(postBody, sessionKey string) string {
mac := hmac.New(sha256.New, []byte(sessionKey))
mac.Write([]byte(postBody))
return hex.EncodeToString(mac.Sum(nil))
}
// BuildSignData 生成 signData 的 JSON 字符串(返回串必须原样用于签名与下发)
func BuildSignData(offerId, productId string, goodsPrice int64, outTradeNo, attach string, env int) (string, error) {
data := xpaySignData{
OfferId: offerId,
BuyQuantity: 1, // 会员一次买一份
Env: env,
CurrencyType: consts.XPayCurrency,
ProductId: productId,
GoodsPrice: goodsPrice,
OutTradeNo: outTradeNo,
Attach: attach,
}
body, err := json.Marshal(data)
if err != nil {
return "", gerror.Newf("组装 signData 失败: %v", err)
}
return string(body), nil
}
// ===== access_token(B 端接口需要)=====
var (
accessTokenMu sync.Mutex
accessTokenCache string
accessTokenExpire time.Time
)
// wxAccessToken 获取并缓存 access_token(stable_token 接口,有效期约 2 小时)
func wxAccessToken(ctx context.Context) (string, error) {
cfg := xpayConfig(ctx)
if cfg.AppId == "" || cfg.Secret == "" {
return "", gerror.New("未配置 wx.appId / wx.appSecret,无法获取 access_token")
}
accessTokenMu.Lock()
defer accessTokenMu.Unlock()
if accessTokenCache != "" && time.Now().Before(accessTokenExpire) {
return accessTokenCache, nil
}
resp, err := g.Client().Post(ctx, xpayAPIBase+"/cgi-bin/stable_token", g.Map{
"grant_type": "client_credential",
"appid": cfg.AppId,
"secret": cfg.Secret,
"force_refresh": false,
})
if err != nil {
return "", gerror.Newf("请求 access_token 失败: %v", err)
}
defer resp.Close()
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
return "", gerror.Newf("解析 access_token 响应失败: %v", err)
}
if out.ErrCode != 0 || out.AccessToken == "" {
return "", gerror.Newf("获取 access_token 失败: %d %s", out.ErrCode, out.ErrMsg)
}
accessTokenCache = out.AccessToken
// 提前 5 分钟过期,留出刷新余量
ttl := out.ExpiresIn - 300
if ttl < 60 {
ttl = 60
}
accessTokenExpire = time.Now().Add(time.Duration(ttl) * time.Second)
return accessTokenCache, nil
}
// ===== B 端:查询订单(兜底发货)=====
// xpayOrderState 查单结果(只保留发货判定需要的字段)
type xpayOrderState struct {
Paid bool // 是否已支付
WxOrderId string // 平台单号(若响应中可得)
RawJson string // 原始响应,排障用
}
// 查单响应体。字段名以官方文档为准;未在文档中明确的字段(wxOrderId 等)
// 采用「多别名兼容 + 缺失不致命」策略,避免因平台临时调整字段名而误判。
type xpayQueryOrderResp struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Order struct {
OrderId string `json:"orderId"`
WxOrderId string `json:"wxOrderId"`
WxOrderId2 string `json:"wx_order_id"`
Token string `json:"token"`
OrderFee int64 `json:"orderFee"`
PaidFee int64 `json:"paidFee"`
PaidTime int64 `json:"paidTime"`
Status int `json:"status"`
OrderType int `json:"orderType"`
} `json:"order"`
}
// XPayQueryOrder 主动查单:推送丢失时的兜底路径。
//
// 已支付判定依据是 `paidTime > 0 || paidFee > 0`,而不是 order.status —— 原因是
// status 的枚举在官方文档中未完整给出(社区可见 3 表示已发货,orderType 还新增了
// iOS 的 7/8),依赖枚举值有误判风险;而「有支付时间/实付金额」在任何渠道下都成立。
func XPayQueryOrder(ctx context.Context, openid, outTradeNo string) (*xpayOrderState, error) {
cfg := xpayConfig(ctx)
if !cfg.Configured() {
return nil, gerror.New("虚拟支付未配置(缺少 wx.pay.offerId / wx.pay.appKey)")
}
token, err := wxAccessToken(ctx)
if err != nil {
return nil, err
}
// 请求体字符串必须与实际发出的完全一致 —— 先序列化,再拿同一串去签名
body := g.Map{
"openid": openid,
"env": cfg.Env,
"order_id": outTradeNo,
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, gerror.Newf("组装查单请求体失败: %v", err)
}
postBody := string(bodyBytes)
paySig := CalcPaySig(xpayURIQueryOrder, postBody, cfg.AppKey)
url := fmt.Sprintf("%s%s?access_token=%s&pay_sig=%s", xpayAPIBase, xpayURIQueryOrder, token, paySig)
resp, err := g.Client().ContentJson().Post(ctx, url, postBody)
if err != nil {
return nil, gerror.Newf("调用 query_order 失败: %v", err)
}
defer resp.Close()
raw := string(resp.ReadAll())
out := &xpayQueryOrderResp{}
if err = json.Unmarshal([]byte(raw), out); err != nil {
return &xpayOrderState{RawJson: raw}, gerror.Newf("解析 query_order 响应失败: %v", err)
}
if out.ErrCode != 0 {
return &xpayOrderState{RawJson: raw}, gerror.Newf("query_order 返回错误: %d %s", out.ErrCode, out.ErrMsg)
}
wxOrderId := out.Order.WxOrderId
if wxOrderId == "" {
wxOrderId = out.Order.WxOrderId2
}
if wxOrderId == "" {
wxOrderId = out.Order.Token
}
state := &xpayOrderState{
Paid: out.Order.PaidTime > 0 || out.Order.PaidFee > 0,
WxOrderId: wxOrderId,
RawJson: raw,
}
g.Log().Debugf(ctx, "[xpay] query_order outTradeNo=%s paid=%v wxOrderId=%s raw=%s",
outTradeNo, state.Paid, state.WxOrderId, raw)
return state, nil
}
// ===== 发货推送解析 =====
// XPayDeliverNotify 道具发货推送(XML)解析结构
type XPayDeliverNotify struct {
XMLName xml.Name `xml:"xml"`
ToUserName string `xml:"ToUserName"`
FromUserName string `xml:"FromUserName"`
CreateTime int64 `xml:"CreateTime"`
MsgType string `xml:"MsgType"`
Event string `xml:"Event"`
OpenId string `xml:"OpenId"`
OutTradeNo string `xml:"OutTradeNo"`
Env int `xml:"Env"`
WeChatPayInfo struct {
MchOrderNo string `xml:"MchOrderNo"`
} `xml:"WeChatPayInfo"`
GoodsInfo struct {
ProductId string `xml:"ProductId"`
Quantity int `xml:"Quantity"`
} `xml:"GoodsInfo"`
}
// ParseDeliverNotify 解析发货推送 XML
func ParseDeliverNotify(raw []byte) (*XPayDeliverNotify, error) {
notify := &XPayDeliverNotify{}
if err := xml.Unmarshal(raw, notify); err != nil {
return nil, gerror.Newf("解析发货推送 XML 失败: %v", err)
}
if notify.Event != consts.XPayNotifyEvent {
return nil, gerror.Newf("非发货推送事件: %s", notify.Event)
}
if notify.OutTradeNo == "" {
return nil, gerror.New("发货推送缺少 OutTradeNo")
}
return notify, nil
}
// XPayNotifyAck 发货推送成功应答(平台要求返回 0,否则最多重试 15 次)
func XPayNotifyAck() string {
return "<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>"
}
// XPayNotifyAckFail 发货推送失败应答(触发平台重试)
func XPayNotifyAckFail(msg string) string {
return fmt.Sprintf("<xml><ErrCode>-1</ErrCode><ErrMsg><![CDATA[%s]]></ErrMsg></xml>", msg)
}