From 1c48fbdeaf315933d73a1b60371accfd50adfdfc Mon Sep 17 00:00:00 2001 From: nepiedg <806669289@qq.com.com> Date: Wed, 31 Dec 2025 03:55:30 +0000 Subject: [PATCH] Integrate WeChat Official Account support and update configuration - Added WeChat Official Account configuration options to .env.example and config.go for OAuth2 integration. - Updated main.go to initialize WeChat OAuth handler and register routes for handling OAuth requests. - Enhanced documentation to include references for WeChat Official Account functionality. - Updated route registration to accommodate the new OAuth handler for improved API structure. --- .env.example | 5 + cmd/api/main.go | 7 +- config/config.go | 13 ++ docs/README.md | 1 + docs/common/README.md | 4 + docs/common/wechat_official.md | 65 ++++++++ .../wechat_official/handler/oauth_handler.go | 80 +++++++++ .../wechat_official/service/oauth_service.go | 155 ++++++++++++++++++ internal/routes/routes.go | 5 + internal/routes/wechat_official_routes.go | 15 ++ 10 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 docs/common/wechat_official.md create mode 100644 internal/common/wechat_official/handler/oauth_handler.go create mode 100644 internal/common/wechat_official/service/oauth_service.go create mode 100644 internal/routes/wechat_official_routes.go diff --git a/.env.example b/.env.example index eaa7c8a..78051d3 100755 --- a/.env.example +++ b/.env.example @@ -29,3 +29,8 @@ QINIU_CDN_DOMAIN= QINIU_KEY_PREFIX=uploads/ # token 有效期(秒) QINIU_TOKEN_EXPIRE_SECONDS=300 + +# 微信公众号(网页授权 OAuth2) +WECHAT_OA_APP_ID=replace-with-oa-appid +WECHAT_OA_APP_SECRET=replace-with-oa-appsecret +WECHAT_OA_TIMEOUT_SECONDS=5 diff --git a/cmd/api/main.go b/cmd/api/main.go index 77887fa..84e9ec7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -10,6 +10,8 @@ import ( authservice "wx_service/internal/common/auth/service" qiniuhandler "wx_service/internal/common/qiniu/handler" qiniuservice "wx_service/internal/common/qiniu/service" + oahandler "wx_service/internal/common/wechat_official/handler" + oaservice "wx_service/internal/common/wechat_official/service" "wx_service/internal/database" "wx_service/internal/model" rmhandler "wx_service/internal/remove_watermark/handler" @@ -60,8 +62,11 @@ func main() { qiniuService := qiniuservice.NewQiniuService(config.AppConfig.Qiniu) uploadHandler := qiniuhandler.NewUploadHandler(qiniuService) + oaService := oaservice.NewWeChatOAService(config.AppConfig.WeChatOA) + oaOAuthHandler := oahandler.NewOAuthHandler(oaService) + // 6) 注册路由:把 URL 映射到 handler - routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, uploadHandler) + routes.Register(router, database.DB, authHandler, videoHandler, smokeHandler, uploadHandler, oaOAuthHandler) // 7) 启动监听端口 addr := ":" + config.AppConfig.Server.Port diff --git a/config/config.go b/config/config.go index b74d5e9..8f399e9 100755 --- a/config/config.go +++ b/config/config.go @@ -15,6 +15,7 @@ type Config struct { JWT JWTConfig ShortVideo ShortVideoConfig Qiniu QiniuConfig + WeChatOA WeChatOfficialConfig } type ServerConfig struct { @@ -53,6 +54,13 @@ type QiniuConfig struct { TokenExpireSeconds int } +// WeChatOfficialConfig 用于微信公众号网页授权(OAuth2)相关接口。 +type WeChatOfficialConfig struct { + AppID string + AppSecret string + RequestTimeout time.Duration +} + var AppConfig *Config func LoadConfig() { @@ -91,6 +99,11 @@ func LoadConfig() { KeyPrefix: getEnv("QINIU_KEY_PREFIX", "uploads/"), TokenExpireSeconds: getEnvAsInt("QINIU_TOKEN_EXPIRE_SECONDS", 300), }, + WeChatOA: WeChatOfficialConfig{ + AppID: getEnv("WECHAT_OA_APP_ID", ""), + AppSecret: getEnv("WECHAT_OA_APP_SECRET", ""), + RequestTimeout: time.Duration(getEnvAsInt("WECHAT_OA_TIMEOUT_SECONDS", 5)) * time.Second, + }, } } diff --git a/docs/README.md b/docs/README.md index 97ac955..46e6efe 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - `docs/common/auth.md` - `docs/common/response.md` - `docs/common/upload_qiniu.md` +- `docs/common/wechat_official.md` ## 去水印小程序 diff --git a/docs/common/README.md b/docs/common/README.md index 322a90a..78fbcc3 100644 --- a/docs/common/README.md +++ b/docs/common/README.md @@ -26,3 +26,7 @@ ## 上传(七牛直传) - `docs/common/upload_qiniu.md` + +## 微信公众号 + +- `docs/common/wechat_official.md` diff --git a/docs/common/wechat_official.md b/docs/common/wechat_official.md new file mode 100644 index 0000000..25d7e2e --- /dev/null +++ b/docs/common/wechat_official.md @@ -0,0 +1,65 @@ +# 微信公众号:code 换取 openid/用户信息 + +本接口用于微信公众号网页授权(OAuth2):前端拿到微信回调的 `code` 后,调用后端换取 `openid`,并可选获取用户信息(昵称/头像/unionid 等)。 +https://open.weixin.qq.com/connect/oauth2/authorize?appid=wx65d83a301fc84068&redirect_uri=https%3A%2F%2Fwww.baidu.com&response_type=code&scope=&state=1#wechat_redirect +https://open.weixin.qq.com/connect/oauth2/authorize?appid=wx65d83a301fc84068&redirect_uri=https%3A%2F%2Fwww.baidu.com&response_type=code&scope=SCOPE&state=STATE#wechat_redirect +## 配置 + +在 `.env` 中配置(示例见:`.env.example`): + +- `WECHAT_OA_APP_ID` +- `WECHAT_OA_APP_SECRET` +- `WECHAT_OA_TIMEOUT_SECONDS`(可选) + +## 接口 + +`POST /api/v1/wechat/official/oauth/code2user` + +说明: +- 不需要登录(无需 Bearer Token),因为该 `code` 本身来自微信网页授权流程。 + +请求体: + +```json +{ + "code": "微信回调参数 code", + "with_userinfo": true +} +``` + +- `with_userinfo` 可选,默认 `true`。 +- 若网页授权 scope 仅为 `snsapi_base`,通常只能拿到 `openid`,获取 `userinfo` 可能失败(接口会返回 `userinfo_error` 提示)。 + +curl 示例: + +```bash +curl -X POST 'http://127.0.0.1:8080/api/v1/wechat/official/oauth/code2user' \ + -H 'Content-Type: application/json' \ + -d '{"code":"YOUR_CODE","with_userinfo":true}' +``` + +成功响应示例(节选): + +```json +{ + "code": 200, + "message": "success", + "data": { + "openid": "oXXXX", + "unionid": "oXXXX", + "scope": "snsapi_userinfo", + "expires_in": 7200, + "userinfo": { + "openid": "oXXXX", + "nickname": "昵称", + "headimgurl": "https://...", + "sex": 1, + "province": "Guangdong", + "city": "Shenzhen", + "country": "CN", + "unionid": "oXXXX" + } + } +} +``` + diff --git a/internal/common/wechat_official/handler/oauth_handler.go b/internal/common/wechat_official/handler/oauth_handler.go new file mode 100644 index 0000000..aeadec8 --- /dev/null +++ b/internal/common/wechat_official/handler/oauth_handler.go @@ -0,0 +1,80 @@ +package handler + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + + "wx_service/internal/common/wechat_official/service" + "wx_service/internal/model" +) + +type OAuthHandler struct { + oaService *service.WeChatOAService +} + +func NewOAuthHandler(oaService *service.WeChatOAService) *OAuthHandler { + return &OAuthHandler{oaService: oaService} +} + +type codeToUserRequest struct { + Code string `json:"code" binding:"required"` + WithUserInfo *bool `json:"with_userinfo"` +} + +// CodeToUser 使用微信公众号网页授权 code 换取 openid,并可选拉取用户信息(需要 snsapi_userinfo 授权)。 +func (h *OAuthHandler) CodeToUser(c *gin.Context) { + var req codeToUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误")) + return + } + + withUserInfo := true + if req.WithUserInfo != nil { + withUserInfo = *req.WithUserInfo + } + + token, err := h.oaService.ExchangeCode(c.Request.Context(), req.Code) + if err != nil { + switch { + case errors.Is(err, service.ErrWeChatOANotConfigured): + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置公众号服务,请联系管理员")) + case errors.Is(err, service.ErrWeChatOACodeRequired): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "code 不能为空")) + default: + var apiErr *service.WeChatOAError + if errors.As(err, &apiErr) { + c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, "微信接口异常,请稍后重试")) + return + } + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取 openid 失败,请稍后重试")) + } + return + } + + payload := gin.H{ + "openid": token.OpenID, + "unionid": token.UnionID, + "scope": token.Scope, + "expires_in": token.ExpiresIn, + } + + if withUserInfo { + info, err := h.oaService.FetchUserInfo(c.Request.Context(), token.AccessToken, token.OpenID) + if err != nil { + // 可能是 scope 不够(snsapi_base),此时仍返回 openid,并给出提示。 + payload["userinfo_error"] = "未获取到用户信息(可能未授权 snsapi_userinfo)" + } else { + // 统一从 userinfo 里回填 unionid(如果有) + if info.UnionID != "" { + payload["unionid"] = info.UnionID + } + payload["userinfo"] = info + } + } + + // 为安全起见,这里不向前端返回 access_token/refresh_token。 + c.JSON(http.StatusOK, model.Success(payload)) +} diff --git a/internal/common/wechat_official/service/oauth_service.go b/internal/common/wechat_official/service/oauth_service.go new file mode 100644 index 0000000..da194f6 --- /dev/null +++ b/internal/common/wechat_official/service/oauth_service.go @@ -0,0 +1,155 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "wx_service/config" +) + +var ( + ErrWeChatOANotConfigured = errors.New("wechat official account is not configured") + ErrWeChatOACodeRequired = errors.New("code is required") +) + +type WeChatOAService struct { + cfg config.WeChatOfficialConfig + client *http.Client +} + +func NewWeChatOAService(cfg config.WeChatOfficialConfig) *WeChatOAService { + timeout := cfg.RequestTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + return &WeChatOAService{ + cfg: cfg, + client: &http.Client{ + Timeout: timeout, + }, + } +} + +type OAuthToken struct { + OpenID string `json:"openid"` + UnionID string `json:"unionid,omitempty"` + Scope string `json:"scope,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + AccessToken string `json:"access_token,omitempty"` +} + +type UserInfo struct { + OpenID string `json:"openid"` + NickName string `json:"nickname,omitempty"` + Sex int `json:"sex,omitempty"` + Province string `json:"province,omitempty"` + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` + HeadImgURL string `json:"headimgurl,omitempty"` + Privilege []string `json:"privilege,omitempty"` + UnionID string `json:"unionid,omitempty"` +} + +type wechatError struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +type WeChatOAError struct { + Code int + Msg string +} + +func (e *WeChatOAError) Error() string { + return fmt.Sprintf("wechat oa error: errcode=%d errmsg=%s", e.Code, e.Msg) +} + +func (s *WeChatOAService) ExchangeCode(ctx context.Context, code string) (OAuthToken, error) { + if s.cfg.AppID == "" || s.cfg.AppSecret == "" { + return OAuthToken{}, ErrWeChatOANotConfigured + } + if code == "" { + return OAuthToken{}, ErrWeChatOACodeRequired + } + + params := url.Values{} + params.Set("appid", s.cfg.AppID) + params.Set("secret", s.cfg.AppSecret) + params.Set("code", code) + params.Set("grant_type", "authorization_code") + + endpoint := fmt.Sprintf("https://api.weixin.qq.com/sns/oauth2/access_token?%s", params.Encode()) + body, err := s.get(ctx, endpoint) + if err != nil { + return OAuthToken{}, err + } + + var apiErr wechatError + _ = json.Unmarshal(body, &apiErr) + if apiErr.ErrCode != 0 { + return OAuthToken{}, &WeChatOAError{Code: apiErr.ErrCode, Msg: apiErr.ErrMsg} + } + + var token OAuthToken + if err := json.Unmarshal(body, &token); err != nil { + return OAuthToken{}, fmt.Errorf("decode oauth token: %w", err) + } + return token, nil +} + +func (s *WeChatOAService) FetchUserInfo(ctx context.Context, accessToken string, openid string) (UserInfo, error) { + if accessToken == "" || openid == "" { + return UserInfo{}, fmt.Errorf("missing access_token or openid") + } + + params := url.Values{} + params.Set("access_token", accessToken) + params.Set("openid", openid) + params.Set("lang", "zh_CN") + + endpoint := fmt.Sprintf("https://api.weixin.qq.com/sns/userinfo?%s", params.Encode()) + body, err := s.get(ctx, endpoint) + if err != nil { + return UserInfo{}, err + } + + var apiErr wechatError + _ = json.Unmarshal(body, &apiErr) + if apiErr.ErrCode != 0 { + return UserInfo{}, &WeChatOAError{Code: apiErr.ErrCode, Msg: apiErr.ErrMsg} + } + + var info UserInfo + if err := json.Unmarshal(body, &info); err != nil { + return UserInfo{}, fmt.Errorf("decode user info: %w", err) + } + return info, nil +} + +func (s *WeChatOAService) get(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("build request: %w", err) + } + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("call wechat api: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("wechat api status=%d body=%s", resp.StatusCode, string(body)) + } + return body, nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index c3cfbc9..91087e0 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -8,6 +8,7 @@ import ( authhandler "wx_service/internal/common/auth/handler" qiniuhandler "wx_service/internal/common/qiniu/handler" + oahandler "wx_service/internal/common/wechat_official/handler" "wx_service/internal/middleware" rmhandler "wx_service/internal/remove_watermark/handler" smokehandler "wx_service/internal/smoke/handler" @@ -20,6 +21,7 @@ func Register( videoHandler *rmhandler.VideoHandler, smokeHandler *smokehandler.SmokeHandler, uploadHandler *qiniuhandler.UploadHandler, + oaOAuthHandler *oahandler.OAuthHandler, ) { // Register 用来集中注册所有 HTTP 路由,便于工程结构更清晰: // - main 只负责初始化(配置/DB/依赖注入) @@ -29,6 +31,9 @@ func Register( // 登录接口:用微信 code 换取/创建用户并返回 session_key(作为后续 Bearer Token) api.POST("/auth/login", authHandler.LoginWithWeChat) + // 公众号网页授权:不需要登录(code 本身来自微信授权回调) + registerWeChatOfficialRoutes(api, oaOAuthHandler) + // 需要登录的接口组:统一挂载鉴权中间件 protected := api.Group("") protected.Use(middleware.AuthMiddleware(db)) diff --git a/internal/routes/wechat_official_routes.go b/internal/routes/wechat_official_routes.go new file mode 100644 index 0000000..37ba6dc --- /dev/null +++ b/internal/routes/wechat_official_routes.go @@ -0,0 +1,15 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + oahandler "wx_service/internal/common/wechat_official/handler" +) + +func registerWeChatOfficialRoutes(api *gin.RouterGroup, oauthHandler *oahandler.OAuthHandler) { + // 微信公众号(网页授权 OAuth2) + oa := api.Group("/wechat/official") + { + oa.POST("/oauth/code2user", oauthHandler.CodeToUser) + } +}