From cd2093f594976978bc31da6c0961a8baed60ef52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=96=B9=E6=88=90?= Date: Sat, 18 Oct 2025 10:40:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(wechat):=20=E6=B7=BB=E5=8A=A0=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现微信小程序二维码生成接口,包括获取access_token和生成二维码的逻辑 添加路由配置和handler处理函数,支持返回Base64编码的二维码图片 --- internal/api/wechat/generate_qrcode.go | 71 +++++++ internal/api/wechat/wechat.go | 21 ++ internal/pkg/wechat/qrcode.go | 261 +++++++++++++++++++++++++ internal/router/router.go | 8 + 4 files changed, 361 insertions(+) create mode 100644 internal/api/wechat/generate_qrcode.go create mode 100644 internal/api/wechat/wechat.go create mode 100644 internal/pkg/wechat/qrcode.go diff --git a/internal/api/wechat/generate_qrcode.go b/internal/api/wechat/generate_qrcode.go new file mode 100644 index 0000000..fdc6fd3 --- /dev/null +++ b/internal/api/wechat/generate_qrcode.go @@ -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) + } +} diff --git a/internal/api/wechat/wechat.go b/internal/api/wechat/wechat.go new file mode 100644 index 0000000..c720313 --- /dev/null +++ b/internal/api/wechat/wechat.go @@ -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()), + } +} \ No newline at end of file diff --git a/internal/pkg/wechat/qrcode.go b/internal/pkg/wechat/qrcode.go new file mode 100644 index 0000000..bc5f506 --- /dev/null +++ b/internal/pkg/wechat/qrcode.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index 4857003..97020f2 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)) {