480 lines
18 KiB
Go
480 lines
18 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
|
||
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)
|
||
}
|