需求背景:
基于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='商户应用'
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
}
}
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提供
接口详情见文首微信官方文档
【优先级低】(可在微信开放平台后台操作)使用平台token提交草稿箱代码至模板库
解决方案:(待分析)
手动触发小程序批量上传(更新)代码并/提交审核/发布
定期(如每天)自动检查代码模板更新时自动触发批量上传(更新)代码并/提交审核/发布
使用app_id通过tenant服务grpc获取刷新令牌与tenant_id
通过js_code获取session_key,open_id,union_id
使用fan_id和session_key生成jwt_token
产品介绍-小程序支付 | 微信支付商户平台文档中心
信息、资金流:微信支付—>直连商户
产品介绍-小程序支付 | 微信支付服务商平台文档中心
目前服务商的社交载体只能是公众号,服务商可通过公众平台完成公众号注册申请。
前期准备
可解决的问题:
需手动操作步骤:
子商户
在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='支付订单'
GO_官方SDK
支付客户端服务注入
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)
}
官方需求背景
● 人工录入大量商户资料,耗时耗力。
● 商户对标准费率不满意,无法说服商户先签约再帮其调整费率。
待预研
待预研