303 lines
10 KiB
Go
303 lines
10 KiB
Go
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)
|
||
}
|