diff --git a/backend/go-service/app/auth/controller/auth_controller.go b/backend/go-service/app/auth/controller/auth_controller.go
index 7df4d73..de3e6ed 100644
--- a/backend/go-service/app/auth/controller/auth_controller.go
+++ b/backend/go-service/app/auth/controller/auth_controller.go
@@ -2,9 +2,16 @@
package controller
import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "strings"
+
"github.com/echochat/backend/app/auth/service"
"github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/dto"
+ "github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/middleware"
"github.com/echochat/backend/pkg/utils"
@@ -16,11 +23,13 @@ import (
// 处理用户注册、登录、Token 刷新、个人信息管理等接口
type AuthController struct {
authService *service.AuthService
+ oauthService *service.OAuthService
+ oauthCfg *config.OAuthConfig
}
// NewAuthController 创建前台认证控制器实例
-func NewAuthController(authService *service.AuthService) *AuthController {
- return &AuthController{authService: authService}
+func NewAuthController(authService *service.AuthService, oauthService *service.OAuthService, oauthCfg *config.OAuthConfig) *AuthController {
+ return &AuthController{authService: authService, oauthService: oauthService, oauthCfg: oauthCfg}
}
// Register 用户注册
@@ -77,6 +86,53 @@ func (ctrl *AuthController) Login(c *gin.Context) {
utils.ResponseOK(c, resp)
}
+func (ctrl *AuthController) OAuthAuthorize(c *gin.Context) {
+ provider := strings.ToLower(strings.TrimSpace(c.Param("provider")))
+ authURL, err := ctrl.oauthService.BuildAuthorizeURL(c.Request.Context(), provider)
+ if err != nil {
+ handleAuthError(c, err, "第三方登录初始化失败")
+ return
+ }
+ utils.ResponseOK(c, gin.H{"url": authURL})
+}
+
+func (ctrl *AuthController) OAuthCallback(c *gin.Context) {
+ provider := strings.ToLower(strings.TrimSpace(c.Param("provider")))
+ code := strings.TrimSpace(c.Query("code"))
+ state := strings.TrimSpace(c.Query("state"))
+ if code == "" {
+ ctrl.redirectOAuthFailure(c, "第三方登录授权失败")
+ return
+ }
+ resp, err := ctrl.oauthService.Callback(c.Request.Context(), provider, code, state, c.ClientIP())
+ if err != nil {
+ ctrl.redirectOAuthFailure(c, err.Error())
+ return
+ }
+ payload, err := json.Marshal(resp)
+ if err != nil {
+ ctrl.redirectOAuthFailure(c, "登录结果生成失败")
+ return
+ }
+ ctrl.redirectOAuthResult(c, "success", base64.RawURLEncoding.EncodeToString(payload))
+}
+
+func (ctrl *AuthController) redirectOAuthFailure(c *gin.Context, message string) {
+ ctrl.redirectOAuthResult(c, "error", base64.RawURLEncoding.EncodeToString([]byte(message)))
+}
+
+func (ctrl *AuthController) redirectOAuthResult(c *gin.Context, status, payload string) {
+ callbackURL := "/echoChat-frontend/#/pages/auth/oauth-callback"
+ if ctrl.oauthCfg != nil && strings.TrimSpace(ctrl.oauthCfg.FrontendCallbackURL) != "" {
+ callbackURL = strings.TrimSpace(ctrl.oauthCfg.FrontendCallbackURL)
+ }
+ sep := "?"
+ if strings.Contains(callbackURL, "?") {
+ sep = "&"
+ }
+ c.Redirect(http.StatusFound, callbackURL+sep+"status="+url.QueryEscape(status)+"&payload="+url.QueryEscape(payload))
+}
+
// Logout 用户登出
// POST /api/v1/auth/logout(需认证)
func (ctrl *AuthController) Logout(c *gin.Context) {
@@ -226,6 +282,12 @@ func handleAuthError(c *gin.Context, err error, fallbackMsg ...string) {
utils.ResponseForbidden(c, err.Error())
case service.ErrRefreshTokenType:
utils.ResponseBadRequest(c, err.Error())
+ case service.ErrOAuthProviderUnsupported:
+ utils.ResponseBadRequest(c, err.Error())
+ case service.ErrOAuthProviderDisabled:
+ utils.ResponseBadRequest(c, err.Error())
+ case service.ErrOAuthStateInvalid:
+ utils.ResponseBadRequest(c, err.Error())
default:
msg := "服务器内部错误"
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {
diff --git a/backend/go-service/app/auth/dao/oauth_account_dao.go b/backend/go-service/app/auth/dao/oauth_account_dao.go
new file mode 100644
index 0000000..1ba58e9
--- /dev/null
+++ b/backend/go-service/app/auth/dao/oauth_account_dao.go
@@ -0,0 +1,33 @@
+package dao
+
+import (
+ "context"
+
+ "github.com/echochat/backend/app/auth/model"
+ "gorm.io/gorm"
+)
+
+type OAuthAccountDAO struct {
+ db *gorm.DB
+}
+
+func NewOAuthAccountDAO(db *gorm.DB) *OAuthAccountDAO {
+ return &OAuthAccountDAO{db: db}
+}
+
+func (d *OAuthAccountDAO) FindByProviderOpenID(ctx context.Context, provider, openID string) (*model.OAuthAccount, error) {
+ var account model.OAuthAccount
+ err := d.db.WithContext(ctx).Where("provider = ? AND open_id = ?", provider, openID).First(&account).Error
+ if err != nil {
+ return nil, err
+ }
+ return &account, nil
+}
+
+func (d *OAuthAccountDAO) Create(ctx context.Context, account *model.OAuthAccount) error {
+ return d.db.WithContext(ctx).Create(account).Error
+}
+
+func (d *OAuthAccountDAO) Update(ctx context.Context, account *model.OAuthAccount) error {
+ return d.db.WithContext(ctx).Save(account).Error
+}
diff --git a/backend/go-service/app/auth/model/oauth_account.go b/backend/go-service/app/auth/model/oauth_account.go
new file mode 100644
index 0000000..af6ed8d
--- /dev/null
+++ b/backend/go-service/app/auth/model/oauth_account.go
@@ -0,0 +1,22 @@
+package model
+
+import "time"
+
+type OAuthAccount struct {
+ ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
+ UserID int64 `json:"user_id" gorm:"not null;index"`
+ Provider string `json:"provider" gorm:"size:20;not null;uniqueIndex:idx_oauth_provider_openid"`
+ OpenID string `json:"openid" gorm:"size:128;not null;uniqueIndex:idx_oauth_provider_openid"`
+ UnionID string `json:"unionid" gorm:"size:128;not null;default:'';index"`
+ Nickname string `json:"nickname" gorm:"size:100;not null;default:''"`
+ Avatar string `json:"avatar" gorm:"size:500;not null;default:''"`
+ AccessToken string `json:"-" gorm:"column:access_token;size:1024;not null;default:''"`
+ RefreshToken string `json:"-" gorm:"column:refresh_token;size:1024;not null;default:''"`
+ ExpiresAt time.Time `json:"expires_at" gorm:"type:timestamp(0)"`
+ CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"`
+ UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"`
+}
+
+func (OAuthAccount) TableName() string {
+ return "auth_oauth_accounts"
+}
diff --git a/backend/go-service/app/auth/provider.go b/backend/go-service/app/auth/provider.go
index 7cdd5c2..ffee447 100644
--- a/backend/go-service/app/auth/provider.go
+++ b/backend/go-service/app/auth/provider.go
@@ -13,8 +13,10 @@ import (
var AuthSet = wire.NewSet(
dao.NewUserDAO,
dao.NewRoleDAO,
+ dao.NewOAuthAccountDAO,
service.NewTokenStore,
service.NewAuthService,
+ service.NewOAuthService,
controller.NewAuthController,
controller.NewAdminAuthController,
)
diff --git a/backend/go-service/app/auth/router.go b/backend/go-service/app/auth/router.go
index e53af2f..a56bb37 100644
--- a/backend/go-service/app/auth/router.go
+++ b/backend/go-service/app/auth/router.go
@@ -23,6 +23,8 @@ func RegisterRoutes(
{
public.POST("/register", ctrl.Register)
public.POST("/login", ctrl.Login)
+ public.GET("/oauth/:provider/authorize", ctrl.OAuthAuthorize)
+ public.GET("/oauth/:provider/callback", ctrl.OAuthCallback)
public.POST("/refresh-token", ctrl.RefreshToken)
}
diff --git a/backend/go-service/app/auth/service/oauth_service.go b/backend/go-service/app/auth/service/oauth_service.go
new file mode 100644
index 0000000..47345b6
--- /dev/null
+++ b/backend/go-service/app/auth/service/oauth_service.go
@@ -0,0 +1,382 @@
+package service
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/echochat/backend/app/auth/dao"
+ "github.com/echochat/backend/app/auth/model"
+ "github.com/echochat/backend/app/constants"
+ "github.com/echochat/backend/app/dto"
+ "github.com/echochat/backend/config"
+ "github.com/echochat/backend/pkg/utils"
+ "github.com/redis/go-redis/v9"
+ "gorm.io/gorm"
+)
+
+const (
+ oauthStateKeyPrefix = "echo:auth:oauth_state:"
+ oauthStateTTL = 10 * time.Minute
+)
+
+var (
+ ErrOAuthProviderUnsupported = errors.New("不支持的第三方登录方式")
+ ErrOAuthProviderDisabled = errors.New("第三方登录未配置或未启用")
+ ErrOAuthStateInvalid = errors.New("第三方登录状态已失效")
+)
+
+type OAuthService struct {
+ userDAO *dao.UserDAO
+ oauthAccountDAO *dao.OAuthAccountDAO
+ roleDAO *dao.RoleDAO
+ authService *AuthService
+ cfg *config.OAuthConfig
+ redis *redis.Client
+ httpClient *http.Client
+}
+
+type OAuthProfile struct {
+ Provider string
+ OpenID string
+ UnionID string
+ Nickname string
+ Avatar string
+ AccessToken string
+ RefreshToken string
+ ExpiresIn int64
+}
+
+type oauthTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+ OpenID string `json:"openid"`
+ UnionID string `json:"unionid"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+}
+
+type qqTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ ExpiresIn int64 `json:"expires_in"`
+ RefreshToken string `json:"refresh_token"`
+}
+
+type qqOpenIDResponse struct {
+ ClientID string `json:"client_id"`
+ OpenID string `json:"openid"`
+}
+
+type wxUserInfoResponse struct {
+ OpenID string `json:"openid"`
+ Nickname string `json:"nickname"`
+ HeadImg string `json:"headimgurl"`
+ UnionID string `json:"unionid"`
+ ErrCode int `json:"errcode"`
+ ErrMsg string `json:"errmsg"`
+}
+
+type qqUserInfoResponse struct {
+ Ret int `json:"ret"`
+ Msg string `json:"msg"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"figureurl_qq_2"`
+ Avatar1 string `json:"figureurl_qq_1"`
+}
+
+func NewOAuthService(userDAO *dao.UserDAO, oauthAccountDAO *dao.OAuthAccountDAO, roleDAO *dao.RoleDAO, authService *AuthService, cfg *config.OAuthConfig, redisClient *redis.Client) *OAuthService {
+ return &OAuthService{
+ userDAO: userDAO,
+ oauthAccountDAO: oauthAccountDAO,
+ roleDAO: roleDAO,
+ authService: authService,
+ cfg: cfg,
+ redis: redisClient,
+ httpClient: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+func (s *OAuthService) BuildAuthorizeURL(ctx context.Context, provider string) (string, error) {
+ provider = normalizeOAuthProvider(provider)
+ app, err := s.providerConfig(provider)
+ if err != nil {
+ return "", err
+ }
+ state, err := randomState()
+ if err != nil {
+ return "", err
+ }
+ if err = s.saveOAuthState(ctx, provider, state); err != nil {
+ return "", err
+ }
+ redirectURI := url.QueryEscape(app.RedirectURI)
+ scope := "snsapi_login"
+ if provider == "qq" {
+ scope = "get_user_info"
+ }
+ if provider == "wechat" {
+ return fmt.Sprintf("https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect", url.QueryEscape(app.ClientID), redirectURI, scope, state), nil
+ }
+ return fmt.Sprintf("https://graph.qq.com/oauth2.0/authorize?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&state=%s", url.QueryEscape(app.ClientID), redirectURI, url.QueryEscape(scope), state), nil
+}
+
+func (s *OAuthService) Callback(ctx context.Context, provider, code, state, clientIP string) (*dto.LoginResponse, error) {
+ provider = normalizeOAuthProvider(provider)
+ if err := s.validateOAuthState(ctx, provider, state); err != nil {
+ return nil, err
+ }
+ profile, err := s.fetchProfile(ctx, provider, code)
+ if err != nil {
+ return nil, err
+ }
+ account, err := s.oauthAccountDAO.FindByProviderOpenID(ctx, profile.Provider, profile.OpenID)
+ var user *model.User
+ if err == nil {
+ user, err = s.userDAO.FindByID(ctx, account.UserID)
+ if err != nil {
+ return nil, err
+ }
+ account.Nickname = profile.Nickname
+ account.Avatar = profile.Avatar
+ account.AccessToken = profile.AccessToken
+ account.RefreshToken = profile.RefreshToken
+ account.UnionID = profile.UnionID
+ account.ExpiresAt = time.Now().Add(time.Duration(profile.ExpiresIn) * time.Second)
+ _ = s.oauthAccountDAO.Update(ctx, account)
+ } else if errors.Is(err, gorm.ErrRecordNotFound) {
+ user, account, err = s.createOAuthUser(ctx, profile)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ return nil, err
+ }
+ return s.authService.BuildLoginResponseForUser(ctx, user, clientIP, constants.ClientTypeFrontend)
+}
+
+func (s *OAuthService) createOAuthUser(ctx context.Context, profile *OAuthProfile) (*model.User, *model.OAuthAccount, error) {
+ nickname := strings.TrimSpace(profile.Nickname)
+ if nickname == "" {
+ nickname = profile.Provider + "用户"
+ }
+ username := s.uniqueUsername(ctx, profile.Provider)
+ passwordHash, err := utils.HashPassword(randomPassword())
+ if err != nil {
+ return nil, nil, err
+ }
+ user := &model.User{
+ Username: username,
+ Email: username + "@oauth.echochat.local",
+ PasswordHash: passwordHash,
+ Nickname: nickname,
+ Avatar: profile.Avatar,
+ Status: constants.UserStatusActive,
+ }
+ if err = s.userDAO.Create(ctx, user); err != nil {
+ return nil, nil, err
+ }
+ defaultRole, roleErr := s.roleDAO.FindByCode(ctx, constants.RoleUser)
+ if roleErr == nil {
+ _ = s.roleDAO.AssignRole(ctx, user.ID, defaultRole.ID)
+ }
+ account := &model.OAuthAccount{
+ UserID: user.ID,
+ Provider: profile.Provider,
+ OpenID: profile.OpenID,
+ UnionID: profile.UnionID,
+ Nickname: profile.Nickname,
+ Avatar: profile.Avatar,
+ AccessToken: profile.AccessToken,
+ RefreshToken: profile.RefreshToken,
+ ExpiresAt: time.Now().Add(time.Duration(profile.ExpiresIn) * time.Second),
+ }
+ if err = s.oauthAccountDAO.Create(ctx, account); err != nil {
+ return nil, nil, err
+ }
+ return user, account, nil
+}
+
+func (s *OAuthService) fetchProfile(ctx context.Context, provider, code string) (*OAuthProfile, error) {
+ app, err := s.providerConfig(provider)
+ if err != nil {
+ return nil, err
+ }
+ if provider == "wechat" {
+ return s.fetchWechatProfile(ctx, app, code)
+ }
+ return s.fetchQQProfile(ctx, app, code)
+}
+
+func (s *OAuthService) fetchWechatProfile(ctx context.Context, app config.OAuthApp, code string) (*OAuthProfile, error) {
+ tokenURL := fmt.Sprintf("https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code", url.QueryEscape(app.ClientID), url.QueryEscape(app.ClientSecret), url.QueryEscape(code))
+ var token oauthTokenResponse
+ if err := s.getJSON(ctx, tokenURL, &token); err != nil {
+ return nil, err
+ }
+ if token.ErrCode != 0 || token.AccessToken == "" || token.OpenID == "" {
+ return nil, fmt.Errorf("微信登录失败: %s", token.ErrMsg)
+ }
+ infoURL := fmt.Sprintf("https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN", url.QueryEscape(token.AccessToken), url.QueryEscape(token.OpenID))
+ var info wxUserInfoResponse
+ if err := s.getJSON(ctx, infoURL, &info); err != nil {
+ return nil, err
+ }
+ if info.ErrCode != 0 {
+ return nil, fmt.Errorf("微信用户信息获取失败: %s", info.ErrMsg)
+ }
+ return &OAuthProfile{Provider: "wechat", OpenID: token.OpenID, UnionID: firstNonEmpty(info.UnionID, token.UnionID), Nickname: info.Nickname, Avatar: info.HeadImg, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresIn: token.ExpiresIn}, nil
+}
+
+func (s *OAuthService) fetchQQProfile(ctx context.Context, app config.OAuthApp, code string) (*OAuthProfile, error) {
+ tokenURL := fmt.Sprintf("https://graph.qq.com/oauth2.0/token?grant_type=authorization_code&client_id=%s&client_secret=%s&code=%s&redirect_uri=%s&fmt=json", url.QueryEscape(app.ClientID), url.QueryEscape(app.ClientSecret), url.QueryEscape(code), url.QueryEscape(app.RedirectURI))
+ var token qqTokenResponse
+ if err := s.getJSON(ctx, tokenURL, &token); err != nil {
+ return nil, err
+ }
+ if token.AccessToken == "" {
+ return nil, errors.New("QQ登录失败")
+ }
+ openidURL := fmt.Sprintf("https://graph.qq.com/oauth2.0/me?access_token=%s&fmt=json", url.QueryEscape(token.AccessToken))
+ var openid qqOpenIDResponse
+ if err := s.getJSON(ctx, openidURL, &openid); err != nil {
+ return nil, err
+ }
+ if openid.OpenID == "" {
+ return nil, errors.New("QQ openid 获取失败")
+ }
+ infoURL := fmt.Sprintf("https://graph.qq.com/user/get_user_info?access_token=%s&oauth_consumer_key=%s&openid=%s&fmt=json", url.QueryEscape(token.AccessToken), url.QueryEscape(app.ClientID), url.QueryEscape(openid.OpenID))
+ var info qqUserInfoResponse
+ if err := s.getJSON(ctx, infoURL, &info); err != nil {
+ return nil, err
+ }
+ if info.Ret != 0 {
+ return nil, fmt.Errorf("QQ用户信息获取失败: %s", info.Msg)
+ }
+ return &OAuthProfile{Provider: "qq", OpenID: openid.OpenID, Nickname: info.Nickname, Avatar: firstNonEmpty(info.Avatar, info.Avatar1), AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, ExpiresIn: token.ExpiresIn}, nil
+}
+
+func (s *OAuthService) providerConfig(provider string) (config.OAuthApp, error) {
+ provider = normalizeOAuthProvider(provider)
+ if provider != "wechat" && provider != "qq" {
+ return config.OAuthApp{}, ErrOAuthProviderUnsupported
+ }
+ app := config.OAuthApp{}
+ if s.cfg != nil && s.cfg.Providers != nil {
+ app = s.cfg.Providers[provider]
+ }
+ prefix := "ECHOCHAT_OAUTH_" + strings.ToUpper(provider) + "_"
+ if strings.TrimSpace(app.ClientID) == "" {
+ app.ClientID = strings.TrimSpace(os.Getenv(prefix + "CLIENT_ID"))
+ }
+ if strings.TrimSpace(app.ClientSecret) == "" {
+ app.ClientSecret = strings.TrimSpace(os.Getenv(prefix + "CLIENT_SECRET"))
+ }
+ if strings.TrimSpace(app.RedirectURI) == "" {
+ app.RedirectURI = strings.TrimSpace(os.Getenv(prefix + "REDIRECT_URI"))
+ }
+ if !app.Enabled {
+ app.Enabled = strings.EqualFold(strings.TrimSpace(os.Getenv(prefix+"ENABLED")), "true")
+ }
+ if !app.Enabled || app.ClientID == "" || app.ClientSecret == "" || app.RedirectURI == "" {
+ return config.OAuthApp{}, ErrOAuthProviderDisabled
+ }
+ return app, nil
+}
+
+func (s *OAuthService) saveOAuthState(ctx context.Context, provider, state string) error {
+ if s.redis == nil {
+ return errors.New("OAuth state storage not initialized")
+ }
+ return s.redis.Set(ctx, oauthStateKey(provider, state), "1", oauthStateTTL).Err()
+}
+
+func (s *OAuthService) validateOAuthState(ctx context.Context, provider, state string) error {
+ provider = normalizeOAuthProvider(provider)
+ state = strings.TrimSpace(state)
+ if provider != "wechat" && provider != "qq" || state == "" {
+ return ErrOAuthStateInvalid
+ }
+ if s.redis == nil {
+ return ErrOAuthStateInvalid
+ }
+ _, err := s.redis.Get(ctx, oauthStateKey(provider, state)).Result()
+ if errors.Is(err, redis.Nil) {
+ return ErrOAuthStateInvalid
+ }
+ if err != nil {
+ return err
+ }
+ _ = s.redis.Del(ctx, oauthStateKey(provider, state)).Err()
+ return nil
+}
+
+func oauthStateKey(provider, state string) string {
+ return oauthStateKeyPrefix + normalizeOAuthProvider(provider) + ":" + strings.TrimSpace(state)
+}
+
+func normalizeOAuthProvider(provider string) string {
+ return strings.ToLower(strings.TrimSpace(provider))
+}
+
+func (s *OAuthService) getJSON(ctx context.Context, rawURL string, out interface{}) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := s.httpClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("OAuth HTTP %d", resp.StatusCode)
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func (s *OAuthService) uniqueUsername(ctx context.Context, provider string) string {
+ for i := 0; i < 5; i++ {
+ name := fmt.Sprintf("%s_%s", provider, randomString(10))
+ if existing, _ := s.userDAO.FindByUsername(ctx, name); existing == nil {
+ return name
+ }
+ }
+ return fmt.Sprintf("%s_%d", provider, time.Now().UnixNano())
+}
+
+func randomState() (string, error) {
+ buf := make([]byte, 16)
+ _, err := rand.Read(buf)
+ if err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+func randomString(n int) string {
+ buf := make([]byte, n)
+ _, _ = rand.Read(buf)
+ return hex.EncodeToString(buf)[:n]
+}
+
+func randomPassword() string {
+ return "oauth_" + randomString(32)
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
diff --git a/backend/go-service/app/provider/provider.go b/backend/go-service/app/provider/provider.go
index 55d75c6..96b0fdb 100644
--- a/backend/go-service/app/provider/provider.go
+++ b/backend/go-service/app/provider/provider.go
@@ -191,6 +191,10 @@ func provideMeetingConfig(cfg *config.Config) *config.MeetingConfig {
return &cfg.Meeting
}
+func provideOAuthConfig(cfg *config.Config) *config.OAuthConfig {
+ return &cfg.OAuth
+}
+
// InfraSet 基础设施层 Provider Set
var InfraSet = wire.NewSet(
provideDBConfig,
@@ -200,6 +204,7 @@ var InfraSet = wire.NewSet(
provideServerConfig,
provideLLMSourceConfig,
provideMeetingConfig,
+ provideOAuthConfig,
db.NewPostgres,
db.NewRedis,
db.NewLLMSourceDB,
diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go
index da34d33..7619408 100644
--- a/backend/go-service/app/provider/wire_gen.go
+++ b/backend/go-service/app/provider/wire_gen.go
@@ -60,10 +60,13 @@ func InitializeApp(cfg *config.Config) (*App, error) {
}
userDAO := dao.NewUserDAO(gormDB)
roleDAO := dao.NewRoleDAO(gormDB)
+ oauthAccountDAO := dao.NewOAuthAccountDAO(gormDB)
jwtConfig := provideJWTConfig(cfg)
tokenStore := service.NewTokenStore(client, jwtConfig)
authService := service.NewAuthService(userDAO, roleDAO, jwtConfig, tokenStore)
- authController := controller.NewAuthController(authService)
+ oauthConfig := provideOAuthConfig(cfg)
+ oauthService := service.NewOAuthService(userDAO, oauthAccountDAO, roleDAO, authService, oauthConfig, client)
+ authController := controller.NewAuthController(authService, oauthService, oauthConfig)
adminAuthController := controller.NewAdminAuthController(authService)
userManageDAO := dao2.NewUserManageDAO(gormDB)
userManageService := service2.NewUserManageService(userManageDAO, userDAO, roleDAO)
diff --git a/backend/go-service/cmd/server/main.go b/backend/go-service/cmd/server/main.go
index b60947f..4b30b9f 100644
--- a/backend/go-service/cmd/server/main.go
+++ b/backend/go-service/cmd/server/main.go
@@ -10,6 +10,7 @@ import (
"syscall"
"time"
+ authModel "github.com/echochat/backend/app/auth/model"
"github.com/echochat/backend/app/im/model"
meetingModel "github.com/echochat/backend/app/meeting/model"
transcribeModel "github.com/echochat/backend/app/transcribe/model"
@@ -58,6 +59,7 @@ func main() {
&model.Conversation{},
&model.ConversationMember{},
&model.Message{},
+ &authModel.OAuthAccount{},
// Phase B 新增:会议录制元数据表
&meetingModel.MeetingRecording{},
// Phase B 新增:会议录制语音转写结果表
diff --git a/backend/go-service/config/config.dev.yaml b/backend/go-service/config/config.dev.yaml
index c6587a8..7d5e753 100644
--- a/backend/go-service/config/config.dev.yaml
+++ b/backend/go-service/config/config.dev.yaml
@@ -33,6 +33,20 @@ jwt:
refresh_expire_day: 7 # Refresh Token 有效期(天),即 7 天
issuer: echochat # JWT 签发者标识
+oauth:
+ frontend_callback_url: "/echoChat-frontend/#/pages/auth/oauth-callback"
+ providers:
+ wechat:
+ enabled: false
+ client_id: ""
+ client_secret: ""
+ redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/wechat/callback"
+ qq:
+ enabled: false
+ client_id: ""
+ client_secret: ""
+ redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/qq/callback"
+
# 日志配置
log:
level: debug # 最低输出级别:debug / info / warn / error
@@ -95,4 +109,4 @@ meeting:
cloud_law_internal_token: cloud-law-internal-xx14
recording_transcode_enabled: true
recording_transcode_ffmpeg_path: /home/ykf/jenkins/yuyin/ffmpeg/ffmpeg-7.0.2-amd64-static/ffmpeg
- recording_transcode_timeout_seconds: 600
\ No newline at end of file
+ recording_transcode_timeout_seconds: 600
diff --git a/backend/go-service/config/config.docker.yaml b/backend/go-service/config/config.docker.yaml
index ab5569b..3d3b9e0 100644
--- a/backend/go-service/config/config.docker.yaml
+++ b/backend/go-service/config/config.docker.yaml
@@ -29,6 +29,20 @@ jwt:
refresh_expire_day: 7
issuer: echochat
+oauth:
+ frontend_callback_url: "/echoChat-frontend/#/pages/auth/oauth-callback"
+ providers:
+ wechat:
+ enabled: false
+ client_id: ""
+ client_secret: ""
+ redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/wechat/callback"
+ qq:
+ enabled: false
+ client_id: ""
+ client_secret: ""
+ redirect_uri: "https://yl.z64.cn/api/v1/auth/oauth/qq/callback"
+
log:
level: debug
format: text
diff --git a/backend/go-service/config/config.go b/backend/go-service/config/config.go
index 4d7c803..88b3b28 100644
--- a/backend/go-service/config/config.go
+++ b/backend/go-service/config/config.go
@@ -20,6 +20,19 @@ type Config struct {
MediaServer MediaServerConfig `mapstructure:"media_server"`
Meeting MeetingConfig `mapstructure:"meeting"`
LLMSource LLMSourceConfig `mapstructure:"llm_source"` // Phase B:外部 LLM 配置库(MySQL,t_llm_provider/model/key)
+ OAuth OAuthConfig `mapstructure:"oauth"`
+}
+
+type OAuthConfig struct {
+ FrontendCallbackURL string `mapstructure:"frontend_callback_url"`
+ Providers map[string]OAuthApp `mapstructure:"providers"`
+}
+
+type OAuthApp struct {
+ ClientID string `mapstructure:"client_id"`
+ ClientSecret string `mapstructure:"client_secret"`
+ RedirectURI string `mapstructure:"redirect_uri"`
+ Enabled bool `mapstructure:"enabled"`
}
// LLMSourceConfig 外部 LLM/STT 配置中心 MySQL 连接
diff --git a/deploy/docker/postgres/init.sql b/deploy/docker/postgres/init.sql
index 5e550e2..2bb6ae5 100644
--- a/deploy/docker/postgres/init.sql
+++ b/deploy/docker/postgres/init.sql
@@ -81,6 +81,40 @@ COMMENT ON COLUMN auth_user_roles.user_id IS '关联的用户 ID';
COMMENT ON COLUMN auth_user_roles.role_id IS '关联的角色 ID';
COMMENT ON COLUMN auth_user_roles.created_at IS '角色分配时间';
+-- ============================================================
+-- auth_oauth_accounts: 第三方登录账号绑定表
+-- 存储微信/QQ 等 OAuth 账号与本地 auth_users 的绑定关系
+-- ============================================================
+CREATE TABLE auth_oauth_accounts (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES auth_users(id),
+ provider VARCHAR(20) NOT NULL,
+ open_id VARCHAR(128) NOT NULL,
+ union_id VARCHAR(128) NOT NULL DEFAULT '',
+ nickname VARCHAR(100) NOT NULL DEFAULT '',
+ avatar VARCHAR(500) NOT NULL DEFAULT '',
+ access_token VARCHAR(1024) NOT NULL DEFAULT '',
+ refresh_token VARCHAR(1024) NOT NULL DEFAULT '',
+ expires_at TIMESTAMP(0) DEFAULT NULL,
+ created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
+ UNIQUE (provider, open_id)
+);
+
+COMMENT ON TABLE auth_oauth_accounts IS '第三方登录账号绑定表';
+COMMENT ON COLUMN auth_oauth_accounts.user_id IS '关联本地用户 ID';
+COMMENT ON COLUMN auth_oauth_accounts.provider IS '第三方平台标识:wechat / qq';
+COMMENT ON COLUMN auth_oauth_accounts.open_id IS '第三方平台用户 OpenID';
+COMMENT ON COLUMN auth_oauth_accounts.union_id IS '第三方平台 UnionID';
+COMMENT ON COLUMN auth_oauth_accounts.nickname IS '第三方平台昵称快照';
+COMMENT ON COLUMN auth_oauth_accounts.avatar IS '第三方平台头像快照';
+COMMENT ON COLUMN auth_oauth_accounts.access_token IS '第三方平台 access_token';
+COMMENT ON COLUMN auth_oauth_accounts.refresh_token IS '第三方平台 refresh_token';
+COMMENT ON COLUMN auth_oauth_accounts.expires_at IS '第三方平台 token 过期时间';
+
+CREATE INDEX idx_auth_oauth_accounts_user ON auth_oauth_accounts (user_id);
+CREATE INDEX idx_auth_oauth_accounts_union ON auth_oauth_accounts (union_id);
+
-- ============================================================
-- 插入默认超级管理员(系统预置唯一账号)
-- 用户名: super_admin 密码: admin123456 (bcrypt hash)
diff --git a/deploy/docker/postgres/oauth_migration.sql b/deploy/docker/postgres/oauth_migration.sql
new file mode 100644
index 0000000..e98ee16
--- /dev/null
+++ b/deploy/docker/postgres/oauth_migration.sql
@@ -0,0 +1,35 @@
+-- ============================================================
+-- OAuth 登录数据库迁移:auth_oauth_accounts
+-- 执行环境:在已有 EchoChat 数据库基础上增量升级
+-- 全部语句使用 IF NOT EXISTS / 幂等形式,可重复执行
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS auth_oauth_accounts (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES auth_users(id),
+ provider VARCHAR(20) NOT NULL,
+ open_id VARCHAR(128) NOT NULL,
+ union_id VARCHAR(128) NOT NULL DEFAULT '',
+ nickname VARCHAR(100) NOT NULL DEFAULT '',
+ avatar VARCHAR(500) NOT NULL DEFAULT '',
+ access_token VARCHAR(1024) NOT NULL DEFAULT '',
+ refresh_token VARCHAR(1024) NOT NULL DEFAULT '',
+ expires_at TIMESTAMP(0) DEFAULT NULL,
+ created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(),
+ UNIQUE (provider, open_id)
+);
+
+COMMENT ON TABLE auth_oauth_accounts IS '第三方登录账号绑定表';
+COMMENT ON COLUMN auth_oauth_accounts.user_id IS '关联本地用户 ID';
+COMMENT ON COLUMN auth_oauth_accounts.provider IS '第三方平台标识:wechat / qq';
+COMMENT ON COLUMN auth_oauth_accounts.open_id IS '第三方平台用户 OpenID';
+COMMENT ON COLUMN auth_oauth_accounts.union_id IS '第三方平台 UnionID';
+COMMENT ON COLUMN auth_oauth_accounts.nickname IS '第三方平台昵称快照';
+COMMENT ON COLUMN auth_oauth_accounts.avatar IS '第三方平台头像快照';
+COMMENT ON COLUMN auth_oauth_accounts.access_token IS '第三方平台 access_token';
+COMMENT ON COLUMN auth_oauth_accounts.refresh_token IS '第三方平台 refresh_token';
+COMMENT ON COLUMN auth_oauth_accounts.expires_at IS '第三方平台 token 过期时间';
+
+CREATE INDEX IF NOT EXISTS idx_auth_oauth_accounts_user ON auth_oauth_accounts (user_id);
+CREATE INDEX IF NOT EXISTS idx_auth_oauth_accounts_union ON auth_oauth_accounts (union_id);
diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js
index ee5134d..922e1fa 100644
--- a/frontend/src/api/auth.js
+++ b/frontend/src/api/auth.js
@@ -37,6 +37,10 @@ const login = (data) => {
return post('/api/v1/auth/login', data, { needAuth: false })
}
+const getOAuthAuthorizeUrl = (provider) => {
+ return get(`/api/v1/auth/oauth/${provider}/authorize`, {}, { needAuth: false })
+}
+
/**
* 退出登录
* POST /api/v1/auth/logout
@@ -107,6 +111,7 @@ const changePassword = (data) => {
export default {
register,
login,
+ getOAuthAuthorizeUrl,
logout,
refreshToken,
getProfile,
diff --git a/frontend/src/pages.json b/frontend/src/pages.json
index c8d1b49..cc0ea34 100644
--- a/frontend/src/pages.json
+++ b/frontend/src/pages.json
@@ -31,6 +31,12 @@
"navigationBarTitleText": "登录中"
}
},
+ {
+ "path": "pages/auth/oauth-callback",
+ "style": {
+ "navigationBarTitleText": "登录中"
+ }
+ },
{
"path": "pages/chat/index",
"style": {
diff --git a/frontend/src/pages/auth/login.vue b/frontend/src/pages/auth/login.vue
index ba86553..d94d020 100644
--- a/frontend/src/pages/auth/login.vue
+++ b/frontend/src/pages/auth/login.vue
@@ -73,6 +73,18 @@
{{ loading ? '登录中…' : '登录' }}
+
+
+
+ 其他登录方式
+
+
+
+
+
+
+
+
还没有账号?
@@ -94,6 +106,7 @@ import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
import { useNotifyStore } from '@/store/notify'
+import authApi from '@/api/auth'
function readSsoQueryFromLocation() {
try {
@@ -125,6 +138,7 @@ export default {
form: { account: '', password: '' },
passwordVisible: false,
loading: false,
+ oauthLoading: false,
focusState: { account: false, password: false },
errors: { account: '', password: '' }
}
@@ -200,6 +214,29 @@ export default {
goRegister() {
uni.navigateTo({ url: '/pages/auth/register' })
+ },
+
+ async oauthLogin(provider) {
+ if (this.oauthLoading) return
+ this.oauthLoading = true
+ try {
+ const res = await authApi.getOAuthAuthorizeUrl(provider)
+ const url = res && res.data && res.data.url
+ if (!url) {
+ uni.showToast({ title: '第三方登录未配置', icon: 'none' })
+ return
+ }
+ // #ifdef H5
+ window.location.href = url
+ // #endif
+ // #ifndef H5
+ uni.showToast({ title: '当前端暂不支持第三方登录', icon: 'none' })
+ // #endif
+ } catch (e) {
+ console.error('第三方登录失败:', e)
+ } finally {
+ this.oauthLoading = false
+ }
}
}
}
@@ -362,6 +399,54 @@ export default {
letter-spacing: 2rpx;
}
+.oauth-section {
+ margin-top: 40rpx;
+}
+
+.oauth-divider {
+ display: flex;
+ align-items: center;
+ gap: 20rpx;
+}
+
+.oauth-line {
+ flex: 1;
+ height: 2rpx;
+ background-color: #E2E8F0;
+}
+
+.oauth-text {
+ font-size: 24rpx;
+ color: #94A3B8;
+}
+
+.oauth-buttons {
+ display: flex;
+ gap: 24rpx;
+ margin-top: 28rpx;
+}
+
+.oauth-btn {
+ flex: 1;
+ height: 80rpx;
+ border-radius: 16rpx;
+ font-size: 28rpx;
+ color: #FFFFFF;
+ border: none;
+}
+
+.oauth-btn::after {
+ border: none;
+}
+
+.oauth-wechat {
+ background-color: #07C160;
+}
+
+.oauth-qq {
+ background-color: #12B7F5;
+}
+
/* ---- 底部链接 ---- */
.link-row {
display: flex;
diff --git a/frontend/src/pages/auth/oauth-callback.vue b/frontend/src/pages/auth/oauth-callback.vue
new file mode 100644
index 0000000..1887b74
--- /dev/null
+++ b/frontend/src/pages/auth/oauth-callback.vue
@@ -0,0 +1,131 @@
+
+
+
+
+ 正在完成登录
+ 请稍候,正在同步账号信息…
+
+
+
+
+
+
+
diff --git a/frontend/src/store/user.js b/frontend/src/store/user.js
index ec27eef..f7d2122 100644
--- a/frontend/src/store/user.js
+++ b/frontend/src/store/user.js
@@ -106,6 +106,11 @@ export const useUserStore = defineStore('user', () => {
return res.data
}
+ const applyOAuthLogin = (data) => {
+ _handleAuthSuccess(data)
+ return data
+ }
+
/**
* 退出登录
* 1. 调用后端 API 从 Redis 删除 Token(使 Token 立即失效)
@@ -207,6 +212,7 @@ export const useUserStore = defineStore('user', () => {
// actions
login,
register,
+ applyOAuthLogin,
logout,
getProfile,
updateProfile,