视频连线
This commit is contained in:
@@ -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] != "" {
|
||||
|
||||
33
backend/go-service/app/auth/dao/oauth_account_dao.go
Normal file
33
backend/go-service/app/auth/dao/oauth_account_dao.go
Normal 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
|
||||
}
|
||||
22
backend/go-service/app/auth/model/oauth_account.go
Normal file
22
backend/go-service/app/auth/model/oauth_account.go
Normal 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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
382
backend/go-service/app/auth/service/oauth_service.go
Normal file
382
backend/go-service/app/auth/service/oauth_service.go
Normal 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 ""
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 新增:会议录制语音转写结果表
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 连接
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
deploy/docker/postgres/oauth_migration.sql
Normal file
35
deploy/docker/postgres/oauth_migration.sql
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -31,6 +31,12 @@
|
||||
"navigationBarTitleText": "登录中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/auth/oauth-callback",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录中"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/chat/index",
|
||||
"style": {
|
||||
|
||||
@@ -73,6 +73,18 @@
|
||||
<text class="btn-label">{{ loading ? '登录中…' : '登录' }}</text>
|
||||
</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">
|
||||
<text class="link-hint">还没有账号?</text>
|
||||
@@ -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;
|
||||
|
||||
131
frontend/src/pages/auth/oauth-callback.vue
Normal file
131
frontend/src/pages/auth/oauth-callback.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user