diff --git a/api/router.go b/api/router.go index 41a8c7f..ae02db7 100644 --- a/api/router.go +++ b/api/router.go @@ -38,15 +38,20 @@ func SetupRouter(engine *gin.RouterGroup) { ossRouter.POST("/file/upload", endpoint.UploadFile) } - userRouter := v1.Group("/user", common.GetAuthMiddleware()) + userEndpoint := user.NewUserEndpoint() + + userRouter := v1.Group("/user") { - userEndpoint := user.NewUserEndpoint() - { - userRouter.POST("/sms", userEndpoint.SendVerificationCode) - userRouter.POST("/register", userEndpoint.RegisterByEmail) - userRouter.POST("/login", userEndpoint.LoginByEmail) - userRouter.GET("/info", common.MustAuthMiddleware(), userEndpoint.GetUserInfo) - } + userRouter.POST("/sms", userEndpoint.SendVerificationCode) + userRouter.POST("/register", userEndpoint.RegisterByEmail) + userRouter.POST("/login", userEndpoint.LoginByEmail) + userRouter.GET("/oauth/google/url", userEndpoint.GetGoogleOAuthUrl) + userRouter.POST("/oauth/google/callback", userEndpoint.GoogleOAuthCallback) + } + + userAuthRouter := v1.Group("/user", common.GetAuthMiddleware()) + { + userAuthRouter.GET("/info", common.MustAuthMiddleware(), userEndpoint.GetUserInfo) } // 数据埋点路由 diff --git a/api/v1/user/handler.go b/api/v1/user/handler.go index 5e98e21..29f593e 100644 --- a/api/v1/user/handler.go +++ b/api/v1/user/handler.go @@ -1,7 +1,9 @@ package user import ( + "fmt" "net/http" + "net/url" "gitea.com/texpixel/document_ai/config" model "gitea.com/texpixel/document_ai/internal/model/user" @@ -169,3 +171,69 @@ func (h *UserEndpoint) LoginByEmail(ctx *gin.Context) { ExpiresAt: tokenResult.ExpiresAt, })) } + +func (h *UserEndpoint) GetGoogleOAuthUrl(ctx *gin.Context) { + req := model.GoogleAuthUrlRequest{} + if err := ctx.ShouldBindQuery(&req); err != nil { + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, common.CodeParamErrorMsg)) + return + } + + googleConfig := config.GlobalConfig.Google + if googleConfig.ClientID == "" { + log.Error(ctx, "func", "GetGoogleOAuthUrl", "msg", "Google OAuth not configured") + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg)) + return + } + + authURL := fmt.Sprintf( + "https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid%%20email%%20profile&state=%s", + url.QueryEscape(googleConfig.ClientID), + url.QueryEscape(req.RedirectURI), + url.QueryEscape(req.State), + ) + + ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.GoogleAuthUrlResponse{ + AuthURL: authURL, + })) +} + +func (h *UserEndpoint) GoogleOAuthCallback(ctx *gin.Context) { + req := model.GoogleOAuthCallbackRequest{} + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeParamError, common.CodeParamErrorMsg)) + return + } + + googleConfig := config.GlobalConfig.Google + if googleConfig.ClientID == "" || googleConfig.ClientSecret == "" { + log.Error(ctx, "func", "GoogleOAuthCallback", "msg", "Google OAuth not configured") + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg)) + return + } + + userInfo, err := h.userService.ExchangeGoogleCodeAndGetUserInfo(ctx, googleConfig.ClientID, googleConfig.ClientSecret, req.Code, req.RedirectURI) + if err != nil { + log.Error(ctx, "func", "GoogleOAuthCallback", "msg", "exchange code failed", "error", err) + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg)) + return + } + + uid, err := h.userService.FindOrCreateGoogleUser(ctx, userInfo) + if err != nil { + log.Error(ctx, "func", "GoogleOAuthCallback", "msg", "find or create user failed", "error", err) + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeSystemError, common.CodeSystemErrorMsg)) + return + } + + tokenResult, err := jwt.CreateToken(jwt.User{UserId: uid}) + if err != nil { + ctx.JSON(http.StatusOK, common.ErrorResponse(ctx, common.CodeUnauthorized, common.CodeUnauthorizedMsg)) + return + } + + ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.GoogleOAuthCallbackResponse{ + Token: tokenResult.Token, + ExpiresAt: tokenResult.ExpiresAt, + })) +} diff --git a/config/config.go b/config/config.go index 34fcdb8..3fdd5b5 100644 --- a/config/config.go +++ b/config/config.go @@ -6,21 +6,28 @@ import ( ) type Config struct { - Log log.LogConfig `mapstructure:"log"` - Server ServerConfig `mapstructure:"server"` - Database DatabaseConfig `mapstructure:"database"` - Redis RedisConfig `mapstructure:"redis"` - UploadDir string `mapstructure:"upload_dir"` - Limit LimitConfig `mapstructure:"limit"` - Aliyun AliyunConfig `mapstructure:"aliyun"` - Mathpix MathpixConfig `mapstructure:"mathpix"` - BaiduOCR BaiduOCRConfig `mapstructure:"baidu_ocr"` + Log log.LogConfig `mapstructure:"log"` + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + UploadDir string `mapstructure:"upload_dir"` + Limit LimitConfig `mapstructure:"limit"` + Aliyun AliyunConfig `mapstructure:"aliyun"` + Mathpix MathpixConfig `mapstructure:"mathpix"` + BaiduOCR BaiduOCRConfig `mapstructure:"baidu_ocr"` + Google GoogleOAuthConfig `mapstructure:"google"` } type BaiduOCRConfig struct { Token string `mapstructure:"token"` } +type GoogleOAuthConfig struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + RedirectURI string `mapstructure:"redirect_uri"` +} + type MathpixConfig struct { AppID string `mapstructure:"app_id"` AppKey string `mapstructure:"app_key"` diff --git a/config/config_dev.yaml b/config/config_dev.yaml index e75c3d9..ec788d0 100644 --- a/config/config_dev.yaml +++ b/config/config_dev.yaml @@ -50,3 +50,8 @@ mathpix: baidu_ocr: token: "e3a47bd2438f1f38840c203fc5939d17a54482d1" + +google: + client_id: "" + client_secret: "" + redirect_uri: "http://localhost:5173/auth/callback" diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 90b73bc..652c5a9 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -43,3 +43,31 @@ type EmailLoginResponse struct { Token string `json:"token"` ExpiresAt int64 `json:"expires_at"` } + +type GoogleAuthUrlRequest struct { + RedirectURI string `form:"redirect_uri" binding:"required"` + State string `form:"state" binding:"required"` +} + +type GoogleAuthUrlResponse struct { + AuthURL string `json:"auth_url"` +} + +type GoogleOAuthCallbackRequest struct { + Code string `json:"code" binding:"required"` + State string `json:"state" binding:"required"` + RedirectURI string `json:"redirect_uri" binding:"required"` +} + +type GoogleOAuthCallbackResponse struct { + Token string `json:"token"` + ExpiresAt int64 `json:"expires_at"` +} + +type GoogleUserInfo struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` + VerifiedEmail bool `json:"verified_email"` +} diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 4627c2e..8c5ddd3 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -2,10 +2,14 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "math/rand" + "net/http" + "net/url" + "gitea.com/texpixel/document_ai/internal/model/user" "gitea.com/texpixel/document_ai/internal/storage/cache" "gitea.com/texpixel/document_ai/internal/storage/dao" "gitea.com/texpixel/document_ai/pkg/common" @@ -159,3 +163,114 @@ func (svc *UserService) LoginByEmail(ctx context.Context, email, password string return user.ID, nil } + +type googleTokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +func (svc *UserService) ExchangeGoogleCodeAndGetUserInfo(ctx context.Context, clientID, clientSecret, code, redirectURI string) (*model.GoogleUserInfo, error) { + tokenURL := "https://oauth2.googleapis.com/token" + formData := url.Values{ + "client_id": {clientID}, + "client_secret": {clientSecret}, + "code": {code}, + "grant_type": {"authorization_code"}, + "redirect_uri": {redirectURI}, + } + + resp, err := http.PostForm(tokenURL, formData) + if err != nil { + log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "exchange code failed", "error", err) + return nil, err + } + defer resp.Body.Close() + + var tokenResp googleTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "decode token response failed", "error", err) + return nil, err + } + + if tokenResp.AccessToken == "" { + log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "no access token in response") + return nil, errors.New("no access token in response") + } + + userInfo, err := svc.getGoogleUserInfo(ctx, tokenResp.AccessToken) + if err != nil { + log.Error(ctx, "func", "ExchangeGoogleCodeAndGetUserInfo", "msg", "get user info failed", "error", err) + return nil, err + } + + return &model.GoogleUserInfo{ + ID: userInfo.ID, + Email: userInfo.Email, + Name: userInfo.Name, + }, nil +} + +func (svc *UserService) getGoogleUserInfo(ctx context.Context, accessToken string) (*model.GoogleUserInfo, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var userInfo model.GoogleUserInfo + if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { + return nil, err + } + + return &userInfo, nil +} + +func (svc *UserService) FindOrCreateGoogleUser(ctx context.Context, userInfo *model.GoogleUserInfo) (uid int64, err error) { + existingUser, err := svc.userDao.GetByGoogleID(dao.DB.WithContext(ctx), userInfo.ID) + if err != nil { + log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "get user by google id error", "error", err) + return 0, err + } + + if existingUser != nil { + return existingUser.ID, nil + } + + existingUser, err = svc.userDao.GetByEmail(dao.DB.WithContext(ctx), userInfo.Email) + if err != nil { + log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "get user by email error", "error", err) + return 0, err + } + + if existingUser != nil { + existingUser.GoogleID = userInfo.ID + err = svc.userDao.Update(dao.DB.WithContext(ctx), existingUser) + if err != nil { + log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "update user google id error", "error", err) + return 0, err + } + return existingUser.ID, nil + } + + user := &dao.User{ + Email: userInfo.Email, + GoogleID: userInfo.ID, + Username: userInfo.Name, + } + err = svc.userDao.Create(dao.DB.WithContext(ctx), user) + if err != nil { + log.Error(ctx, "func", "FindOrCreateGoogleUser", "msg", "create user error", "error", err) + return 0, err + } + + return user.ID, nil +} diff --git a/internal/storage/dao/user.go b/internal/storage/dao/user.go index 18dd6bf..40b5ee7 100644 --- a/internal/storage/dao/user.go +++ b/internal/storage/dao/user.go @@ -14,6 +14,7 @@ type User struct { Password string `gorm:"column:password" json:"password"` WechatOpenID string `gorm:"column:wechat_open_id" json:"wechat_open_id"` WechatUnionID string `gorm:"column:wechat_union_id" json:"wechat_union_id"` + GoogleID string `gorm:"column:google_id" json:"google_id"` } func (u *User) TableName() string { @@ -63,3 +64,18 @@ func (dao *UserDao) GetByEmail(tx *gorm.DB, email string) (*User, error) { } return &user, nil } + +func (dao *UserDao) GetByGoogleID(tx *gorm.DB, googleID string) (*User, error) { + var user User + if err := tx.Where("google_id = ?", googleID).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &user, nil +} + +func (dao *UserDao) Update(tx *gorm.DB, user *User) error { + return tx.Save(user).Error +}