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..1c18b1b 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" @@ -98,15 +100,9 @@ func (h *UserEndpoint) GetUserInfo(ctx *gin.Context) { return } - status := 0 - if user.ID > 0 { - status = 1 - } - ctx.JSON(http.StatusOK, common.SuccessResponse(ctx, model.UserInfoResponse{ Username: user.Username, - Phone: user.Phone, - Status: status, + Email: user.Email, })) } @@ -169,3 +165,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..25201cc 100644 --- a/config/config.go +++ b/config/config.go @@ -6,21 +6,29 @@ 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"` + Proxy string `mapstructure:"proxy"` +} + 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..e757707 100644 --- a/config/config_dev.yaml +++ b/config/config_dev.yaml @@ -4,8 +4,8 @@ server: database: driver: mysql - host: mysql - port: 3306 + host: localhost + port: 3006 username: root password: texpixel#pwd123! dbname: doc_ai @@ -13,7 +13,7 @@ database: max_open: 100 redis: - addr: redis:6379 + addr: localhost:6079 password: yoge@123321! db: 0 @@ -50,3 +50,9 @@ mathpix: baidu_ocr: token: "e3a47bd2438f1f38840c203fc5939d17a54482d1" + +google: + client_id: "404402221037-nqdsk11bkpk5a7oh396mrg1ieh28u6q1.apps.googleusercontent.com" + client_secret: "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" + redirect_uri: "https://app.cloud.texpixel.com:10443/auth/google/callback" + proxy: "http://localhost:7890" diff --git a/config/config_prod.yaml b/config/config_prod.yaml index c0d13cf..7e51480 100644 --- a/config/config_prod.yaml +++ b/config/config_prod.yaml @@ -50,3 +50,9 @@ mathpix: baidu_ocr: token: "e3a47bd2438f1f38840c203fc5939d17a54482d1" + +google: + client_id: "404402221037-nqdsk11bkpk5a7oh396mrg1ieh28u6q1.apps.googleusercontent.com" + client_secret: "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" + redirect_uri: "https://texpixel.com/auth/google/callback" + proxy: "http://100.115.184.74:7890" diff --git a/deploy_dev.sh b/deploy_dev.sh index 61a8b6d..5097974 100755 --- a/deploy_dev.sh +++ b/deploy_dev.sh @@ -6,7 +6,8 @@ ssh ubuntu << 'ENDSSH' cd /home/yoge/Dev/doc_ai_backed git checkout test git pull origin test -docker compose down +docker compose -f docker-compose.infra.yml up -d +docker compose down docker image rm doc_ai_backed-doc_ai:latest -docker compose -f docker-compose.yml up -d +docker compose up -d ENDSSH \ No newline at end of file diff --git a/docker-compose.infra.yml b/docker-compose.infra.yml new file mode 100644 index 0000000..76d3f77 --- /dev/null +++ b/docker-compose.infra.yml @@ -0,0 +1,32 @@ +services: + mysql: + image: mysql:8.0 + container_name: mysql + environment: + MYSQL_ROOT_PASSWORD: texpixel#pwd123! + MYSQL_DATABASE: doc_ai + MYSQL_USER: texpixel + MYSQL_PASSWORD: texpixel#pwd123! + ports: + - "3006:3306" + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-ptexpixel#pwd123!"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 30s + restart: always + + redis: + image: redis:latest + container_name: redis + command: redis-server --requirepass "yoge@123321!" + ports: + - "6079:6379" + restart: always + +volumes: + mysql_data: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index f20d439..d5c84d4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,58 +2,9 @@ services: doc_ai: build: . container_name: doc_ai - ports: - - "8024:8024" + network_mode: host volumes: - ./config:/app/config - ./logs:/app/logs - networks: - - backend - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_started command: ["-env", "dev"] restart: always - - mysql: - image: mysql:8.0 - container_name: mysql - environment: - MYSQL_ROOT_PASSWORD: texpixel#pwd123! - MYSQL_DATABASE: doc_ai - MYSQL_USER: texpixel - MYSQL_PASSWORD: texpixel#pwd123! - ports: - - "3006:3306" - volumes: - - mysql_data:/var/lib/mysql - networks: - - backend - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-ptexpixel#pwd123!"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 30s - restart: always - - redis: - image: redis:latest - container_name: redis - command: redis-server --requirepass "yoge@123321!" - ports: - - "6079:6379" - networks: - - backend - restart: always - -volumes: - mysql_data: - # 持久化MySQL数据卷 - driver: local - -networks: - backend: - driver: bridge diff --git a/internal/model/user/user.go b/internal/model/user/user.go index 90b73bc..f558210 100644 --- a/internal/model/user/user.go +++ b/internal/model/user/user.go @@ -20,8 +20,7 @@ type PhoneLoginResponse struct { type UserInfoResponse struct { Username string `json:"username"` - Phone string `json:"phone"` - Status int `json:"status"` // 0: not login, 1: login + Email string `json:"email"` } type EmailRegisterRequest struct { @@ -43,3 +42,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..97929c9 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -2,10 +2,15 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "math/rand" + "net/http" + "net/url" + "gitea.com/texpixel/document_ai/config" + model "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 +164,126 @@ 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) googleHTTPClient() *http.Client { + if config.GlobalConfig.Google.Proxy == "" { + return &http.Client{} + } + proxyURL, err := url.Parse(config.GlobalConfig.Google.Proxy) + if err != nil { + return &http.Client{} + } + return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} +} + +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}, + } + + client := svc.googleHTTPClient() + resp, err := client.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 := svc.googleHTTPClient() + 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 +}