微信支付接入教程(独立开发者实战版):JSAPI支付接入指南,附Golang实现完整项目源码

文章目录
微信公众号二维码
本文已同步发布到微信公众号「人言兑
👈 扫描二维码关注,第一时间获取更新!

作为一名独立开发者,接入微信支付可能是你项目变现的关键一步。但面对复杂的文档、各种证书、密钥和回调机制,很多人会在第一步就卡住。

本教程基于微信支付 APIv3官方 Go SDK,从零开始带你完成 JSAPI 支付的完整接入。我们将使用最新的微信支付公钥模式(适用于 2023 年后新申请的商户号),相比传统的平台证书模式更加简洁。

你将学到:

  • JSAPI 支付的完整业务流程
  • 如何获取用户 OpenID(微信授权)
  • 如何创建订单、调起支付、处理回调
  • 如何使用微信支付公钥进行验签
  • 完整的可运行代码示例

封面图片

前置要求:

  • 基础的 Go 语言知识
  • 已注册微信支付商户号(支持公钥模式)
  • 已注册微信公众号/服务号(已认证)
  • 一个外网可访问的域名

在开始之前,请确保已完成以下准备工作:

👉 点击查看《微信支付系列教程》文章目录

在《微信支付系列教程》系列文章中,我们将详细介绍如何从0到1在自己的网站中接入微信支付。以下是该系列文章的全部内容:

  1. 微信支付接入教程(独立开发者实战版):微信支付商户号注册全流程指南(最新版)| 如何开通微信支付收款功能
  2. 微信支付接入教程(独立开发者实战版):微信支付全部产品与功能一文搞懂
  3. 微信支付接入教程(独立开发者实战版):微信公众号服务号认证完全指南
  4. 微信支付接入教程(独立开发者实战版):商户号与APPID绑定完全指南
  5. 微信支付接入教程(独立开发者实战版):Native 支付完整接入指南
  6. 微信支付接入教程(独立开发者实战版):JSAPI 支付完整接入指南

微信支付JSAPI支付产品概述

什么是 JSAPI 支付?

JSAPI 支付是微信支付为微信内置浏览器提供的支付能力。用户在微信客户端内访问商户的网页,选购商品后可直接在微信内完成支付,无需跳出微信。

根据官方文档定义:

JSAPI支付,提供商户在微信客户端内部浏览器网页中使用微信支付收款的能力。

核心特点

特性说明
环境限制必须在微信内置浏览器中使用
用户标识需要获取用户的 OpenID
支付流程用户点击支付 → 调起微信收银台 → 输入密码 → 支付完成
回调机制支持前端回调 + 后端异步通知双重确认

JSAPI支付与传统 H5 支付的区别

对比项JSAPI 支付H5 支付
使用环境微信内置浏览器外部浏览器(Safari/Chrome等)
用户标识需要 OpenID不需要
体验流畅度优(无需跳出)一般(需跳转微信)
接入复杂度一般(需授权)较高 (但需要先接入Native支付后单独申请开通)
费率相同相同

在微信以外的浏览器下单会出现一个你可能比较熟悉的页面提示:

请在微信客户端打开链接

JSAPI扫码支付与Native扫码支付的区别

  • JSAPI 扫的二维码是商品展示网页的链接,进入的是商户 H5 页面
  • Native 扫的是支付协议二维码,直接进入微信原生收银台

PC 网站推荐用 Native 支付,但也可以用 JSAPI 支付:

PC 网站方案实现方式用户体验
推荐:Native 支付展示支付二维码,用户微信扫码扫码后直接支付,体验好
可选:JSAPI 支付展示网页二维码,用户微信扫码打开网页多一步操作,但可以展示更多信息

适用场景与业务模式

根据官方文档,JSAPI 支付主要适用于以下场景:

场景一:公众号商城

用户通过公众号菜单或推文进入商户网页,选购商品后支付。

流程:

  1. 用户关注公众号,点击菜单进入商城
  2. 浏览商品,加入购物车
  3. 进入结算页,确认订单
  4. 调起微信支付,输入密码
  5. 支付成功,返回商户页面查看订单

场景二:扫码支付

用户扫描商户二维码,在微信中打开网页完成支付。

流程:

  1. 用户扫描商户收款二维码
  2. 微信自动打开商户页面
  3. 输入金额或选择商品
  4. 调起支付完成付款

场景三:社交分享

用户通过好友分享的链接进入商品页,直接购买。

官方文档提示:

用户可以通过公众号、扫一扫、分享链接等方式在微信客户端内部浏览器中打开商户页面,完成支付。

微信支付JSAPI支付接入前准备

第一步:开通 JSAPI 支付产品

开通 JSAPI 支付

  1. 登录 微信支付商户平台
  2. 进入"产品中心" → “我的产品”
  3. 找到"JSAPI 支付",点击申请开通
  4. 按提示完成开通流程

第二步:绑定 AppID

JSAPI 支付必须将商户号与公众号/小程序 AppID 绑定:

  1. 商户平台 → 产品中心 → AppID 账号管理
  2. 点击"关联 AppID"
  3. 输入公众号的 AppID(格式:wx 开头,18位字符)
  4. 前往微信公众平台确认绑定

相关阅读推荐: 商户号绑定APPID操作指南

第三步:配置支付授权目录

添加JSAPI支付的授权目录

只有配置了授权目录的网页才能调起支付:

  1. 商户平台 → 产品中心 → JSAPI 支付 → 支付授权目录
  2. 点击"添加"
  3. 填写你的域名(如 https://your-domain.com/
  4. 注意:目录必须以 / 结尾,且是完整的 URL

所有使用JS API方式发起支付请求的链接地址,都必须在当前页面所配置的支付授权目录之下。下单前需要调用【 网页授权获取用户信息 】接口获取到用户的Openid

第四步:获取开发必要参数

在开始开发前,你需要准备以下参数:

参数名称参数说明获取方式
APPID公众账号ID公众平台/开放平台查看
MCHID商户号商户平台查看
商户API证书用于API请求签名商户平台下载
微信支付公钥用于验证微信支付回调签名通过API获取或手动下载
APIv3密钥用于加密敏感数据和验证签名商户平台设置

获取路径

  1. APPID获取
    • 服务号/公众号:公众平台 -> 设置与开发 -> 账号设置 -> 注册信息 -> AppID
    • 小程序:公众平台 -> 开发与服务 -> 开发管理 -> 开发设置 -> AppID
    • 移动应用:开放平台 -> 管理中心 -> 移动应用 -> 查看详情 -> APPID
  2. 商户号(MCHID)
    • 商户平台 -> 账户中心 -> 商户信息 -> 基本账户信息 -> 商户号
  3. 获取微信支付公钥
    • 商户平台 -> 账户中心 -> API安全 -> 管理公钥
    • 下载公钥 pub_key.pem
    • 复制公钥ID
  4. 商户API证书下载
    • 商户平台 -> 账户中心 -> API安全 -> 申请API证书 -> 下载证书工具 -> 解压缩文件并按步骤操作获得请求串 -> 商户API证书获取方法及功能介绍
    • 下载后包含:证书序列号、证书私钥(apiclient_key.pem)、证书文件(apiclient_cert.pem)
    • 获取证书序列号:openssl x509 -in cert/apiclient_cert.pem -noout -serial | cut -d= -f2 | tr '[:upper:]' '[:lower:]'
  5. APIv3密钥设置
    • 商户平台 -> 账户中心 -> API安全 -> 设置APIv3密钥
    • 生成32个字符的密钥:openssl rand -hex 16
    • 设置后立即生效,请妥善保存

详细参数说明可参考: 普通商户模式开发必要参数说明

第五步:配置微信公众号服务号

服务号-账号设置-功能设置

  1. 登录 微信公众平台
  2. 设置与开发 → 账号设置 → 功能设置 → JS接口安全域名 → 设置(绑定父级域名即可,其子域名也是可用的)
  3. 设置与开发 → 账号设置 → 功能设置 → 网页授权域名 → 设置(父级域名不可替代子域名)

设置「JS接口安全域名」后,开发者可在该域名下调用微信开放的JS接口,可以设置5个,添加父级域名后子域名自动可用。

而「网页授权域名」是用户在授权给本账号时的重定向地址的域名,微信会将授权数据传给一个回调地址,该地址的域名是需要区分子域名的,最多只能配2个。

配置域名时都需要验证域名所有者身份,具体验证方法如下:

JS接口安全域名配置说明

接入JSAPI支付关于域名相关的配置操作,这里再次总结一下:

  1. 商户号后台配置「支付授权目录(需要区分子域名)」
  2. 服务号后台配置「JS接口安全域名(不需要区分子域名,用父级域名即可)」和「网页授权域名(需要区分子域名)」

开发环境搭建

项目结构

wechatpay-jsapi-demo/
├── .env                    # 环境变量配置(不提交Git)
├── .env.example            # 配置示例
├── go.mod                  # Go模块
├── main.go                 # 入口文件
├── config/
│   └── config.go           # 配置加载
├── handlers/
│   ├── auth.go             # 微信授权(获取OpenID)
│   └── payment.go          # 支付相关接口
├── templates/
│   ├── index.html          # 商品列表页
│   ├── pay.html            # 支付确认页
│   └── success.html        # 支付成功页
└── certs/
    ├── apiclient_key.pem   # 商户私钥
    └── wechatpay_pub_key.pem # 微信支付公钥

安装依赖

创建 go.mod

module wechatpay-jsapi-demo

go 1.21

require (
	github.com/gin-gonic/gin v1.9.1
	github.com/joho/godotenv v1.5.1
	github.com/wechatpay-apiv3/wechatpay-go v0.2.21
)

安装依赖:

go mod tidy

环境变量配置

创建 .env 文件:

# 服务器配置
SERVER_PORT=8080
BASE_URL=https://your-domain.com

# 微信支付配置
MCH_ID=1900000000
MCH_API_V3_KEY=YourAPIv3KeyHere
MCH_CERT_SERIAL_NO=YourCertSerialNo
MCH_PRIVATE_KEY_PATH=./certs/apiclient_key.pem

# 微信支付公钥配置(新商户必填)
WECHAT_PAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxxxx
WECHAT_PAY_PUBLIC_KEY_PATH=./certs/wechatpay_pub_key.pem

# 公众号配置
APP_ID=wx0000000000000000
APP_SECRET=YourAppSecret

# 支付配置
NOTIFY_URL=https://your-domain.com/api/pay/notify
ORDER_EXPIRE_MINUTES=30

微信支付 JSAPI 支付核心业务流程详解

根据官方文档,JSAPI 支付的整体业务流程分为五个阶段:

阶段一:商户下单

商户后端调用 JSAPI 下单接口,获取预支付 ID(prepay_id)。

关键参数说明:

参数说明注意事项
openid用户标识必须通过网页授权获取
out_trade_no商户订单号同一商户号下唯一,6-32字符
description商品描述用户账单可见,127字符以内
amount.total订单金额单位为分,不能有小数
time_expire支付结束时间RFC3339格式,如2025-02-14T10:00:00+08:00
notify_url回调地址必须外网可访问,HTTPS

prepay_id 有效期: 2小时,过期需重新下单。

阶段二:商户调起支付

前端通过微信内置对象 WeixinJSBridge 调起支付。

调起支付参数:

参数说明
appId公众号 AppID
timeStamp当前时间戳(秒级)
nonceStr随机字符串
packageprepay_id=xxx
signType固定值 RSA
paySign签名值

商户调起支付前,请确保已在商户平台配置好 JSAPI 支付授权目录(只有配置了 JSAPI 支付授权目录的网页才能调起支付)。

阶段三:用户支付

用户在微信收银台完成支付或取消支付。

前端回调结果:

err_msg含义处理方式
get_brand_wcpay_request:ok支付成功调后端查单确认
get_brand_wcpay_request:cancel用户取消展示取消页面
get_brand_wcpay_request:fail支付失败展示失败原因

重要提示:

前端回调并不保证它绝对可靠,不可只依赖前端回调判断订单支付状态,订单状态需以后端查询订单和支付成功回调通知为准。

阶段四:支付结果通知

微信支付异步通知商户支付结果。

通知特点:

  • 异步 POST 请求
  • 需要验签确保来源可信
  • 需要返回 {"code": "SUCCESS"} 表示处理成功
  • 同一笔订单可能通知多次(需幂等处理)

阶段五:订单状态流转

根据官方文档,订单状态流转如下:

生成订单 → NOTPAY(未支付)
    ↓
支付成功 → SUCCESS(支付成功)→ 可申请退款 → REFUND(转入退款)
    ↓
支付失败/超时 → CLOSED(已关闭)

终态说明:

  • SUCCESS:支付成功,可退款
  • CLOSED:已关闭,不可再支付
  • REFUND:已退款

JSAPI支付完整代码实战(Golang实现)

由于文章篇幅不易太长,完整本地可运行的demo项目代码我已打包上传,
需要完整项目源码的可以关注我的公众号「 人言兑 」,私信发送「jsapi demo」即可获取。

1. 配置加载(config/config.go)

package config

import (
	"fmt"
	"log"
	"os"
	"path/filepath"

	"github.com/joho/godotenv"
)

type Config struct {
	ServerPort             string
	BaseURL                string
	MchID                  string
	MchAPIv3Key            string
	MchCertSerialNo        string
	MchPrivateKeyPath      string
	WechatPayPublicKeyID   string
	WechatPayPublicKeyPath string
	AppID                  string
	AppSecret              string
	NotifyURL              string
	OrderExpireMinutes     int
}

var GlobalConfig *Config

func Load() *Config {
	if err := godotenv.Load(); err != nil {
		log.Println("Warning: .env file not found")
	}

	config := &Config{
		ServerPort:             getEnv("SERVER_PORT", "8080"),
		BaseURL:                getEnv("BASE_URL", ""),
		MchID:                  getEnv("MCH_ID", ""),
		MchAPIv3Key:            getEnv("MCH_API_V3_KEY", ""),
		MchCertSerialNo:        getEnv("MCH_CERT_SERIAL_NO", ""),
		MchPrivateKeyPath:      getEnv("MCH_PRIVATE_KEY_PATH", "./certs/apiclient_key.pem"),
		WechatPayPublicKeyID:   getEnv("WECHAT_PAY_PUBLIC_KEY_ID", ""),
		WechatPayPublicKeyPath: getEnv("WECHAT_PAY_PUBLIC_KEY_PATH", "./certs/wechatpay_pub_key.pem"),
		AppID:                  getEnv("APP_ID", ""),
		AppSecret:              getEnv("APP_SECRET", ""),
		NotifyURL:              getEnv("NOTIFY_URL", ""),
		OrderExpireMinutes:     getEnvAsInt("ORDER_EXPIRE_MINUTES", 30),
	}

	if err := config.validate(); err != nil {
		log.Fatalf("Config validation failed: %v", err)
	}

	config.MchPrivateKeyPath = toAbsPath(config.MchPrivateKeyPath)
	config.WechatPayPublicKeyPath = toAbsPath(config.WechatPayPublicKeyPath)

	GlobalConfig = config
	return config
}

func (c *Config) validate() error {
	required := []string{"MCH_ID", "MCH_API_V3_KEY", "MCH_CERT_SERIAL_NO",
		"WECHAT_PAY_PUBLIC_KEY_ID", "WECHAT_PAY_PUBLIC_KEY_PATH",
		"APP_ID", "APP_SECRET", "NOTIFY_URL", "BASE_URL"}

	for _, field := range required {
		if getEnv(field, "") == "" {
			return fmt.Errorf("%s is required", field)
		}
	}
	return nil
}

func toAbsPath(path string) string {
	if !filepath.IsAbs(path) {
		if abs, err := filepath.Abs(path); err == nil {
			return abs
		}
	}
	return path
}

func getEnv(key, defaultVal string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return defaultVal
}

func getEnvAsInt(key string, defaultVal int) int {
	if v := os.Getenv(key); v != "" {
		var i int
		if _, err := fmt.Sscanf(v, "%d", &i); err == nil {
			return i
		}
	}
	return defaultVal
}

2. 微信授权获取用户openid(handlers/auth.go)

package handlers

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"

	"wechatpay-jsapi-demo/config"

	"github.com/gin-gonic/gin"
)

type AuthHandler struct {
	cfg *config.Config
}

func NewAuthHandler() *AuthHandler {
	return &AuthHandler{cfg: config.GlobalConfig}
}

// Auth 发起微信授权
func (h *AuthHandler) Auth(c *gin.Context) {
	redirectURI := url.QueryEscape(h.cfg.BaseURL + "/auth/callback")
	state := "STATE123" // 生产环境应使用随机数并验证

	authURL := fmt.Sprintf(
		"https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_base&state=%s#wechat_redirect",
		h.cfg.AppID, redirectURI, state,
	)

    /*
    目前网页授权有两个 scope,分别是:snsapi_base 和 snsapi_userinfo,解释如下:

    - snsapi_base:用来获取进入页面的用户的openid的,并且是静默授权并自动跳转到回调页的。(不会弹出信息确认框,仅能获取用户 openid 等信息,无法获取昵称头像)
    - snsapi_userinfo:用来获取用户的基本信息的。但这种授权需要用户手动同意。(由于用户同意过,所以无须依赖用户关注服务号,就可在授权后获取该用户的基本信息)
    */

	c.Redirect(http.StatusFound, authURL)
}

// Callback 授权回调
func (h *AuthHandler) Callback(c *gin.Context) {
	code := c.Query("code")
	if code == "" {
		c.String(http.StatusBadRequest, "Authorization failed")
		return
	}

	openID, err := h.getOpenID(code)
	if err != nil {
		c.String(http.StatusInternalServerError, "Failed to get openid")
		return
	}

	// 存储到 cookie
	c.SetCookie("openid", openID, 3600, "/", "", false, true)

	redirect := c.DefaultQuery("redirect", "/pay")
	c.Redirect(http.StatusFound, redirect)
}

func (h *AuthHandler) getOpenID(code string) (string, error) {
	tokenURL := fmt.Sprintf(
		"https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
		h.cfg.AppID, h.cfg.AppSecret, code,
	)

	resp, err := http.Get(tokenURL)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)

	var result struct {
		OpenID  string `json:"openid"`
		ErrCode int    `json:"errcode"`
		ErrMsg  string `json:"errmsg"`
	}

	if err := json.Unmarshal(body, &result); err != nil {
		return "", err
	}

	if result.ErrCode != 0 {
		return "", fmt.Errorf("wechat error: %s", result.ErrMsg)
	}

	return result.OpenID, nil
}

// GetOpenID 从 cookie 获取
func GetOpenID(c *gin.Context) (string, error) {
	openid, err := c.Cookie("openid")
	if err != nil {
		return "", fmt.Errorf("not authorized")
	}
	return openid, nil
}

3. 支付处理(handlers/payment.go)

package handlers

import (
	"context"
	"crypto/rsa"
	"fmt"
	"log"
	"net/http"
	"time"

	"wechatpay-jsapi-demo/config"

	"github.com/gin-gonic/gin"
	"github.com/wechatpay-apiv3/wechatpay-go/core"
	"github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers"
	"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
	"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
	"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
)

type PaymentHandler struct {
	client             *core.Client
	cfg                *config.Config
	svc                jsapi.JsapiApiService
	wechatPayPublicKey *rsa.PublicKey
}

func NewPaymentHandler(client *core.Client, pubKey *rsa.PublicKey) *PaymentHandler {
	return &PaymentHandler{
		client:             client,
		cfg:                config.GlobalConfig,
		svc:                jsapi.JsapiApiService{Client: client},
		wechatPayPublicKey: pubKey,
	}
}

// Index 商品页
func (h *PaymentHandler) Index(c *gin.Context) {
	c.HTML(http.StatusOK, "index.html", nil)
}

// PayPage 支付页
func (h *PaymentHandler) PayPage(c *gin.Context) {
	openid, err := GetOpenID(c)
	if err != nil {
		redirect := url.QueryEscape("/pay")
		c.Redirect(http.StatusFound, "/auth?redirect="+redirect)
		return
	}
	c.HTML(http.StatusOK, "pay.html", gin.H{"openid": openid})
}

// CreateOrder 创建订单
func (h *PaymentHandler) CreateOrder(c *gin.Context) {
	var req struct {
		ProductID   string  `json:"product_id"`
		ProductName string  `json:"product_name"`
		Price       float64 `json:"price"` // 单位:元(实际业务中最好不要使用浮点数避免精度问题,这里只是方便演示)
		OpenID      string  `json:"openid"`
	}

	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// 金额转换:元 → 分
	amount := int64(req.Price * 100)

	outTradeNo := generateOrderNo()
	expireTime := time.Now().Add(time.Duration(h.cfg.OrderExpireMinutes) * time.Minute)

	resp, _, err := h.svc.PrepayWithRequestPayment(context.Background(),
		jsapi.PrepayRequest{
			Appid:       core.String(h.cfg.AppID),
			Mchid:       core.String(h.cfg.MchID),
			Description: core.String(req.ProductName),
			OutTradeNo:  core.String(outTradeNo),
			Attach:      core.String(req.ProductID),
			NotifyUrl:   core.String(h.cfg.NotifyURL),
			Amount: &jsapi.Amount{
				Total: core.Int64(amount),
			},
			Payer: &jsapi.Payer{
				Openid: core.String(req.OpenID),
			},
			TimeExpire: core.Time(expireTime),
		},
	)

	if err != nil {
		log.Printf("Create order failed: %v", err)
		c.JSON(http.StatusInternalServerError, gin.H{"error": "create order failed"})
		return
	}

	c.JSON(http.StatusOK, gin.H{
		"success":      true,
		"out_trade_no": outTradeNo,
		"appId":        resp.Appid,
		"timeStamp":    resp.TimeStamp,
		"nonceStr":     resp.NonceStr,
		"package":      resp.Package,
		"signType":     resp.SignType,
		"paySign":      resp.PaySign,
	})
}

// Notify 支付回调
func (h *PaymentHandler) Notify(c *gin.Context) {
	handler, err := notify.NewRSANotifyHandler(
		h.cfg.MchAPIv3Key,
		verifiers.NewSHA256WithRSAPubkeyVerifier(h.cfg.WechatPayPublicKeyID, *h.wechatPayPublicKey),
	)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL"})
		return
	}

	transaction := new(payments.Transaction)
	if _, err := handler.ParseNotifyRequest(context.Background(), c.Request, transaction); err != nil {
		c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL"})
		return
	}

	// 处理业务逻辑
	if transaction.TradeState != nil && *transaction.TradeState == "SUCCESS" {
		log.Printf("Payment success: %s, amount: %d",
			*transaction.OutTradeNo, *transaction.Amount.Total)
		// TODO: 更新订单状态(注意幂等性)
	}

	c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
}

func generateOrderNo() string {
	return fmt.Sprintf("D%s%06d",
		time.Now().Format("20060102150405"),
		time.Now().Nanosecond()%1000000)
}

4. 主入口(main.go)

package main

import (
	"context"
	"fmt"
	"log"

	"wechatpay-jsapi-demo/config"
	"wechatpay-jsapi-demo/handlers"

	"github.com/gin-gonic/gin"
	"github.com/wechatpay-apiv3/wechatpay-go/core"
	"github.com/wechatpay-apiv3/wechatpay-go/core/option"
	"github.com/wechatpay-apiv3/wechatpay-go/utils"
)

func main() {
	cfg := config.Load()

	// 加载证书
	mchPrivateKey, err := utils.LoadPrivateKeyWithPath(cfg.MchPrivateKeyPath)
	if err != nil {
		log.Fatalf("load private key failed: %v", err)
	}

	wechatPayPublicKey, err := utils.LoadPublicKeyWithPath(cfg.WechatPayPublicKeyPath)
	if err != nil {
		log.Fatalf("load public key failed: %v", err)
	}

	// 使用公钥模式初始化客户端
	opts := []core.ClientOption{
		option.WithWechatPayPublicKeyAuthCipher(
			cfg.MchID,
			cfg.MchCertSerialNo,
			mchPrivateKey,
			cfg.WechatPayPublicKeyID,
			wechatPayPublicKey,
		),
	}

	client, err := core.NewClient(context.Background(), opts...)
	if err != nil {
		log.Fatalf("init client failed: %v", err)
	}

	// 设置路由
	r := gin.Default()
	r.LoadHTMLGlob("templates/*")

	paymentHandler := handlers.NewPaymentHandler(client, wechatPayPublicKey)
	authHandler := handlers.NewAuthHandler()

	r.GET("/", paymentHandler.Index)
	r.GET("/pay", paymentHandler.PayPage)
	r.GET("/auth", authHandler.Auth)
	r.GET("/auth/callback", authHandler.Callback)
	r.POST("/api/pay/create", paymentHandler.CreateOrder)
	r.POST("/api/pay/notify", paymentHandler.Notify)

	r.Run(":" + cfg.ServerPort)
}

5. 前端页面

templates/index.html(商品列表):

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>商品列表</title>
    <style>
      body {
        font-family: -apple-system, sans-serif;
        max-width: 600px;
        margin: 0 auto;
        padding: 20px;
      }
      .product {
        border: 1px solid #ddd;
        padding: 15px;
        margin: 10px 0;
        border-radius: 8px;
        cursor: pointer;
      }
      .product:hover {
        border-color: #07c160;
      }
      .price {
        color: #ff6b6b;
        font-size: 20px;
        font-weight: bold;
      }
    </style>
  </head>
  <body>
    <h2>选择商品</h2>
    <div class="product" onclick="buy('P001', '测试商品-可乐', 0.01)">
      <h3>测试商品-可乐</h3>
      <p class="price">¥0.01</p>
    </div>
    <div class="product" onclick="buy('P002', '测试商品-雪碧', 0.01)">
      <h3>测试商品-雪碧</h3>
      <p class="price">¥0.01</p>
    </div>

    <script>
      function buy(id, name, price) {
        // price 单位:元
        sessionStorage.setItem("product", JSON.stringify({ id, name, price }));
        location.href = "/pay";
      }
    </script>
  </body>
</html>

templates/pay.html(支付确认):

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>确认支付</title>
    <style>
      body {
        font-family: -apple-system, sans-serif;
        max-width: 600px;
        margin: 0 auto;
        padding: 20px;
        text-align: center;
      }
      .amount {
        font-size: 48px;
        color: #333;
        margin: 30px 0;
      }
      .btn {
        background: #07c160;
        color: white;
        padding: 15px 40px;
        border: none;
        border-radius: 5px;
        font-size: 18px;
      }
    </style>
  </head>
  <body>
    <h2>确认支付</h2>
    <div id="productName"></div>
    <div class="amount">¥<span id="price">0.00</span></div>
    <button class="btn" onclick="pay()">立即支付</button>

    <script>
      const product = JSON.parse(sessionStorage.getItem("product"));
      document.getElementById("productName").textContent = product.name;
      document.getElementById("price").textContent = product.price.toFixed(2);

      async function pay() {
        const openid = "{{.openid}}";

        // 金额转换:元 → 分
        const amountInCent = Math.round(product.price * 100);

        const res = await fetch("/api/pay/create", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            product_id: product.id,
            product_name: product.name,
            price: product.price, // 实际业务中应该后端根据商品id查询价格,而不是前端指定价格,这里只是为了方便演示
            openid: openid,
          }),
        });

        const data = await res.json();
        if (!data.success) {
          alert("创建订单失败");
          return;
        }

        // 调起微信支付
        WeixinJSBridge.invoke(
          "getBrandWCPayRequest",
          {
            appId: data.appId,
            timeStamp: data.timeStamp,
            nonceStr: data.nonceStr,
            package: data.package,
            signType: data.signType,
            paySign: data.paySign,
          },
          function (res) {
            if (res.err_msg === "get_brand_wcpay_request:ok") {
              alert("支付成功");
              location.href = "/";
            } else if (res.err_msg === "get_brand_wcpay_request:cancel") {
              alert("已取消");
            } else {
              alert("支付失败: " + res.err_msg);
            }
          },
        );
      }
    </script>
  </body>
</html>

测试与调试

将服务部署到公网,或者使用 ngrok 获取临时公网域名:

测试流程:

1. 访问商品页:在微信中打开 https://your-domain/

2. 选择商品:点击商品进入支付页

3. 授权获取 OpenID:首次访问自动静默授权

4. 确认支付:显示金额 ¥0.01,点击支付

5. 调起收银台:输入支付密码

6. 查看结果:支付成功后返回商户页面

常见问题排查

问题一:redirect_uri 域名与后台配置不一致(错误码 10003)

原因:微信授权回调域名配置不正确

解决

  1. 检查微信公众平台 → 接口权限 → 网页授权获取用户基本信息
  2. 确保配置的域名与 BASE_URL 完全一致(不含 https:// 和端口号)
  3. 如果使用 ngrok,每次重启后域名会变化,需要重新配置

问题二:JSAPI 缺少参数或配置错误

排查清单

  • 商户号已开通 JSAPI 支付
  • 商户号与 AppID 已绑定
  • 已配置 JSAPI 支付授权目录
  • 页面在授权目录下访问
  • 使用微信内置浏览器

问题三:回调验签失败

原因:使用了错误的验签方式

解决

  • 确认使用的是微信支付公钥验签(NewSHA256WithRSAPubkeyVerifier
  • 检查公钥文件和公钥 ID 是否匹配

安全与生产环境建议

1. 证书与密钥安全

  • 永远不要将私钥文件提交到 Git
  • 生产环境使用密钥管理服务(如 AWS KMS、阿里云 KMS)
  • 定期轮换 APIv3 密钥

2. 回调安全

  • 必须验签,确保通知来自微信
  • 处理幂等性,同一笔订单的多次通知只处理一次
  • 回调处理逻辑尽量简单,异步处理复杂业务

3. 订单安全

  • 回调中校验返回金额与订单金额是否一致
  • 设置合理的订单超时时间(建议 30 分钟)
  • 超时未支付订单及时关闭

4. 日志与监控

  • 记录所有支付相关日志
  • 设置异常告警(如回调失败、订单状态异常)
  • 定期对账,确保每笔订单状态正确

参考文档链接

结语

通过本教程,你应该已经掌握了 JSAPI 支付的完整接入流程。从获取 OpenID、创建订单、调起支付到处理回调,每一步都有详细的代码示例和官方文档支持。

作为独立开发者,接入微信支付是项目变现的重要一步。希望这篇教程能帮助你少走弯路,快速上线支付功能。如果在接入过程中遇到问题,建议仔细阅读官方文档的错误码说明,或在微信支付开发者社区寻求帮助。

祝你开发顺利,产品大卖!


本教程基于微信支付 APIv3 和官方 Go SDK v0.2.21 编写,内容可能会随官方更新而变化,请以最新官方文档为准。


也可以看看