English 简体中文 繁體中文 한국 사람 日本語 Deutsch русский بالعربية TÜRKÇE português คนไทย french
查看: 7|回复: 0

微信小游戏sdk接入支付和登录,解决了wx小游戏内不支持ios支付的痛点

[复制链接]
查看: 7|回复: 0

微信小游戏sdk接入支付和登录,解决了wx小游戏内不支持ios支付的痛点

[复制链接]
查看: 7|回复: 0

341

主题

0

回帖

1033

积分

金牌会员

积分
1033
okkkk

341

主题

0

回帖

1033

积分

金牌会员

积分
1033
2025-2-7 02:34:15 | 显示全部楼层 |阅读模式
前情提要

微信小游戏是小程序的一种。
项目接入微信小游戏sdk的支付和登录。主要难点在于接入ios的支付。因为官方只支持android, 不支持ios。
即ios用户不能直接在小游戏中发起支付,参考市面上的wx小游戏,大都采用的是进入客服会话,客服发支付链接,ios玩家点击链接后拉起支付付款
wx的文档很多,但并没有在一块,本文档提供了接入wxsdk 各流程和相关链接。希望后来者接入不需要像我一样费力。
以下所有流程我自己都是跑通过的,无需担心。 此文章主要侧重于服务器部分的实现, 很多难写的地方, 我也贴上了Go代码。
wx小游戏 andorid 支付流程


        图1: wx小游戏支付流程小游戏道具直购支付成功,发货回调

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小程序下单


  • 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版本
  • 代码
    使用官方开源库,请查看开源库提供的jaspi 下单例子。
    为什么决定用jsapi 下单, 是因为支付的时候需要用到jsapi 支付。
    为什么决定用jsapi 支付? 微信提供了 jsapi, app, h5, native, 小程序支付方式。
    app 和 小程序都需要在小程序客户端内调用, ios已知不行。
    native 是返回付款码,玩家需要扫码支付, 不方便。
    h5  和  jsapi比较, jsapi 更简单, 只需要在微信浏览器中打开这个html就可以调起支付。故选择jsapi下单

    图5: 微信支付的几种方式
服务器收到玩家进入客服会话推送

服务器向玩家推送客服消息,携带图文链接


  • 发送客服消息,url参数中需要携带 access_token,  它每2小时过期, 且有次数限制
  • access_token根据appid和appsecret, 向wxsdk发送post请求拿到。我推荐stable_token

    图5: stable_token 在有效期内多次获取,不会使原有的token失效。
  • 想要客服会话中有图片,那么需要先上传图片资源。 小程序只允许上传临时资源,即你上传的资源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 接收玩家进入客服会话回调, 发送urlfunc 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语法

点击查看代码// 收到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}
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

341

主题

0

回帖

1033

积分

金牌会员

积分
1033

QQ|智能设备 | 粤ICP备2024353841号-1

GMT+8, 2025-3-10 15:49 , Processed in 2.995857 second(s), 30 queries .

Powered by 智能设备

©2025

|网站地图