微信小遊戲sdk接入支付和登入,解決了wx小遊戲內不支援ios支付的痛點

玉面蛟龙發表於2024-11-25

前情提要

微信小遊戲是小程式的一種。
專案接入微信小遊戲sdk的支付和登入。主要難點在於接入ios的支付。因為官方只支援android, 不支援ios。
即ios使用者不能直接在小遊戲中發起支付,參考市面上的wx小遊戲,大都採用的是進入客服會話,客服發支付連結,ios玩家點選連結後拉起支付付款
wx的文件很多,但並沒有在一塊,本文件提供了接入wxsdk 各流程和相關連結。希望後來者接入不需要像我一樣費力。
以下所有流程我自己都是跑透過的,無需擔心。 此文章主要側重於伺服器部分的實現, 很多難寫的地方, 我也貼上了Go程式碼。

wx小遊戲 andorid 支付流程

        圖1: wx小遊戲支付流程

小遊戲道具直購支付成功,發貨回撥

  1. 文件參考
    https://developers.weixin.qq.com/minigame/dev/guide/open-ability/virtual-payment/goods.html
    https://docs.qq.com/doc/DVUN0QWJja0J5c2x4?open_in_browser=true
    https://developers.weixin.qq.com/miniprogram/dev/framework/server-ability/message-push.html
    https://docs.qq.com/doc/DR1hhWlpnQXJXWHRh
    https://docs.qq.com/doc/DVWF6Z3dEVHJPWExn

  2. 配置

       圖2:wx小遊戲虛擬支付配置圖,url只允許配置 https 開頭
    

2.1 虛擬支付 2.0 > 直購配置 > 道具發貨配置 > 開啟推送。點選提交會立即向配置的url 傳送Http Get請求。 這是驗證url是否可用
伺服器收到Get請求後需要校驗簽名, 並返回url引數中的echoStr, 才能提交配置。
無論配置的明文模式還是安全模式,簽名都按明文模式解析。 以下是Go版本的程式碼

點選檢視小遊戲 道具直購發貨推送Get驗籤程式碼
func receiveMsgNotify(tx *Tx, w http.ResponseWriter, r *http.Request) {
	// 第一次會發微信sdk會發Get來驗證, 實際傳輸資料會發post
	if r.Method == http.MethodGet {
		replyVerifyUrl(tx, w, r)
		return
	}
   // 處理post請求
}

// replyVerifyUrl 回覆開啟訊息推送時驗證Url可用的Get請求
func replyVerifyUrl(tx *Tx, w http.ResponseWriter, r *http.Request) {
	// 簽名驗證, 訊息是否來自微信
	query := r.URL.Query()
	signature := query.Get("signature")
	timestamp := query.Get("timestamp")
	nonce := query.Get("nonce")
	echostr := query.Get("echostr")

	if !plainTextModeVerifySignature(signature, timestamp, nonce) {
		w.Write([]byte("fail"))
	}

	// 第一次會發微信sdk會發Get來驗證, 實際傳輸資料會發post
	w.Write([]byte(echostr))
}
// plainTextModeVerifySignature 明文模式簽名驗證
func plainTextModeVerifySignature(signature, timestamp, nonce string) bool {
	// 簽名驗證, 訊息是否來自微信
	strings := []string{timestamp, nonce, "你配置的Token"}
	sort.Strings(strings) // 進行字典型排序
	data := sha1.Sum([]byte(fmt.Sprintf("%s%s%s", strings[0], strings[1], strings[2])))

	encryptData := hex.EncodeToString(data[:])
	return encryptData == signature
}

2.2 開啟道具直購推送後,還需要點選模擬推送, 返回值 需要為 {"ErrCode":0,"ErrMsg":"Success"}, 才算配置完成 收到Post請求 需要校驗兩次簽名, 一次是樓上url引數中攜帶的簽名,一次是 body中解析出來 PayEventSig欄位的簽名, 以下是Go版本的PayEventSig欄位的驗籤程式碼
點選檢視小遊戲道具直購推送Payload欄位驗籤程式碼
func receiveMsgNotifyPost(tx *Tx, w http.ResponseWriter, r *http.Request) bool {
    ds, _:= io.ReadAll(r.Body)
    req := &YourStructName{}
	if err = json.Unmarshal(ds, req); err != nil {     // 明文模式body裡的引數可以直接解, 安全模式的解法我放在最後
		return false
	}
    payLoad := YourPayLoadStructName{}
    if err = json.Unmarshal([]byte(req.MiniGame.Payload), payLoad); err != nil {
		return false
	}
    
    var appkey string
    switch payLoad.Env {
      case 0: return 虛擬支付2.0-> 基本配置 -> 基礎配置 -> 支付基礎配置 -> 現網 AppKey
      case 1: return 虛擬支付2.0-> 基本配置 -> 基礎配置 -> 支付基礎配置 -> 沙箱 AppKey
      default: return false
    }
    createSign := createWeixinSdkSign(appkey, req.Event, req.MiniGame.Payload)
    return weixinreq.MiniGame.PayEventSig == createSign
}

// 生成微信訊息道具直購Post推送簽名
func createWeixinSdkSign(app_key string, event, payload string) string {
	data := fmt.Sprintf("%s&%s", event, payload)

	hmacSha256ToHex := func(key, data string) string {
		mac := hmac.New(sha256.New, []byte(key))
		_, _ = mac.Write([]byte(data))
		bs := mac.Sum(nil)
		return hex.EncodeToString(bs[:])
	}

	return hmacSha256ToHex(app_key, data)
}

2.3 模擬發包驗證成功後, 如圖所示

  圖3: 小程式虛擬支付道具直購推送成功開啟

wx小遊戲 ios 支付流程

  圖4: wx小遊戲 ios 支付流程

wx小程式下單

1.doc
https://pay.weixin.qq.com/docs/merchant/apis/mini-program-payment/mini-prepay.html
https://github.com/wechatpay-apiv3/wechatpay-go?tab=readme-ov-file // go版本
2.程式碼使用官方開源庫,請檢視開源庫提供的例子

伺服器收到玩家進入客服會話推送

  1. 文件參考
    https://developers.weixin.qq.com/minigame/dev/guide/open-ability/customer-message/receive.html
    https://developers.weixin.qq.com/minigame/dev/api-backend/open-api/access-token/auth.getStableAccessToken.html
    https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/kf-mgnt/kf-message/sendCustomMessage.html
    https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/kf-mgnt/kf-message/uploadTempMedia.html
    https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/kf-mgnt/kf-message/getTempMedia.html
  2. 配置
    小程式管理後臺 -> 開發 -> 開發管理 -> 訊息推送配置
    和道具發貨推送配置一樣的, 要配Url、Token、EncodingAESKey、資料加密方式、資料格式
    url 允許配置http開頭,但必須選擇安全模式
    點選提交時,會傳送Get, 以明文模式驗籤並返回echoStr,樓上已展示程式碼

伺服器向玩家推送客服訊息,攜帶圖文連結

  1. 傳送客服訊息,url引數中需要攜帶 access_token, 它每2小時過期, 且有次數限制

  2. access_token根據appid和appsecret, 向wxsdk傳送post請求拿到。我推薦stable_token

    圖5: stable_token 在有效期內多次獲取,不會使原有的token失效。
    
  3. 想要客服會話中有圖片,那麼需要先上傳圖片資源。 小程式只允許上傳臨時資源,即你上傳的資源3天就會過期, 過期了就需要重新上傳。

Go版本上傳圖片資源的程式碼
// url := fmt.Sprintf("%s?access_token=%s&type=%s", https://api.weixin.qq.com/cgi-bin/media/upload(官網上新增圖片素材的url), 從wxsdk處獲取到的access_token, "image")
	
// httpUploadImage 圖片過期了上傳圖片到wx伺服器
func httpUploadImage(url, imagePath string, reply interface{}) error {
	body := &bytes.Buffer{}
	writer := multipart.NewWriter(body)
	file, err := os.Open(imagePath)
	if err != nil {
		return fmt.Errorf("imagepath illegal:%v, err:%v", imagePath, err)
	}
	defer file.Close()

	part, err := writer.CreateFormFile("media", imagePath)
	if err != nil {
		return fmt.Errorf("createFormFile err:%v", err)
	}
	_, err = io.Copy(part, file)
	if err != nil {
		return fmt.Errorf("io.copy err:%v", err)
	}
	writer.Close()

    // 我這裡用的 "github.com/go-resty/resty/v2" 包, 用標準庫的http一樣的, Header 要手動改一下
	// 構建http請求
	resp, err := resty.New().SetTimeout(5*time.Second).R().
		SetHeader("Content-Type", writer.FormDataContentType()).
		SetBody(body).
		Post(url)
	if err != nil {
		return err
	}
	if !resp.IsSuccess() {
		return fmt.Errorf("http status code: %d", resp.StatusCode())
	}
	return json.Unmarshal(resp.Body(), reply)
}

3. 上傳圖片後得到 media_id, 傳送給玩家客服訊息中攜帶圖文連結, url 是 game server 要提供的, thumb_url為官網上獲取客服訊息中的臨時素材的url
點選檢視程式碼
// guestSessionSendMsg 接收玩家進入客服會話回撥, 傳送url
func guestSessionSendMsg(openid string, extraData *_GuestSessionExtraData) error {
	err := refreshAccessToken()  // 防止access_token過期,重新整理得到access_token
	if err != nil {
		return fmt.Errorf("refreshAccessToken err:%v", err)
	}
	err = refreshGuestImage()  // 防止臨時資源過期, 重新整理得到media_id
	if err != nil {
		return fmt.Errorf("refreshGuestImage err:%v", err)
	}

	linkTitle := "點我支付" 
	linkDescription := "充值後返回遊戲檢視"
	// 發給客服系統的paylink格式:https://自己的域名.com/wx_bridge/ios/paylink?sn=%s
	req := WeiXinSDKGuestSessionSendLinkReq{
		ToUser:  openid,
		MsgType: sendGuestMsgTypeLink,
		Link: &WeiXinSDKGuestSessionLink{
			Title:       linkTitle,
			Description: fmt.Sprintf("%s\n%s", "test", linkDescription),
			Url:         "game server 定製的url",
			Thumb_url:   fmt.Sprintf("%s?access_token=%s&type=image&media_id=%s", "https://api.weixin.qq.com/cgi-bin/media/get", "得到的access_token", "得到的image_id"),
		},
	}
    type _WeiXinSDKGuestSessionSendLinkRsp struct {
	  Errcode int    `json:"errcode"` // 0為成功
	  Errmsg  string `json:"errmsg"`  //
    }
	rsp := _WeiXinSDKGuestSessionSendLinkRsp{}

	dstUrl := fmt.Sprintf("%s?access_token=%s", "https://api.weixin.qq.com/cgi-bin/message/custom/send", "得到的access_token")
	if err = HttpPost(dstUrl, &req, &rsp); err != nil {
		return fmt.Errorf("HttpPost err:%v dstUrl:%v", err, dstUrl)
	}
	if rsp.Errcode != 0 {
		return fmt.Errorf("rsp not success :%v", rsp.Errmsg)
	}
	return nil
}

  圖6:實操截圖

玩家點選支付連結,伺服器返回帶小程式支付的html語法

  1. doc
    https://pay.weixin.qq.com/docs/merchant/apis/jsapi-payment/jsapi-transfer-payment.html
    https://pay.weixin.qq.com/wiki/doc/apiv3/apis/chapter3_1_4.shtml
  2. 程式碼
點選檢視程式碼
// 收到ClickLink請求
func ClickLink(tx *Tx, w http.ResponseWriter, r *http.Request) {
    // balabala 校驗程式碼
	nowStr := strconv.FormatInt(time.Now().Unix(), 10)
	packageStr := fmt.Sprintf("prepay_id=%s", "下單的時候儲存的prepayid")
	nonceStr := generateRandomString() // 隨機字串長度最長不能超過32位, 這段程式碼很簡單就不貼了
	paySign, err := createSign("小程式appID", nowStr, nonceStr, packageStr) // 參考github上支付寫的
	if err != nil {
		log.Panicf("[%s]iosPayLinkcheck  createSign err:%v order sn %s", tx, err, sn)
	}

	reply := fmt.Sprintf(`<html>
<script>
function onBridgeReady() {
      WeixinJSBridge.invoke('getBrandWCPayRequest', {
          "appId": "%s",
          "timeStamp": "%s",
          "nonceStr": "%s",
          "package": "%s",
          "signType": "RSA",
          "paySign":"%s"
      },
      function(res) {
          console.log(res.err_msg)
          if (res.err_msg == "get_brand_wcpay_request:ok") { // 支付成功
                document.write("payment success");
				WeixinJSBridge.call('closeWindow');
          }
          if (res.err_msg == "get_brand_wcpay_request:fail") { // 支付失敗
				document.write("payment fail");
				WeixinJSBridge.call('closeWindow');
          }
          if (res.err_msg == "get_brand_wcpay_request:cancel") { // 支付取消
				document.write("payment cancel");
  				WeixinJSBridge.call('closeWindow');
          }
      });
  }
  if (typeof WeixinJSBridge == "undefined") {
      if (document.addEventListener) {
          document.addEventListener('WeixinJSBridgeReady', onBridgeReady, false);
      } else if (document.attachEvent) {
          document.attachEvent('WeixinJSBridgeReady', onBridgeReady);
          document.attachEvent('onWeixinJSBridgeReady', onBridgeReady);
      }
  } else {
      onBridgeReady();
  }
</script>
</html>`, WxSdkAppID, nowStr, nonceStr, packageStr, paySign)

	w.Header().Set("Content-Type", "text/html")
	w.Write([]byte(reply))
	http.Error(w, "", http.StatusOK)
}

func createSign(appid, timeStamp, nonceStr, packageStr string) (string, error) {
	message := fmt.Sprintf("%s\n%s\n%s\n%s\n", appid, timeStamp, nonceStr, packageStr)
	// 載入私鑰
	privateKey, err := utils.LoadPrivateKeyWithPath("小程式商戶金鑰的路徑") //  官方開源提供的"github.com/wechatpay-apiv3/wechatpay-go/utils"
	if err != nil {
		return "", fmt.Errorf("load private payment key err:%v", err)
	}

	// 簽名
	signature, err := signWithRsa(message, privateKey)
	if err != nil {
		return "", fmt.Errorf("generateSignature err:%v", err)
	}
	return signature, nil
}

// 生成rsa簽名
func signWithRsa(data string, privateKey *rsa.PrivateKey) (string, error) {
	// 使用 SHA256 對待簽名資料進行雜湊
	hash := sha256.New()
	hash.Write([]byte(data))
	hashed := hash.Sum(nil)

	// 使用私鑰對雜湊值進行 RSA 簽名
	signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed)
	if err != nil {
		return "", fmt.Errorf("failed to sign data: %v", err)
	}

	// 將簽名進行 Base64 編碼
	encodedSignature := base64.StdEncoding.EncodeToString(signature)
	return encodedSignature, nil
}

小程式支付成功,發貨成功回撥

終於走到這一步了!
https://pay.weixin.qq.com/docs/merchant/apis/mini-program-payment/payment-notice.html
同樣, 使用官方開源庫及例子書寫
至此,wx小遊戲ios支付已通

wx小遊戲登入流程

   圖7: wx 小遊戲官方登入流程圖

    圖8: wx小遊戲登入流程圖(簡化版)

後記

推送訊息,如果使用安全模式, 官方並沒有go版本的文件, 問了客服,也遲遲沒回復,於是我自己寫了一版。測試過能解析,但還是有點擔心可能也有解析不到的特殊資料。

點選檢視程式碼
// getMsgByPlainTextMode (安全)加密模式解析訊息   官方沒有提供go的寫法,自己寫的,可能有問題。
func getMsgBySafeMode(r *http.Request, req interface{}) error {
	query := r.URL.Query()
	signature := query.Get("msg_signature")
	timestamp := query.Get("timestamp")
	nonce := query.Get("nonce")

	ds, err := io.ReadAll(r.Body)
	if err != nil {
		return fmt.Errorf("io.ReadAll err %v", err)
	}
	type _WeiXinSDKGuestMsgSafeMode struct {
		ToUserName string // 小遊戲原始ID
		Encrypt    string // 密文
	}

	encryptReq := &_WeiXinSDKGuestMsgSafeMode{}
	if err = json.Unmarshal(ds, encryptReq); err != nil {
		return fmt.Errorf("json.unmarshal err:%v ds:%v", err, string(ds))
	}
	if len(encryptReq.Encrypt) == 0 {
		return fmt.Errorf("encryReq.Encrypt is empty ")
	}

	if !safeModeVerifySignature(signature, timestamp, nonce, encryptReq.Encrypt) {
		log.Errorf("getMsgByPlainTextMode signature not match, signature:%v, timestamp:%v nonce:%v", signature, timestamp, nonce)
		return errors.New("signature not match")
	}

	// 漫長的解密步驟
	encodingAESKey := WxSdkGuestMsgEncodingAESKey
	encodingAESKey += "="
	aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey)
	if err != nil {
		return err
	}

	tmpMsg, err := base64.StdEncoding.DecodeString(encryptReq.Encrypt)
	if err != nil {
		return err
	}

	// 使用 AES 解密
	fullStr, err := aesDecryptCBC(tmpMsg, aesKey)
	if err != nil {
		return err
	}
	msg := fullStr[20:]
	ret := strings.Split(string(msg), "}")
	if len(ret) == 0 {
		return errors.New("msg is empty")
	}
	ret[0] += "}"

	if err = json.Unmarshal([]byte(ret[0]), req); err != nil {
		return fmt.Errorf("json.unmarshal err:%v ds:%v", err, string(ds))
	}
	return nil
}

func aesDecryptCBC(cipherText, key []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, fmt.Errorf("failed to create AES cipher: %v", err)
	}

	// 獲取 AES 塊大小
	blockSize := block.BlockSize()

	// 確保密文長度是塊大小的整數倍
	if len(cipherText)%blockSize != 0 {
		return nil, fmt.Errorf("ciphertext is not a multiple of block size")
	}

	// 使用 CBC 模式解密
	mode := cipher.NewCBCDecrypter(block, key[:blockSize]) // CBC 模式,IV 是金鑰的一部分
	plainText := make([]byte, len(cipherText))
	mode.CryptBlocks(plainText, cipherText)

	// 去除填充
	plainText, err = pkcs7UnPadding(plainText)
	if err != nil {
		return nil, fmt.Errorf("failed to remove padding: %v", err)
	}

	return plainText, nil
}

// 去掉 PKCS#7 填充
func pkcs7UnPadding(data []byte) ([]byte, error) {
	length := len(data)
	if length == 0 {
		return nil, fmt.Errorf("data is empty")
	}

	// 獲取填充位元組的大小
	padding := int(data[length-1])
	if padding > length {
		return nil, fmt.Errorf("invalid padding")
	}

	return data[:length-padding], nil
}

相關文章