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) }