feat(wechat): 添加微信小程序二维码生成功能
实现微信小程序二维码生成接口,包括获取access_token和生成二维码的逻辑 添加路由配置和handler处理函数,支持返回Base64编码的二维码图片
This commit is contained in:
parent
713b0e723a
commit
cd2093f594
71
internal/api/wechat/generate_qrcode.go
Normal file
71
internal/api/wechat/generate_qrcode.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
internal/api/wechat/wechat.go
Normal file
21
internal/api/wechat/wechat.go
Normal 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()),
|
||||
}
|
||||
}
|
||||
261
internal/pkg/wechat/qrcode.go
Normal file
261
internal/pkg/wechat/qrcode.go
Normal 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
|
||||
}
|
||||
@ -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))
|
||||
{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user