Files
qitongxue-api/internal/logic/xpay.go
2026-09-29 15:48:13 +08:00

480 lines
18 KiB
Go
Raw 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
xpayURIRefundOrder = "/xpay/refund_order" // B 端退款 uri(支付 365 天内可退)
)
// 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 // 原始响应,排障用
Refund xpayRefundProbe // 退款状态探测(供退款轮询用;Known=false 表示响应辨不出)
}
// 查单响应体。字段名以官方文档为准;未在文档中明确的字段(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"`
// 退款状态字段(供退款轮询用;官方文档未完整给出,多别名兼容,判不动时以退款推送为准)
RefundStatus int `json:"refundStatus"`
RefundStatus2 int `json:"refund_status"`
OutRefundNo string `json:"outRefundNo"`
OutRefundNo2 string `json:"out_refund_no"`
} `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,
Refund: refundProbeFromQuery(out),
}
g.Log().Debugf(ctx, "[xpay] query_order outTradeNo=%s paid=%v wxOrderId=%s raw=%s",
outTradeNo, state.Paid, state.WxOrderId, raw)
return state, nil
}
// ===== B 端:发起退款 =====
// xpayOrderState 退款轮询用的补充判定:查单响应中可辨别的退款状态。
// Known=false 表示响应里辨不出退款信息(字段缺失/枚举未知),以退款推送为准。
type xpayRefundProbe struct {
Known bool
Success bool
}
// refundProbeFromQuery 从查单响应中提取退款状态(多别名兼容,判不动则 Unknown)。
func refundProbeFromQuery(out *xpayQueryOrderResp) xpayRefundProbe {
o := out.Order
// 已知口径:退款成功后平台侧状态位为 0(与推送 RefundStatus=0 同源)。
// 仅在响应确实携带了退款单号时才把 0 视为「已退款」,避免字段缺失误判。
if o.RefundStatus == 0 && (o.OutRefundNo != "" || o.OutRefundNo2 != "") {
return xpayRefundProbe{Known: true, Success: true}
}
if o.RefundStatus2 == 0 && (o.OutRefundNo != "" || o.OutRefundNo2 != "") {
return xpayRefundProbe{Known: true, Success: true}
}
return xpayRefundProbe{Known: false}
}
// XPayRefundOrder 发起退款(POST /xpay/refund_order,支付 365 天内可退;
// 180 天内退手续费,见 doc/会员购买接入说明.md「费率与退款」)。
//
// 请求模式与 XPayQueryOrder 完全一致:请求体序列化后**同一串**用于 pay_sig 签名,
// URL query 带 access_token + pay_sig。错误判定 errcode != 0。
//
// 注意:该接口是「启动退款任务」,最终结果以 xpay_refund_notify 推送 / 查单轮询为准。
func XPayRefundOrder(
ctx context.Context, openid, outTradeNo, outRefundNo string, refundAmount int64, refundReason string,
) error {
cfg := xpayConfig(ctx)
if !cfg.Configured() {
return gerror.New("虚拟支付未配置(缺少 wx.pay.offerId / wx.pay.appKey)")
}
if openid == "" || outTradeNo == "" || outRefundNo == "" || refundAmount <= 0 {
return gerror.Newf("退款参数不完整: openid=%q outTradeNo=%q outRefundNo=%q amount=%d",
openid, outTradeNo, outRefundNo, refundAmount)
}
token, err := wxAccessToken(ctx)
if err != nil {
return err
}
// 请求体字符串必须与实际发出的完全一致 —— 先序列化,再拿同一串去签名
body := g.Map{
"openid": openid,
"env": cfg.Env,
"order_id": outTradeNo,
"out_refund_no": outRefundNo,
"refund_amount": refundAmount,
}
if refundReason != "" {
body["refund_reason"] = refundReason
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return gerror.Newf("组装退款请求体失败: %v", err)
}
postBody := string(bodyBytes)
paySig := CalcPaySig(xpayURIRefundOrder, postBody, cfg.AppKey)
url := fmt.Sprintf("%s%s?access_token=%s&pay_sig=%s", xpayAPIBase, xpayURIRefundOrder, token, paySig)
resp, err := g.Client().ContentJson().Post(ctx, url, postBody)
if err != nil {
return gerror.Newf("调用 refund_order 失败: %v", err)
}
defer resp.Close()
raw := string(resp.ReadAll())
out := &struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}{}
if err = json.Unmarshal([]byte(raw), out); err != nil {
return gerror.Newf("解析 refund_order 响应失败: %v raw=%s", err, raw)
}
if out.ErrCode != 0 {
return gerror.Newf("refund_order 返回错误: %d %s", out.ErrCode, out.ErrMsg)
}
g.Log().Infof(ctx, "[xpay] 发起退款 outTradeNo=%s outRefundNo=%s amount=%d raw=%s",
outTradeNo, outRefundNo, refundAmount, raw)
return 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"`
}
// XPayRefundNotify 退款结果推送(XML)解析结构。
// 字段名以官方文档为准;采用「多别名兼容 + 缺失不致命」策略(与查单同款),
// 成功判定优先看平台结果码字段,判不动时交由查单轮询兜底收敛。
type XPayRefundNotify 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"`
OutRefundNo string `xml:"OutRefundNo"`
Env int `xml:"Env"`
WeChatPayInfo struct {
MchOrderNo string `xml:"MchOrderNo"`
OutRefundNo string `xml:"OutRefundNo"`
} `xml:"WeChatPayInfo"`
RefundInfo struct {
OutRefundNo string `xml:"OutRefundNo"`
RefundFee int64 `xml:"RefundFee"` // 退款金额(分)
RefundStatus int `xml:"RefundStatus"` // 平台退款状态(0=退款成功,社区口径)
RefundRespCode int `xml:"RefundRespCode"` // 退款结果码(0=成功)
} `xml:"RefundInfo"`
}
// ParseNotifyEvent 解析推送 XML 的顶层事件名(最小解析,供 /pay/notify 分流)。
func ParseNotifyEvent(raw []byte) (string, error) {
var head struct {
XMLName xml.Name `xml:"xml"`
Event string `xml:"Event"`
}
if err := xml.Unmarshal(raw, &head); err != nil {
return "", gerror.Newf("解析推送 XML 失败: %v", err)
}
if head.Event == "" {
return "", gerror.New("推送缺少 Event 字段")
}
return head.Event, nil
}
// ParseRefundNotify 解析退款结果推送 XML(事件名由调用方分流后进入)。
func ParseRefundNotify(raw []byte) (*XPayRefundNotify, error) {
notify := &XPayRefundNotify{}
if err := xml.Unmarshal(raw, notify); err != nil {
return nil, gerror.Newf("解析退款推送 XML 失败: %v", err)
}
if notify.OutTradeNo == "" {
return nil, gerror.New("退款推送缺少 OutTradeNo")
}
g.Log().Infof(context.Background(),
"[xpay] 退款推送 outTradeNo=%s outRefundNo=%s refundFee=%d status=%d respCode=%d raw=%s",
notify.OutTradeNo, notify.OutRefundNo, notify.RefundInfo.RefundFee,
notify.RefundInfo.RefundStatus, notify.RefundInfo.RefundRespCode, string(raw))
return notify, nil
}
// RefundNotifySuccess 推送是否表明退款成功。
// 平台字段枚举在官方文档中未完整给出:已知的「0 = 成功」两个码位任一命中即成功;
// 都未携带时返回 false(不确定),交由查单轮询收敛,绝不误标成功。
func (n *XPayRefundNotify) RefundNotifySuccess() bool {
info := n.RefundInfo
switch {
case info.RefundRespCode != 0:
return true
case info.RefundStatus == 0 && (info.OutRefundNo != "" || info.RefundFee > 0 || n.OutRefundNo != ""):
// RefundStatus=0 只有在推送确实携带退款信息时才视为「成功 0」,
// 避免把「字段缺失默认 0」误判为成功。
return true
default:
return false
}
}
// ParseDeliverNotify 解析发货推送 XML(事件名校验保留:调用方先按 Event 分流后才进入)。
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 "<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)
}