package service import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "sort" "strings" "qnc-server/app/main/api/internal/config" "qnc-server/app/main/model" "qnc-server/pkg/lzkit/lzUtils" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/stores/redis" ) const ( xpaySessionKeyFmt = "qnc:wx:xpay:session:%s" xpayNotifiedKeyFmt = "qnc:xpay:notified:%s" xpayAccessTokenKey = "qnc:wx:xpay:access_token" xpayMode = "short_series_goods" xpayVirtualPaymentURI = "requestVirtualPayment" ) // XpayPrepayData 前端 wx.requestVirtualPayment 参数 type XpayPrepayData struct { Provider string `json:"provider"` Mode string `json:"mode"` SignData string `json:"signData"` PaySig string `json:"paySig"` Signature string `json:"signature"` } type XpayService struct { config config.Config redis *redis.Redis } func NewXpayService(c config.Config, r *redis.Redis) *XpayService { return &XpayService{config: c, redis: r} } func (s *XpayService) Enabled() bool { return s.config.WechatXpay.Enabled && s.config.WechatXpay.OfferId != "" && s.config.WechatXpay.AppKey != "" } func (s *XpayService) Env() int { return s.config.WechatXpay.Env } func (s *XpayService) SessionKeyRedisKey(userID string) string { return fmt.Sprintf(xpaySessionKeyFmt, userID) } func (s *XpayService) SaveSessionKey(ctx context.Context, userID, sessionKey string) error { ttl := int(s.config.WechatXpay.SessionKeyTTL) if ttl <= 0 { ttl = int(s.config.JwtAuth.AccessExpire) } return s.redis.SetexCtx(ctx, s.SessionKeyRedisKey(userID), sessionKey, ttl) } func (s *XpayService) GetSessionKey(ctx context.Context, userID string) (string, error) { val, err := s.redis.GetCtx(ctx, s.SessionKeyRedisKey(userID)) if err != nil { return "", err } if val == "" { return "", redis.Nil } return val, nil } func hmacSHA256Hex(key, data string) string { mac := hmac.New(sha256.New, []byte(key)) mac.Write([]byte(data)) return hex.EncodeToString(mac.Sum(nil)) } func compactJSON(v interface{}) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil } type signDataPayload 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"` } // BuildPayParams 构造虚拟支付双签参数 func (s *XpayService) BuildPayParams(ctx context.Context, userID, orderNo, productEn string, amount float64) (*XpayPrepayData, error) { sessionKey, err := s.GetSessionKey(ctx, userID) if err != nil { if err == redis.Nil { return nil, fmt.Errorf("微信会话已过期,请重新打开小程序") } return nil, fmt.Errorf("获取微信会话失败: %w", err) } goodsPrice := lzUtils.ToWechatAmount(amount) xpayProductID := ResolveXpayProductId(productEn) payload := signDataPayload{ OfferId: s.config.WechatXpay.OfferId, BuyQuantity: 1, Env: s.Env(), CurrencyType: "CNY", ProductId: xpayProductID, GoodsPrice: goodsPrice, OutTradeNo: orderNo, Attach: fmt.Sprintf("query:%s", orderNo), } signData, err := compactJSON(payload) if err != nil { return nil, err } appKey := s.config.WechatXpay.AppKey paySig := hmacSHA256Hex(appKey, xpayVirtualPaymentURI+"&"+signData) signature := hmacSHA256Hex(sessionKey, signData) logx.WithContext(ctx).Infof("[xpay] create user=%s order_no=%s env=%d product_en=%s xpay_product_id=%s goodsPrice=%d", userID, orderNo, s.Env(), productEn, xpayProductID, goodsPrice) return &XpayPrepayData{ Provider: "xpay", Mode: xpayMode, SignData: signData, PaySig: paySig, Signature: signature, }, nil } func (s *XpayService) getAccessToken(ctx context.Context) (string, error) { cached, err := s.redis.GetCtx(ctx, xpayAccessTokenKey) if err == nil && cached != "" { return cached, nil } appID := s.config.WechatMini.AppID secret := s.config.WechatMini.AppSecret url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appID, secret) resp, err := http.Get(url) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) var tokenResp struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(body, &tokenResp); err != nil { return "", err } if tokenResp.ErrCode != 0 || tokenResp.AccessToken == "" { return "", fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg) } ttl := tokenResp.ExpiresIn - 300 if ttl < 60 { ttl = 60 } _ = s.redis.SetexCtx(ctx, xpayAccessTokenKey, tokenResp.AccessToken, ttl) return tokenResp.AccessToken, nil } type xpayAPIResp struct { ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` Data json.RawMessage `json:"data"` Order json.RawMessage `json:"order"` // query_order 官方返回 order 字段 } type xpayOrderStatus struct { Status int `json:"status"` PaidFee int64 `json:"paid_fee"` LeftFee int64 `json:"left_fee"` OrderID string `json:"order_id"` WxOrderID string `json:"wx_order_id"` ErrMsg string `json:"err_msg"` RawOrder string `json:"-"` // query_order 原始 order JSON,便于日志排查 } // XpayAPIError 微信 xpay 接口返回 errcode != 0 type XpayAPIError struct { URI string ErrCode int ErrMsg string RawBody string } func (e *XpayAPIError) Error() string { return fmt.Sprintf("xpay %s errcode=%d errmsg=%s body=%s", e.URI, e.ErrCode, e.ErrMsg, e.RawBody) } func (s *XpayService) callAPI(ctx context.Context, uri, bodyJSON, sessionKey string, needSignature bool) (*xpayAPIResp, error) { accessToken, err := s.getAccessToken(ctx) if err != nil { return nil, err } appKey := s.config.WechatXpay.AppKey paySig := hmacSHA256Hex(appKey, uri+"&"+bodyJSON) reqURL := fmt.Sprintf("https://api.weixin.qq.com%s?access_token=%s&pay_sig=%s", uri, accessToken, paySig) if needSignature && sessionKey != "" { sig := hmacSHA256Hex(sessionKey, bodyJSON) reqURL += "&signature=" + sig } req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader([]byte(bodyJSON))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") logx.WithContext(ctx).Infof("[xpay] req uri=%s env=%d body=%s", uri, s.Env(), bodyJSON) httpResp, err := http.DefaultClient.Do(req) if err != nil { logx.WithContext(ctx).Errorf("[xpay] http err uri=%s err=%v", uri, err) return nil, err } defer httpResp.Body.Close() respBody, _ := io.ReadAll(httpResp.Body) respStr := string(respBody) var apiResp xpayAPIResp if err := json.Unmarshal(respBody, &apiResp); err != nil { logx.WithContext(ctx).Errorf("[xpay] parse err uri=%s http=%d body=%s err=%v", uri, httpResp.StatusCode, respStr, err) return nil, fmt.Errorf("解析 xpay 响应失败: %w, body=%s", err, respStr) } logx.WithContext(ctx).Infof("[xpay] resp uri=%s http=%d errcode=%d errmsg=%s body=%s", uri, httpResp.StatusCode, apiResp.ErrCode, apiResp.ErrMsg, respStr) if apiResp.ErrCode != 0 { return &apiResp, &XpayAPIError{ URI: uri, ErrCode: apiResp.ErrCode, ErrMsg: apiResp.ErrMsg, RawBody: respStr, } } return &apiResp, nil } // QueryOrder 调微信 query_order func (s *XpayService) QueryOrder(ctx context.Context, openid, orderNo, sessionKey string) (*xpayOrderStatus, error) { bodyObj := map[string]interface{}{ "openid": openid, "env": s.Env(), "order_id": orderNo, } bodyJSON, err := compactJSON(bodyObj) if err != nil { return nil, err } // 官方文档:query_order 仅需 pay_sig,不需用户态 signature apiResp, err := s.callAPI(ctx, "/xpay/query_order", bodyJSON, "", false) if err != nil { if xpayErr, ok := err.(*XpayAPIError); ok { logx.WithContext(ctx).Errorf("[xpay] query_order FAIL order_no=%s openid=%s errcode=%d errmsg=%s raw=%s", orderNo, openid, xpayErr.ErrCode, xpayErr.ErrMsg, xpayErr.RawBody) } return nil, err } orderRaw := apiResp.Order if len(orderRaw) == 0 { orderRaw = apiResp.Data } var status xpayOrderStatus if len(orderRaw) > 0 { status.RawOrder = string(orderRaw) _ = json.Unmarshal(orderRaw, &status) } logx.WithContext(ctx).Infof("[xpay] query_order order_no=%s status=%d paid_fee=%d left_fee=%d wx_order_id=%s err_msg=%s", orderNo, status.Status, status.PaidFee, status.LeftFee, status.WxOrderID, status.ErrMsg) return &status, nil } // RefundOrder 启动 xpay 退款任务(全额退) func (s *XpayService) RefundOrder(ctx context.Context, openid, orderNo, refundOrderID string, refundFeeFen int64) error { if refundFeeFen <= 0 { return fmt.Errorf("退款金额无效") } status, err := s.QueryOrder(ctx, openid, orderNo, "") if err != nil { return fmt.Errorf("退款前查单失败: %w", err) } if IsXpayAlreadyRefunded(status) { logx.WithContext(ctx).Infof("[xpay] refund_order skip already refunded order_no=%s wx_status=%d left_fee=%d paid_fee=%d", orderNo, status.Status, status.LeftFee, status.PaidFee) return nil } leftFee := status.LeftFee if leftFee <= 0 { leftFee = status.PaidFee } if leftFee <= 0 { leftFee = refundFeeFen } if refundFeeFen > leftFee { refundFeeFen = leftFee } bodyObj := map[string]interface{}{ "openid": openid, "env": s.Env(), "order_id": orderNo, "refund_order_id": refundOrderID, "left_fee": leftFee, "refund_fee": refundFeeFen, "biz_meta": fmt.Sprintf("refund:%s", orderNo), "refund_reason": 5, "req_from": 3, } bodyJSON, err := compactJSON(bodyObj) if err != nil { return err } apiResp, err := s.callAPI(ctx, "/xpay/refund_order", bodyJSON, "", false) if err != nil { if xpayErr, ok := err.(*XpayAPIError); ok { if xpayErr.ErrCode == 268490004 || xpayErr.ErrCode == 268490005 || xpayErr.ErrCode == 268490014 { logx.WithContext(ctx).Infof("[xpay] refund_order idempotent order_no=%s errcode=%d", orderNo, xpayErr.ErrCode) return nil } logx.WithContext(ctx).Errorf("[xpay] refund_order FAIL order_no=%s errcode=%d errmsg=%s raw=%s", orderNo, xpayErr.ErrCode, xpayErr.ErrMsg, xpayErr.RawBody) } return err } _ = apiResp logx.WithContext(ctx).Infof("[xpay] refund_order OK order_no=%s refund_order_id=%s fee=%d", orderNo, refundOrderID, refundFeeFen) return nil } // NotifyProvideGoods 主动通知微信已发货 func (s *XpayService) NotifyProvideGoods(ctx context.Context, openid, orderNo, wxOrderID, sessionKey string) error { bodyObj := map[string]interface{}{ "openid": openid, "env": s.Env(), "order_id": orderNo, } if wxOrderID != "" { bodyObj["wx_order_id"] = wxOrderID } bodyJSON, err := compactJSON(bodyObj) if err != nil { return err } _, err = s.callAPI(ctx, "/xpay/notify_provide_goods", bodyJSON, sessionKey, true) if err != nil { if xpayErr, ok := err.(*XpayAPIError); ok { if xpayErr.ErrCode == 268490004 { logx.WithContext(ctx).Infof("[xpay] notify_provide_goods idempotent order_no=%s", orderNo) return nil } logx.WithContext(ctx).Errorf("[xpay] notify_provide_goods FAIL order_no=%s errcode=%d errmsg=%s raw=%s", orderNo, xpayErr.ErrCode, xpayErr.ErrMsg, xpayErr.RawBody) } return err } logx.WithContext(ctx).Infof("[xpay] notify_provide_goods OK order_no=%s", orderNo) return nil } // IsXpayPaidStatus 微信订单状态 2/3/4 视为支付成功 func IsXpayPaidStatus(status int) bool { return status == 2 || status == 3 || status == 4 } // IsXpayRefundedStatus 微信侧已退款:5=订单已经退款,8=用户退款完成 func IsXpayRefundedStatus(status int) bool { return status == 5 || status == 8 } // IsXpayAlreadyRefunded 查单结果表示微信侧已无可退金额(含在微信后台手动退款、本地未同步的情况) func IsXpayAlreadyRefunded(status *xpayOrderStatus) bool { if status == nil { return false } if IsXpayRefundedStatus(status.Status) { return true } return status.PaidFee > 0 && status.LeftFee == 0 && status.Status >= 2 } // IsXpayClosedStatus 6 视为关闭 func IsXpayClosedStatus(status int) bool { return status == 6 } // VerifyPushSignature GET 验签(消息推送配置) func (s *XpayService) VerifyPushSignature(signature, timestamp, nonce string) bool { token := s.config.WechatXpay.MessagePushToken arr := []string{token, timestamp, nonce} sort.Strings(arr) hash := sha1.Sum([]byte(strings.Join(arr, ""))) return signature == hex.EncodeToString(hash[:]) } // XpayDeliverNotify 推送消息体(明文 JSON) type XpayDeliverNotify struct { Event string `json:"Event"` OpenId string `json:"OpenId"` OutTradeNo string `json:"OutTradeNo"` Env int `json:"Env"` WeChatPayInfo struct { TransactionId string `json:"TransactionId"` PaidTime int64 `json:"PaidTime"` } `json:"WeChatPayInfo"` GoodsInfo struct { ProductId string `json:"ProductId"` Quantity int `json:"Quantity"` OrigPrice int64 `json:"OrigPrice"` ActualPrice int64 `json:"ActualPrice"` Attach string `json:"Attach"` } `json:"GoodsInfo"` } func (s *XpayService) AlreadyNotified(ctx context.Context, orderNo string) (bool, error) { key := fmt.Sprintf(xpayNotifiedKeyFmt, orderNo) val, err := s.redis.GetCtx(ctx, key) if err == redis.Nil { return false, nil } if err != nil { return false, err } return val != "", nil } func (s *XpayService) MarkNotified(ctx context.Context, orderNo string) error { key := fmt.Sprintf(xpayNotifiedKeyFmt, orderNo) return s.redis.SetexCtx(ctx, key, "1", 7*24*3600) } // GetWxMiniOpenID 查询用户小程序 openid func (s *XpayService) GetWxMiniOpenID(ctx context.Context, userAuthModel model.UserAuthModel, userID string) (string, error) { auth, err := userAuthModel.FindOneByUserIdAuthType(ctx, userID, model.UserAuthTypeWxMiniOpenID) if err != nil { return "", err } return auth.AuthKey, nil }