Saas平台接入商户代小程序开发解决方案

简介

需求背景:
基于Saas平台为商户提供小程序接入代理进行代开发(包含支付场景)、管理、发布等一系列实际业务场景的解决方案

以下摘要至微信官方文档

平台概述
微信开放平台 - 第三方平台(简称第三方平台),由微信团队面向所有通过开发者资质认证的第三方开发者提供提供的官方平台。
在得到公众号或小程序管理员授权后,基于该平台,第三方服务商可以通过调用官方接口能力,为商家提供公众号代运营、小程序代注册、代开发等服务以及提供公众号和小程序相关的行业方案、活动营销、插件能力等全方位服务。

资料参考

微信官方文档
WeChat SDK for Go: https://github.com/silenceper/wechat
SDK文档

配置

微信配置

微信配置文档

实战整理

项目情况

框架:kratos(微服务)
SDK:https://github.com/silenceper/wechat

应用场景

小程序开发使用第三方平台服务商方式代商户做小程序开发与管理

服务依赖

初始化开放平台openPlatform 服务


// newRedisClient 实例化Redis
func newRedisClient() *redis.Client {
	client := redis.NewClient(&redis.Options{
		Addr:     "conf.Redis.Addr",
		Username: "conf.Redis.Username",
		Password: "conf.Redis.Password",
		DB:       0,
	})
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
	defer cancel()
	err := client.Ping(ctx).Err()
	if err != nil {
		log.Fatalf("redis connect error: %v", err)
	}
	return client
}

func newRedisClient() *openplatform.OpenPlatform {
	wc := wechat.NewWechat()
	redisCache := cache.NewRedis(context.Background(), &cache.RedisOpts{})
	// SDK 的redis缓存方案连接配置不支持设置用户名,使用 SetConn 实现自定义连接设置
	redisClient := newRedisClient()
	redisCache.SetConn(redisClient)
	openPlatformConfig := &opConfig.Config{
		AppID:          "conf.Wechat.AppId",
		AppSecret:      "conf.Wechat.AppSecret",
		Token:          "conf.Wechat.Token",
		EncodingAESKey: "conf.Wechat.EncodingAesKey",
		Cache:          redisCache,
	}
	openPlatform := wc.GetOpenPlatform(openPlatformConfig)
	return openPlatform
}

用户故事一:小程序授权

商户作为Saas用户登录平台后通过扫描授权绑定商户小程序,后续开发流程由平台方支持

数据结构

CREATE TABLE `tenant_application` (
  `tenant_id` char(21) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商户ID',
  `app_type` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '应用类型(WECHAT_MINIPROGRAM:微信小程序)',
  `app_id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '应用ID',
  `name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '应用名称',
  `headimg_url` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '应用头像链接',
  `auth_refresh_token` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '应用刷新令牌',
  `is_authed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否授权',
  `auth_at` datetime NOT NULL COMMENT '最近授权时间',
  `unauth_at` datetime NOT NULL COMMENT '最近取消授权时间',
  `meta_info` json DEFAULT NULL COMMENT '元数据(保留数据)',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`tenant_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='商户应用'

第三方平台配置

Saas平台接入商户代小程序开发解决方案_第1张图片
微信开放平台登录入口

  1. 配置授权事件接收接口
  2. 处理ticket获取并缓存平台token

demo:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/go-redis/redis/v8"
	"github.com/silenceper/wechat/v2"
	"github.com/silenceper/wechat/v2/cache"
	"github.com/silenceper/wechat/v2/officialaccount/message"
	"github.com/silenceper/wechat/v2/openplatform"
	opConfig "github.com/silenceper/wechat/v2/openplatform/config"
)

// logx 微信回调接口调试日志
func logx(format string, v ...any) {
	file, err := os.OpenFile("/tmp/wechat.log", os.O_CREATE|os.O_APPEND|os.O_RDWR, os.ModePerm)
	if err != nil {
		return
	}
	defer file.Close()
	log.SetOutput(file)
	log.SetFlags(log.Llongfile)
	log.Printf(format+"\n", v)
}

// handleWechatAuthorMessage 处理微信授权消息
func handleWechatAuthorMessage(openPlatform *openplatform.OpenPlatform, msg *message.MixMessage) error {
	switch msg.InfoType {
	case message.InfoTypeVerifyTicket:
		// TODO: 判断 ComponentAccessToken 未过期时直接返回
		// TODO: 判断 ComponentVerifyTicket 缓存存在时直接调用 SetComponentAccessToken
		// TODO: 缓存 ComponentVerifyTicket
		logx("ComponentVerifyTicket: %s", msg.ComponentVerifyTicket)
		// 接收授权信息:通过 ComponentVerifyTicket 获取并缓存 ComponentAccessToken
		openPlatform.SetComponentAccessToken(msg.ComponentVerifyTicket)
		token, err := openPlatform.GetComponentAccessToken()
		if err != nil {
			logx("GetComponentAccessToken err: %v", err)
		} else {
			logx("GetComponentAccessToken token: %s;", token)
		}
	case message.InfoTypeAuthorized:
		logx("AuthCode: %s", msg.AuthCode)
		// 授权通知 获取权限令牌,并保存刷新令牌等信息
		authBaseInfo, err := openPlatform.QueryAuthCode(msg.AuthCode)
		if err != nil {
			logx("QueryAuthCode err: %v", err)
		} else {
			logx("QueryAuthCode authBaseInfo: %v;", authBaseInfo)
		}
		// TODO:收集授权待保存数据
		// TODO:缓存权限令牌
		// 获取授权账号详情
		//authorizerInfo, authorizationInfo, err := u.OpenPlatform.GetAuthrInfo(authBaseInfo.Appid)
		// TODO:收集账号详情待保存数据
		// TODO:保存应用信息
	case message.InfoTypeUpdateAuthorized:
		// 更新授权
		// TODO:更新应用信息
	case message.InfoTypeUnauthorized:
		// 取消授权
		// TODO:更新应用状态为未授权
	}
	return nil
}

func main() {
	// 授权事件回调接口
	http.HandleFunc("/callback/auth", func(writer http.ResponseWriter, request *http.Request) {
		openPlatform := getOpenPlatform()
		// 传入request和responseWriter
		server := openPlatform.GetServer(request, writer)
		//设置接收消息的处理方法
		server.SetMessageHandler(func(msg *message.MixMessage) *message.Reply {
			err := handleWechatAuthorMessage(openPlatform., msg)
			if err != nil {
				log.Fatalf("handleWechatAuthorMessage failed err: %s", err)
			}
			return nil
		})
		// 调试阶段:关闭校验
		server.SkipValidate(true)
		//处理消息接收以及回复
		err := server.Serve()
		if err != nil {
			log.Fatalf("server.Serve() err: %s", err)
			return
		}
		server.String("success")
		return
	})
	//建立监听
	err := http.ListenAndServe("127.0.0.1:8080", nil)
	if err != nil {
		fmt.Println("网络错误")
		return
	}
}

商户授权小程序

  1. 使用平台token获取授权页面链接 文档说明
  2. 商户小程序管理员扫描授权页面二维码进行授权
  3. 回调后台页面提交授权数据服务端继续获取授权信息进行授权绑定
    1. 获取刷新令牌与APPID等授权数据
    2. 禁止小程序直接重复授权绑定(否则导致旧账号的小程序刷新令牌过期会导致一系列问题)
    3. 缓存商户小程序权限令牌(定时过期,可用刷新令牌重新获取)
    4. 持久化保存商户小程序刷新令牌(重新授权前长期有效)

demo:

package main

import (
	"encoding/json"
	"fmt"
	"github.com/silenceper/wechat/v2/util"
	"net/url"
)

// getAuthorizePageUrl 获取小程序授权页面链接
// 重新封装:
// SDK没有做异常捕获,配置出现问题时没有错误提示,比如component_access_token过期。导致的结果是预授权码为空字符串
func getAuthorizePageUrl(returnUrl string, authType int) (string, error) {
	openPlatform := getOpenPlatform()
	cat, err := openPlatform.GetComponentAccessToken()
	if err != nil {
		return "", err
	}
	req := map[string]string{
		"component_appid": openPlatform.AppID,
	}
	uri := fmt.Sprintf(getPreCodeURL, cat)
	body, err := util.PostJSON(uri, req)
	if err != nil {
		return "", err
	}
	var ret struct {
		PreCode string `json:"pre_auth_code"`
	}
	if err := json.Unmarshal(body, &ret); err != nil {
		return "", err
	}
	if err != nil {
		return "", err
	}
	code := ret.PreCode
	// 预授权码获取失败
	if code == "" {
		fmt.Printf("预授权码获取失败 body: %s\n", body)
		return "", fmt.Errorf("预授权码获取失败")
	}
	return fmt.Sprintf(componentLoginURL, openPlatform.AppID, code, url.QueryEscape(returnUrl), authType, ""), nil
}

// 其余操作由SDK提供

商户更换小程序

  1. 与商户授权小程序一致流程直接进行覆盖授权绑定

切换商户使用同一小程序

  1. 【优先级高】(不做限制容易引发连带bug,如出现过期的刷新令牌)授权已授权的小程序时提示小程序已绑定其他账号
  2. 【优先级低】提供平台单方面解除小程序授权功能(直接清除授权数据)

用户故事二:代小程序开发(暂以命令行工具形式开发)

接口详情见文首微信官方文档

数据结构(是否做版本管理)

草稿箱管理

【优先级低】(可在微信开放平台后台操作)使用平台token提交草稿箱代码至模板库

代码管理

  1. 使用小程序token指定代码模板上传代码
  2. 使用小程序token提交代码审核
  3. 【优先级低】(可主动查询审核状态)接收处理审核通知回调数据
  4. 使用小程序版本查看审核状态
  5. 发布小程序

更新代码模板

解决方案:(待分析)

  • 手动触发小程序批量上传(更新)代码并/提交审核/发布

  • 定期(如每天)自动检查代码模板更新时自动触发批量上传(更新)代码并/提交审核/发布

用户故事三:代小程序登录

获取小程序端提交参数

  1. app_id 需要在为小程序上传代码时设置ext_json(里指定extappid),小程序从ext_json获取自身appid并在登录时提交
  2. js_code
  3. phone_code

获取小程序信息

使用app_id通过tenant服务grpc获取刷新令牌与tenant_id

获取开放账号信息

通过js_code获取session_key,open_id,union_id

获取用户手机号

  1. 获取小程序实例
  2. 设置小程序刷新令牌
  3. 获取访问令牌(令牌失效时通过刷新令牌更新)
  4. 通过令牌与phone_code获取手机号

注册用户

  1. 获取fan粉丝数据
  2. 获取用户数据

获取登录态

使用fan_id和session_key生成jwt_token

前端登录鉴权

  1. 创建前端authzx
  2. server包中的http添加一个前端鉴权中间件(与后端鉴权区分开)
  3. 鉴权中间件白名单区分前后端路由

用户故事四:代小程序支付(用于账单支付)

方案选定:

直连模式

产品介绍-小程序支付 | 微信支付商户平台文档中心

信息、资金流:微信支付—>直连商户

服务商模式(初定)

产品介绍-小程序支付 | 微信支付服务商平台文档中心

Saas平台接入商户代小程序开发解决方案_第2张图片—— 信息流 —— 资金流

目前服务商的社交载体只能是公众号,服务商可通过公众平台完成公众号注册申请。

前期准备

  1. 平台商户号申请成为服务商商户号
  2. 注册微信公众号绑定商户ID

可解决的问题:

  1. 后端支付相关接口无需子商户每一个的key与证书(只需要服务商平台商户的相关配置,直连模式需要每一个子商户的相关配置)
  2. 子商户只需要创建账号,服务商平台商户添加子商户,商户再做审核通过即可(无需做开发配置)

需手动操作步骤:

  1. 商家注册子商户号
  2. 商家小程序绑定商家商户号
  3. 服务商新增子商户号

支付功能服务划分与数据结构

服务划分方案选定:
  • 以包形式封装通过import调用支付能力在各自服务处理
    • 简单维护
  • 独立支付服务(或者直接使用财务服务)通过grpc通知相关服务处理(初定)
    • 统一支付相关接口
    • 与业务解耦
数据结构设计

子商户

tenant_application表添加商户信息

`mch_id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '商户ID',

支付订单

CREATE TABLE `pay_order` (
  `id` char(21) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '唯一ID',
  `pay_type` enum("WECHAT") CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '支付方式(WECHAT:微信)',
  `pay_mode` enum("WECHAT_PARTNER","WECHAT") CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '支付对接模式(WECHAT_PARTNER:微信服务商模式;WECHAT:微信直通模式)',
  `trade_type` enum("JSAPI","NATIVE","APP","MICROPAY","MWEB","FACEPAY") CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '交易类型(JSAPI:公众号支付;NATIVE:扫码支付;APP:APP支付;MICROPAY:付款码支付;MWEB:H5支付;FACEPAY:刷脸支付)',
  `tenant_id` char(21) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '租户ID',
  `app_id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '租户应用ID(小程序APPID)',
  `mch_id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '租户商户ID',
  `out_trade_no` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '交易订单号',
  `open_id` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '支付者open_id',
  `subject_type` enum("BILL") CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '交易主体类型(BILL:租房月账单)',
  `subject_id` char(21) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '交易主体ID(BILL:填收款单ID?)',
  `amount` decimal(18,2) NOT NULL COMMENT '金额',
  `state` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '订单交易状态(SUCCESS:支付成功;REFUND:转入退款;NOTPAY:未支付;CLOSED:已关闭;REVOKED:已撤销【仅付款码支付会返回】;USERPAYING:用户支付中【仅付款码支付会返回】;PAYERROR:支付失败【仅付款码支付会返回】)',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '交易主体描述',
  `meta_info` json DEFAULT NULL COMMENT '元数据(保留数据)',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `out_trade_no` (`mch_id`,`out_trade_no`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='支付订单'

接入微信SDK

GO_官方SDK

支付客户端服务注入

预下单

  1. 实例化服务商
  2. 获取商户appID和mchID
  3. 调用接口获取prepay_id(前端小程序下单使用)

demo

package main
import (
    "context"
    "log"
    "time"
    "github.com/wechatpay-apiv3/wechatpay-go/core"
    "github.com/wechatpay-apiv3/wechatpay-go/core/option"
    partnerJsapi "github.com/wechatpay-apiv3/wechatpay-go/services/partnerpayments/jsapi"
    "github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
    "github.com/wechatpay-apiv3/wechatpay-go/utils"
)
func getClient(ctx context.Context) (client *core.Client, err error) {
    var (
        mchID                      string = "190000****"                               // 商户号
        mchCertificateSerialNumber string = "3775************************************" // 商户证书序列号
        mchAPIv3Key                string = "2ab9****************************"         // 商户APIv3密钥
    )
    // 使用 utils 提供的函数从本地文件中加载商户私钥,商户私钥会用来生成请求的签名
    mchPrivateKey, err := utils.LoadPrivateKeyWithPath("./apiclient_key.pem")
    if err != nil {
        log.Print("load merchant private key error")
        return
    }
    // 使用商户私钥等初始化 client,并使它具有自动定时获取微信支付平台证书的能力
    opts := []core.ClientOption{
        option.WithWechatPayAutoAuthCipher(mchID, mchCertificateSerialNumber, mchPrivateKey, mchAPIv3Key),
    }
    client, err = core.NewClient(ctx, opts...)
    if err != nil {
        log.Printf("new wechat pay client err:%s", err)
    }
    return
}
func partnerPrepay(ctx context.Context) {
    client, err := getClient(ctx)
    if err != nil {
        log.Print(err.Error())
        return
    }
    svc := partnerJsapi.JsapiApiService{Client: client}
    resp, result, err := svc.Prepay(ctx,
        partnerJsapi.PrepayRequest{
            SpAppid:       core.String("wxd678efh567hg6787"),
            SpMchid:       core.String("1230000109"),
            SubAppid:      core.String("wxd678efh567hg6787"),
            SubMchid:      core.String("1230000109"),
            Description:   core.String("Image形象店-深圳腾大-QQ公仔"),
            OutTradeNo:    core.String("1217752501201407033233368018"),
            TimeExpire:    core.Time(time.Now()),
            Attach:        core.String("自定义数据说明"),
            NotifyUrl:     core.String("https://www.weixin.qq.com/wxpay/pay.php"),
            GoodsTag:      core.String("WXG"),
            LimitPay:      []string{"LimitPay_example"},
            SupportFapiao: core.Bool(false),
            Amount: &partnerJsapi.Amount{
                Currency: core.String("CNY"),
                Total:    core.Int64(100),
            },
            Payer: &partnerJsapi.Payer{
                SpOpenid:  core.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
                SubOpenid: core.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
            },
            Detail: &partnerJsapi.Detail{
                CostPrice: core.Int64(608800),
                GoodsDetail: []partnerJsapi.GoodsDetail{partnerJsapi.GoodsDetail{
                    GoodsName:        core.String("iPhoneX 256G"),
                    MerchantGoodsId:  core.String("ABC"),
                    Quantity:         core.Int64(1),
                    UnitPrice:        core.Int64(828800),
                    WechatpayGoodsId: core.String("1001"),
                }},
                InvoiceId: core.String("wx123"),
            },
            SceneInfo: &partnerJsapi.SceneInfo{
                DeviceId:      core.String("013467007045764"),
                PayerClientIp: core.String("14.23.150.211"),
                StoreInfo: &partnerJsapi.StoreInfo{
                    Address:  core.String("广东省深圳市南山区科技中一道10000号"),
                    AreaCode: core.String("440305"),
                    Id:       core.String("0001"),
                    Name:     core.String("腾讯大厦分店"),
                },
            },
            SettleInfo: &partnerJsapi.SettleInfo{
                ProfitSharing: core.Bool(false),
            },
        },
    )
    if err != nil {
        // 处理错误
        log.Printf("call Prepay err:%s", err)
    } else {
        // 处理返回结果
        log.Printf("status=%d resp=%s", result.Response.StatusCode, resp)
    }
}
func prepay(ctx context.Context) {
    client, err := getClient(ctx)
    if err != nil {
        log.Print(err.Error())
        return
    }
    svc := jsapi.JsapiApiService{Client: client}
    // 得到prepay_id,以及调起支付所需的参数和签名
    resp, result, err := svc.PrepayWithRequestPayment(ctx,
        jsapi.PrepayRequest{
            Appid:       core.String("wxd678efh567hg6787"),
            Mchid:       core.String("1900009191"),
            Description: core.String("Image形象店-深圳腾大-QQ公仔"),
            OutTradeNo:  core.String("1217752501201407033233368018"),
            Attach:      core.String("自定义数据说明"),
            NotifyUrl:   core.String("https://www.weixin.qq.com/wxpay/pay.php"),
            Amount: &jsapi.Amount{
                Total: core.Int64(100),
            },
            Payer: &jsapi.Payer{
                Openid: core.String("oUpF8uMuAJO_M2pxb1Q9zNjWeS6o"),
            },
        },
    )
    if err == nil {
        log.Println(resp)
        log.Println(result)
    } else {
        log.Println(err)
    }
}
func main() {
    ctx := context.Background()
    partnerPrepay(ctx)
    prepay(ctx)
}

支付结果异步通知处理

  1. 参数解密
  2. 验签
  3. 数据处理

支付结果主动查询

  1. 通过订单标识查询接口
  2. 数据处理

支付结果业务逻辑处理

  1. 事务(幂等)
  2. 根据支付结构处理订单状态

【优先级低】【待预研】特约商户进件

官方需求背景
● 人工录入大量商户资料,耗时耗力。
● 商户对标准费率不满意,无法说服商户先签约再帮其调整费率。

用户故事五:代小程序消息推送(用于账单推送)

待预研

用户故事六:【优先级低】代小程序注册

待预研

你可能感兴趣的:(实战方案,微信小程序,golang)