视频连线

This commit is contained in:
duoaohui
2026-05-26 22:41:53 +08:00
parent 331941f9ce
commit 24e3a37c96
19 changed files with 860 additions and 4 deletions

View File

@@ -2,9 +2,16 @@
package controller package controller
import ( import (
"encoding/base64"
"encoding/json"
"net/http"
"net/url"
"strings"
"github.com/echochat/backend/app/auth/service" "github.com/echochat/backend/app/auth/service"
"github.com/echochat/backend/app/constants" "github.com/echochat/backend/app/constants"
"github.com/echochat/backend/app/dto" "github.com/echochat/backend/app/dto"
"github.com/echochat/backend/config"
"github.com/echochat/backend/pkg/logs" "github.com/echochat/backend/pkg/logs"
"github.com/echochat/backend/pkg/middleware" "github.com/echochat/backend/pkg/middleware"
"github.com/echochat/backend/pkg/utils" "github.com/echochat/backend/pkg/utils"
@@ -16,11 +23,13 @@ import (
// 处理用户注册、登录、Token 刷新、个人信息管理等接口 // 处理用户注册、登录、Token 刷新、个人信息管理等接口
type AuthController struct { type AuthController struct {
authService *service.AuthService authService *service.AuthService
oauthService *service.OAuthService
oauthCfg *config.OAuthConfig
} }
// NewAuthController 创建前台认证控制器实例 // NewAuthController 创建前台认证控制器实例
func NewAuthController(authService *service.AuthService) *AuthController { func NewAuthController(authService *service.AuthService, oauthService *service.OAuthService, oauthCfg *config.OAuthConfig) *AuthController {
return &AuthController{authService: authService} return &AuthController{authService: authService, oauthService: oauthService, oauthCfg: oauthCfg}
} }
// Register 用户注册 // Register 用户注册
@@ -77,6 +86,53 @@ func (ctrl *AuthController) Login(c *gin.Context) {
utils.ResponseOK(c, resp) 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 用户登出 // Logout 用户登出
// POST /api/v1/auth/logout需认证 // POST /api/v1/auth/logout需认证
func (ctrl *AuthController) Logout(c *gin.Context) { 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()) utils.ResponseForbidden(c, err.Error())
case service.ErrRefreshTokenType: case service.ErrRefreshTokenType:
utils.ResponseBadRequest(c, err.Error()) 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: default:
msg := "服务器内部错误" msg := "服务器内部错误"
if len(fallbackMsg) > 0 && fallbackMsg[0] != "" { if len(fallbackMsg) > 0 && fallbackMsg[0] != "" {

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -13,8 +13,10 @@ import (
var AuthSet = wire.NewSet( var AuthSet = wire.NewSet(
dao.NewUserDAO, dao.NewUserDAO,
dao.NewRoleDAO, dao.NewRoleDAO,
dao.NewOAuthAccountDAO,
service.NewTokenStore, service.NewTokenStore,
service.NewAuthService, service.NewAuthService,
service.NewOAuthService,
controller.NewAuthController, controller.NewAuthController,
controller.NewAdminAuthController, controller.NewAdminAuthController,
) )

View File

@@ -23,6 +23,8 @@ func RegisterRoutes(
{ {
public.POST("/register", ctrl.Register) public.POST("/register", ctrl.Register)
public.POST("/login", ctrl.Login) 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) public.POST("/refresh-token", ctrl.RefreshToken)
} }

View File

@@ -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 ""
}

View File

@@ -191,6 +191,10 @@ func provideMeetingConfig(cfg *config.Config) *config.MeetingConfig {
return &cfg.Meeting return &cfg.Meeting
} }
func provideOAuthConfig(cfg *config.Config) *config.OAuthConfig {
return &cfg.OAuth
}
// InfraSet 基础设施层 Provider Set // InfraSet 基础设施层 Provider Set
var InfraSet = wire.NewSet( var InfraSet = wire.NewSet(
provideDBConfig, provideDBConfig,
@@ -200,6 +204,7 @@ var InfraSet = wire.NewSet(
provideServerConfig, provideServerConfig,
provideLLMSourceConfig, provideLLMSourceConfig,
provideMeetingConfig, provideMeetingConfig,
provideOAuthConfig,
db.NewPostgres, db.NewPostgres,
db.NewRedis, db.NewRedis,
db.NewLLMSourceDB, db.NewLLMSourceDB,

View File

@@ -60,10 +60,13 @@ func InitializeApp(cfg *config.Config) (*App, error) {
} }
userDAO := dao.NewUserDAO(gormDB) userDAO := dao.NewUserDAO(gormDB)
roleDAO := dao.NewRoleDAO(gormDB) roleDAO := dao.NewRoleDAO(gormDB)
oauthAccountDAO := dao.NewOAuthAccountDAO(gormDB)
jwtConfig := provideJWTConfig(cfg) jwtConfig := provideJWTConfig(cfg)
tokenStore := service.NewTokenStore(client, jwtConfig) tokenStore := service.NewTokenStore(client, jwtConfig)
authService := service.NewAuthService(userDAO, roleDAO, jwtConfig, tokenStore) 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) adminAuthController := controller.NewAdminAuthController(authService)
userManageDAO := dao2.NewUserManageDAO(gormDB) userManageDAO := dao2.NewUserManageDAO(gormDB)
userManageService := service2.NewUserManageService(userManageDAO, userDAO, roleDAO) userManageService := service2.NewUserManageService(userManageDAO, userDAO, roleDAO)

View File

@@ -10,6 +10,7 @@ import (
"syscall" "syscall"
"time" "time"
authModel "github.com/echochat/backend/app/auth/model"
"github.com/echochat/backend/app/im/model" "github.com/echochat/backend/app/im/model"
meetingModel "github.com/echochat/backend/app/meeting/model" meetingModel "github.com/echochat/backend/app/meeting/model"
transcribeModel "github.com/echochat/backend/app/transcribe/model" transcribeModel "github.com/echochat/backend/app/transcribe/model"
@@ -58,6 +59,7 @@ func main() {
&model.Conversation{}, &model.Conversation{},
&model.ConversationMember{}, &model.ConversationMember{},
&model.Message{}, &model.Message{},
&authModel.OAuthAccount{},
// Phase B 新增:会议录制元数据表 // Phase B 新增:会议录制元数据表
&meetingModel.MeetingRecording{}, &meetingModel.MeetingRecording{},
// Phase B 新增:会议录制语音转写结果表 // Phase B 新增:会议录制语音转写结果表

View File

@@ -33,6 +33,20 @@ jwt:
refresh_expire_day: 7 # Refresh Token 有效期(天),即 7 天 refresh_expire_day: 7 # Refresh Token 有效期(天),即 7 天
issuer: echochat # JWT 签发者标识 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: log:
level: debug # 最低输出级别debug / info / warn / error level: debug # 最低输出级别debug / info / warn / error

View File

@@ -29,6 +29,20 @@ jwt:
refresh_expire_day: 7 refresh_expire_day: 7
issuer: echochat 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: log:
level: debug level: debug
format: text format: text

View File

@@ -20,6 +20,19 @@ type Config struct {
MediaServer MediaServerConfig `mapstructure:"media_server"` MediaServer MediaServerConfig `mapstructure:"media_server"`
Meeting MeetingConfig `mapstructure:"meeting"` Meeting MeetingConfig `mapstructure:"meeting"`
LLMSource LLMSourceConfig `mapstructure:"llm_source"` // Phase B外部 LLM 配置库MySQLt_llm_provider/model/key LLMSource LLMSourceConfig `mapstructure:"llm_source"` // Phase B外部 LLM 配置库MySQLt_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 连接 // LLMSourceConfig 外部 LLM/STT 配置中心 MySQL 连接

View File

@@ -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.role_id IS '关联的角色 ID';
COMMENT ON COLUMN auth_user_roles.created_at IS '角色分配时间'; 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) -- 用户名: super_admin 密码: admin123456 (bcrypt hash)

View File

@@ -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);

View File

@@ -37,6 +37,10 @@ const login = (data) => {
return post('/api/v1/auth/login', data, { needAuth: false }) 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 * POST /api/v1/auth/logout
@@ -107,6 +111,7 @@ const changePassword = (data) => {
export default { export default {
register, register,
login, login,
getOAuthAuthorizeUrl,
logout, logout,
refreshToken, refreshToken,
getProfile, getProfile,

View File

@@ -31,6 +31,12 @@
"navigationBarTitleText": "登录中" "navigationBarTitleText": "登录中"
} }
}, },
{
"path": "pages/auth/oauth-callback",
"style": {
"navigationBarTitleText": "登录中"
}
},
{ {
"path": "pages/chat/index", "path": "pages/chat/index",
"style": { "style": {

View File

@@ -73,6 +73,18 @@
<text class="btn-label">{{ loading ? '登录中…' : '登录' }}</text> <text class="btn-label">{{ loading ? '登录中…' : '登录' }}</text>
</button> </button>
<view class="oauth-section">
<view class="oauth-divider">
<view class="oauth-line"></view>
<text class="oauth-text">其他登录方式</text>
<view class="oauth-line"></view>
</view>
<view class="oauth-buttons">
<button class="oauth-btn oauth-wechat" :disabled="oauthLoading" @tap="oauthLogin('wechat')">微信登录</button>
<button class="oauth-btn oauth-qq" :disabled="oauthLoading" @tap="oauthLogin('qq')">QQ登录</button>
</view>
</view>
<!-- 底部链接 --> <!-- 底部链接 -->
<view class="link-row"> <view class="link-row">
<text class="link-hint">还没有账号</text> <text class="link-hint">还没有账号</text>
@@ -94,6 +106,7 @@ import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat' import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact' import { useContactStore } from '@/store/contact'
import { useNotifyStore } from '@/store/notify' import { useNotifyStore } from '@/store/notify'
import authApi from '@/api/auth'
function readSsoQueryFromLocation() { function readSsoQueryFromLocation() {
try { try {
@@ -125,6 +138,7 @@ export default {
form: { account: '', password: '' }, form: { account: '', password: '' },
passwordVisible: false, passwordVisible: false,
loading: false, loading: false,
oauthLoading: false,
focusState: { account: false, password: false }, focusState: { account: false, password: false },
errors: { account: '', password: '' } errors: { account: '', password: '' }
} }
@@ -200,6 +214,29 @@ export default {
goRegister() { goRegister() {
uni.navigateTo({ url: '/pages/auth/register' }) 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; 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 { .link-row {
display: flex; display: flex;

View File

@@ -0,0 +1,131 @@
<template>
<view class="callback-page">
<view class="callback-card">
<view class="loading-dot"></view>
<text class="callback-title">正在完成登录</text>
<text class="callback-desc">请稍候正在同步账号信息</text>
</view>
</view>
</template>
<script>
import { useUserStore } from '@/store/user'
import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
import { useNotifyStore } from '@/store/notify'
function decodePayload(payload) {
const normalized = String(payload || '').replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
const binary = atob(padded)
return decodeURIComponent(Array.prototype.map.call(binary, c => {
return `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`
}).join(''))
}
function readRawQueryParams() {
const result = {}
try {
if (typeof window === 'undefined') return result
const search = String(window.location.search || '').replace(/^\?/, '')
const hash = String(window.location.hash || '')
const hashQuery = hash.indexOf('?') >= 0 ? hash.slice(hash.indexOf('?') + 1) : ''
;[search, hashQuery].forEach(raw => {
if (!raw) return
const params = new URLSearchParams(raw)
params.forEach((value, key) => {
if (result[key] === undefined) {
result[key] = value
}
})
})
} catch (e) {}
return result
}
export default {
name: 'OAuthCallbackPage',
onLoad(query) {
this.handleCallback({ ...readRawQueryParams(), ...(query || {}) })
},
methods: {
async handleCallback(query) {
try {
if (query.status !== 'success') {
const message = query.payload ? decodePayload(query.payload) : '第三方登录失败'
uni.showToast({ title: message || '第三方登录失败', icon: 'none' })
setTimeout(() => uni.redirectTo({ url: '/pages/auth/login' }), 1200)
return
}
const loginData = JSON.parse(decodePayload(query.payload))
const store = useUserStore()
store.applyOAuthLogin(loginData)
const wsStore = useWebSocketStore()
wsStore.connect()
useChatStore().initWsListeners()
useContactStore().initWsListeners()
const notifyStore = useNotifyStore()
notifyStore.initWsListeners()
notifyStore.fetchUnreadCount().catch(() => {})
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 600)
} catch (e) {
console.error('OAuth 回调处理失败:', e)
uni.showToast({ title: '登录失败', icon: 'none' })
setTimeout(() => uni.redirectTo({ url: '/pages/auth/login' }), 1200)
}
}
}
}
</script>
<style scoped>
.callback-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #F8FAFC;
padding: 48rpx;
}
.callback-card {
width: 100%;
max-width: 640rpx;
padding: 64rpx 40rpx;
border-radius: 24rpx;
background-color: #FFFFFF;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
align-items: center;
}
.loading-dot {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
border: 6rpx solid #DBEAFE;
border-top-color: #2563EB;
animation: spin 800ms linear infinite;
}
.callback-title {
margin-top: 32rpx;
font-size: 34rpx;
font-weight: 600;
color: #1E293B;
}
.callback-desc {
margin-top: 12rpx;
font-size: 26rpx;
color: #64748B;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>

View File

@@ -106,6 +106,11 @@ export const useUserStore = defineStore('user', () => {
return res.data return res.data
} }
const applyOAuthLogin = (data) => {
_handleAuthSuccess(data)
return data
}
/** /**
* 退出登录 * 退出登录
* 1. 调用后端 API 从 Redis 删除 Token使 Token 立即失效) * 1. 调用后端 API 从 Redis 删除 Token使 Token 立即失效)
@@ -207,6 +212,7 @@ export const useUserStore = defineStore('user', () => {
// actions // actions
login, login,
register, register,
applyOAuthLogin,
logout, logout,
getProfile, getProfile,
updateProfile, updateProfile,