feat(wechat): 添加微信小程序二维码生成功能

实现微信小程序二维码生成接口,包括获取access_token和生成二维码的逻辑
添加路由配置和handler处理函数,支持返回Base64编码的二维码图片
This commit is contained in:
邹方成 2025-10-18 10:40:41 +08:00
parent 713b0e723a
commit cd2093f594
4 changed files with 361 additions and 0 deletions

View File

@ -0,0 +1,71 @@
package wechat
import (
"encoding/base64"
"fmt"
"net/http"
"mini-chat/internal/code"
"mini-chat/internal/pkg/core"
"mini-chat/internal/pkg/wechat"
)
type generateQRCodeRequest struct {
AppID string `json:"app_id" binding:"required"` // 微信小程序 AppID
AppSecret string `json:"app_secret" binding:"required"` // 微信小程序 AppSecret
Path string `json:"path" binding:"required"` // 小程序页面路径
}
type generateQRCodeResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
Data string `json:"data"` // Base64 编码的图片数据
}
// GenerateQRCode 生成微信小程序二维码
// @Summary 生成微信小程序二维码
// @Description 根据 AppID、AppSecret 和页面路径生成微信小程序二维码
// @Tags 微信
// @Accept json
// @Produce json
// @Param request body generateQRCodeRequest true "请求参数"
// @Success 200 {object} generateQRCodeResponse
// @Failure 400 {object} code.Failure
// @Failure 500 {object} code.Failure
// @Router /api/wechat/qrcode [post]
func (h *handler) GenerateQRCode() core.HandlerFunc {
return func(ctx core.Context) {
req := new(generateQRCodeRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
code.Text(code.ParamBindError),
))
return
}
// 调用微信小程序二维码生成函数
imageData, err := wechat.GenerateQRCode(req.AppID, req.AppSecret, req.Path)
if err != nil {
h.logger.Error(fmt.Sprintf("生成微信小程序二维码失败: %s", err.Error()))
ctx.AbortWithError(core.Error(
http.StatusInternalServerError,
code.ServerError,
err.Error(),
))
return
}
// 将图片数据转换为 Base64 编码
base64Data := base64.StdEncoding.EncodeToString(imageData)
res := &generateQRCodeResponse{
Success: true,
Message: "二维码生成成功",
Data: base64Data,
}
ctx.Payload(res)
}
}

View File

@ -0,0 +1,21 @@
package wechat
import (
"mini-chat/internal/pkg/logger"
"mini-chat/internal/repository/mysql"
"mini-chat/internal/repository/mysql/dao"
)
type handler struct {
logger logger.CustomLogger
writeDB *dao.Query
readDB *dao.Query
}
func New(logger logger.CustomLogger, db mysql.Repo) *handler {
return &handler{
logger: logger,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
}
}

View File

@ -0,0 +1,261 @@
package wechat
import (
"bytes"
"encoding/json"
"fmt"
"mini-chat/internal/pkg/core"
"mini-chat/internal/pkg/httpclient"
"net/http"
"sync"
"time"
)
// AccessTokenRequest 获取 access_token 请求参数
type AccessTokenRequest struct {
AppID string `json:"appid"`
AppSecret string `json:"secret"`
GrantType string `json:"grant_type"`
}
// AccessTokenResponse 获取 access_token 响应
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// TokenCache access_token 缓存结构
type TokenCache struct {
Token string
ExpiresAt time.Time
mutex sync.RWMutex
}
// 全局 token 缓存
var globalTokenCache = &TokenCache{}
// WechatConfig 微信配置
type WechatConfig struct {
AppID string
AppSecret string
}
// QRCodeRequest 获取小程序码请求参数
type QRCodeRequest struct {
Path string `json:"path"`
Width int `json:"width,omitempty"`
AutoColor bool `json:"auto_color,omitempty"`
LineColor *LineColor `json:"line_color,omitempty"`
IsHyaline bool `json:"is_hyaline,omitempty"`
EnvVersion string `json:"release,omitempty"`
}
type LineColor struct {
R int `json:"r"`
G int `json:"g"`
B int `json:"b"`
}
// QRCodeResponse 获取小程序码响应
type QRCodeResponse struct {
Buffer []byte `json:"-"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// QRCodeError 微信API错误响应
type QRCodeError struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
func (e *QRCodeError) Error() string {
return fmt.Sprintf("微信API错误: errcode=%d, errmsg=%s", e.ErrCode, e.ErrMsg)
}
// GetAccessToken 获取微信 access_token
func GetAccessToken(ctx core.StdContext, config *WechatConfig) (string, error) {
if config == nil {
return "", fmt.Errorf("微信配置不能为空")
}
if config.AppID == "" {
return "", fmt.Errorf("AppID 不能为空")
}
if config.AppSecret == "" {
return "", fmt.Errorf("AppSecret 不能为空")
}
// 检查缓存
globalTokenCache.mutex.RLock()
if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) {
token := globalTokenCache.Token
globalTokenCache.mutex.RUnlock()
return token, nil
}
globalTokenCache.mutex.RUnlock()
// 缓存过期或不存在,重新获取
globalTokenCache.mutex.Lock()
defer globalTokenCache.mutex.Unlock()
// 双重检查,防止并发情况下重复请求
if globalTokenCache.Token != "" && time.Now().Before(globalTokenCache.ExpiresAt) {
return globalTokenCache.Token, nil
}
// 构建请求URL
url := "https://api.weixin.qq.com/cgi-bin/token"
// 发送HTTP请求
client := httpclient.GetHttpClientWithContext(ctx)
resp, err := client.R().
SetQueryParams(map[string]string{
"grant_type": "client_credential",
"appid": config.AppID,
"secret": config.AppSecret,
}).
Get(url)
if err != nil {
return "", fmt.Errorf("获取access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
}
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second)
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = expiresAt
return tokenResp.AccessToken, nil
}
// GetQRCodeWithConfig 通过微信配置获取小程序码自动获取access_token
// config: 微信配置AppID 和 AppSecret
// request: 请求参数
// 返回: 成功时返回图片二进制数据,失败时返回错误
func GetQRCodeWithConfig(ctx core.StdContext, config *WechatConfig, request *QRCodeRequest) (*QRCodeResponse, error) {
// 自动获取 access_token
accessToken, err := GetAccessToken(ctx, config)
if err != nil {
return nil, fmt.Errorf("获取access_token失败: %v", err)
}
return GetQRCode(ctx, accessToken, request)
}
// GetQRCode 获取小程序码
// accessToken: 接口调用凭证
// request: 请求参数
// 返回: 成功时返回图片二进制数据,失败时返回错误
func GetQRCode(ctx core.StdContext, accessToken string, request *QRCodeRequest) (*QRCodeResponse, error) {
if accessToken == "" {
return nil, fmt.Errorf("access_token 不能为空")
}
if request == nil {
return nil, fmt.Errorf("请求参数不能为空")
}
if request.Path == "" {
return nil, fmt.Errorf("path 参数不能为空")
}
if len(request.Path) > 1024 {
return nil, fmt.Errorf("path 参数长度不能超过1024个字符")
}
// 设置默认值
if request.Width == 0 {
request.Width = 430
}
if request.Width < 280 {
request.Width = 280
}
if request.Width > 1280 {
request.Width = 1280
}
url := fmt.Sprintf("https://api.weixin.qq.com/wxa/getwxacode?access_token=%s", accessToken)
requestBody, err := json.Marshal(request)
if err != nil {
return nil, fmt.Errorf("序列化请求参数失败: %v", err)
}
client := httpclient.GetHttpClientWithContext(ctx)
resp, err := client.R().
SetHeader("Content-Type", "application/json").
SetBody(requestBody).
Post(url)
if err != nil {
return nil, fmt.Errorf("发送HTTP请求失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
responseBody := resp.Body()
if isJSONResponse(responseBody) {
var qrError QRCodeError
if err := json.Unmarshal(responseBody, &qrError); err != nil {
return nil, fmt.Errorf("解析错误响应失败: %v", err)
}
return nil, &qrError
}
return &QRCodeResponse{
Buffer: responseBody,
}, nil
}
func isJSONResponse(data []byte) bool {
if len(data) == 0 {
return false
}
trimmed := bytes.TrimSpace(data)
return len(trimmed) > 0 && trimmed[0] == '{'
}
// GenerateQRCode 最简化的小程序码生成接口
func GenerateQRCode(appID, appSecret, path string) ([]byte, error) {
ctx := core.StdContext{}
config := &WechatConfig{
AppID: appID,
AppSecret: appSecret,
}
request := &QRCodeRequest{
Path: path,
}
response, err := GetQRCodeWithConfig(ctx, config, request)
if err != nil {
return nil, err
}
return response.Buffer, nil
}

View File

@ -7,6 +7,7 @@ import (
"mini-chat/internal/api/keyword"
"mini-chat/internal/api/message"
"mini-chat/internal/api/upload"
"mini-chat/internal/api/wechat"
"mini-chat/internal/cron"
"mini-chat/internal/dblogger"
"mini-chat/internal/pkg/core"
@ -47,6 +48,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo, cron cron.Server) (co
uploadHandler := upload.New(logger, db)
keywordHandler := keyword.New(logger, db)
messageHandler := message.New(logger, db)
wechatHandler := wechat.New(logger, db)
// 管理端非认证接口路由组
adminNonAuthApiRouter := mux.Group("/admin")
@ -66,6 +68,12 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo, cron cron.Server) (co
appNonAuthApiRouter.POST("/send_message", messageHandler.UserSendMessage()) // 发送消息
}
// 微信 API 路由组
wechatApiRouter := mux.Group("/api/wechat")
{
wechatApiRouter.POST("/qrcode", wechatHandler.GenerateQRCode()) // 生成微信小程序二维码(返回 Base64
}
// 管理端认证接口路由组
adminAuthApiRouter := mux.Group("/admin", core.WrapAuthHandler(interceptorHandler.AdminTokenAuthVerify))
{