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"`
// Attach 透传数据(下单时 BuildSignData 里带的 attach 原样回传)。
// ⚠️ 仅作日志/交叉校验,**永不作为发货依据**(约定 S4:发货只读本地订单行)。
Attach string `xml:"Attach"`
}
// 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")
}
// Attach 仅作日志/交叉校验,**永不作为发货依据**(约定 S4:发货只读本地订单行)。
// 注意:平台推送在部分场景不携带 Attach,缺失不影响发货。
g.Log().Infof(context.Background(),
"[xpay] 发货推送 attach=%s outTradeNo=%s productId=%s",
notify.Attach, notify.OutTradeNo, notify.GoodsInfo.ProductId)
return notify, nil
}
// XPayNotifyAck 发货推送成功应答(平台要求返回 0,否则最多重试 15 次)
func XPayNotifyAck() string {
return "0"
}
// XPayNotifyAckFail 发货推送失败应答(触发平台重试)
func XPayNotifyAckFail(msg string) string {
return fmt.Sprintf("-1", msg)
}