diff --git a/backend/go-service/app/meeting/controller/meeting_controller.go b/backend/go-service/app/meeting/controller/meeting_controller.go index bfc4292..fd4cf36 100644 --- a/backend/go-service/app/meeting/controller/meeting_controller.go +++ b/backend/go-service/app/meeting/controller/meeting_controller.go @@ -305,7 +305,10 @@ func (ctl *MeetingController) CloudLawSession(c *gin.Context) { ctl.handleError(c, err, "创建云律 EchoChat 用户失败") return } - resp, err := ctl.authService.BuildLoginResponseForUser(c.Request.Context(), user, c.ClientIP(), constants.ClientTypeFrontend) + resp, err := ctl.authService.Login(c.Request.Context(), &dto.LoginRequest{ + Account: user.Username, + Password: service.CloudLawDefaultPassword, + }, c.ClientIP(), constants.ClientTypeFrontend) if err != nil { utils.ResponseError(c, "创建云律 EchoChat 登录态失败") return diff --git a/backend/go-service/app/meeting/service/meeting_service.go b/backend/go-service/app/meeting/service/meeting_service.go index e6b8f9a..44420e3 100644 --- a/backend/go-service/app/meeting/service/meeting_service.go +++ b/backend/go-service/app/meeting/service/meeting_service.go @@ -62,6 +62,7 @@ const ( redisKeyInvitePrefix = "echo:meeting:invite:" // 邀请 Token redisKeyPasswordLockPrefix = "echo:meeting:lock:" // 密码错误锁(code:user_id) redisPasswordAttemptPrefix = "echo:meeting:pwd_attempt:" // 密码错误计数 + CloudLawDefaultPassword = "123456" // Task 16 P2-7:会议聊天限流计数键,格式 "echo:meeting:chat_rate::" redisKeyChatRatePrefix = "echo:meeting:chat_rate:" ) @@ -267,6 +268,7 @@ func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone st var existing authModel.User err := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&existing).Error if err == nil { + s.ensureCloudLawDefaultPassword(ctx, &existing) return &existing, false, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -274,7 +276,7 @@ func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone st } username := cloudLawUsername(phone) - passwordHash, err := utils.HashPassword(uuid.New().String()) + passwordHash, err := utils.HashPassword(CloudLawDefaultPassword) if err != nil { return nil, false, err } @@ -289,6 +291,7 @@ func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone st if err = s.db.WithContext(ctx).Create(user).Error; err != nil { var retry authModel.User if retryErr := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&retry).Error; retryErr == nil { + s.ensureCloudLawDefaultPassword(ctx, &retry) return &retry, false, nil } return nil, false, err @@ -302,6 +305,25 @@ func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone st return user, true, nil } +func (s *MeetingService) ensureCloudLawDefaultPassword(ctx context.Context, user *authModel.User) { + if user == nil || user.ID <= 0 || utils.CheckPassword(CloudLawDefaultPassword, user.PasswordHash) { + return + } + passwordHash, err := utils.HashPassword(CloudLawDefaultPassword) + if err != nil { + logs.Warn(ctx, "service.meeting_service.ensureCloudLawDefaultPassword", "生成云律默认密码哈希失败", + zap.Int64("user_id", user.ID), zap.Error(err)) + return + } + if err = s.db.WithContext(ctx).Model(&authModel.User{}).Where("id = ?", user.ID). + Update("password_hash", passwordHash).Error; err != nil { + logs.Warn(ctx, "service.meeting_service.ensureCloudLawDefaultPassword", "更新云律默认密码失败", + zap.Int64("user_id", user.ID), zap.Error(err)) + return + } + user.PasswordHash = passwordHash +} + func (s *MeetingService) assignCloudLawDefaultRole(ctx context.Context, userID int64) { funcName := "service.meeting_service.assignCloudLawDefaultRole" var role authModel.Role diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 7cbbae2..47d0b97 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,74 +1,99 @@ - - - + + + diff --git a/frontend/src/pages/auth/sso.vue b/frontend/src/pages/auth/sso.vue index 6495167..a107531 100644 --- a/frontend/src/pages/auth/sso.vue +++ b/frontend/src/pages/auth/sso.vue @@ -21,20 +21,100 @@ import { ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { saveToken, saveUserInfo } from '@/utils/storage' import { useUserStore } from '@/store/user' +import { useWebSocketStore } from '@/store/websocket' +import { useChatStore } from '@/store/chat' +import { useContactStore } from '@/store/contact' +import { useGroupStore } from '@/store/group' +import { useNotifyStore } from '@/store/notify' const statusText = ref('正在登录…') +function safeDecode(value) { + try { + return decodeURIComponent(value) + } catch (e) { + return value + } +} + +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 +} + +function readSsoParam(query, key) { + const value = query && query[key] !== undefined ? query[key] : readRawQueryParams()[key] + return value === undefined || value === null ? '' : String(value) +} + +function resolveSsoPayload(query) { + const payloadText = readSsoParam(query, 'payload') + if (payloadText) { + return JSON.parse(safeDecode(payloadText)) + } + const token = readSsoParam(query, 'token') + const refreshToken = readSsoParam(query, 'refresh_token') + const expiresIn = Number(readSsoParam(query, 'expires_in') || 0) + const userText = readSsoParam(query, 'user') + const user = userText ? JSON.parse(safeDecode(userText)) : null + return { token, refresh_token: refreshToken, expires_in: expiresIn, user } +} + +function markCloudLawEmbed() { + try { + if (typeof window === 'undefined') return + window.sessionStorage.setItem('echo_cloud_law_meeting_embed', '1') + } catch (e) {} +} + +function bootstrapAfterSso(token) { + // 模拟 App.onLaunch 的初始化路径:先重连 WS(顶掉旧 token 连接),再注册各 store 的 ws 监听并拉初始数据 + try { + const wsStore = useWebSocketStore() + wsStore.disconnect() + wsStore.connect(token) + } catch (wsErr) { + console.warn('[SSO] WebSocket 重连失败', wsErr) + } + try { + const chatStore = useChatStore() + const contactStore = useContactStore() + const groupStore = useGroupStore() + const notifyStore = useNotifyStore() + chatStore.initWsListeners() + contactStore.initWsListeners() + groupStore.initWsListeners() + notifyStore.initWsListeners() + contactStore.fetchPendingRequests().catch(() => {}) + notifyStore.fetchUnreadCount().catch(() => {}) + } catch (storeErr) { + console.warn('[SSO] 全局 store 初始化异常', storeErr) + } +} + onLoad((query) => { try { - if (!query?.payload) { + const payload = resolveSsoPayload(query || {}) + if (!payload.token || !payload.user) { statusText.value = '缺少 payload 参数' return } - const payload = JSON.parse(decodeURIComponent(query.payload)) - if (!payload.token || !payload.user) { - statusText.value = 'payload 字段不完整' - return - } + markCloudLawEmbed() // 写入持久化存储 saveToken(payload.token, payload.refresh_token || '', payload.expires_in || 0) saveUserInfo(payload.user) @@ -43,7 +123,10 @@ onLoad((query) => { userStore.token = payload.token userStore.userInfo = payload.user - const redirect = query.redirect ? decodeURIComponent(query.redirect) : '/pages/meeting/index' + bootstrapAfterSso(payload.token) + + const rawRedirect = readSsoParam(query || {}, 'redirect') + const redirect = rawRedirect ? safeDecode(rawRedirect) : '/pages/meeting/index' statusText.value = '登录成功,跳转中…' // redirectTo 不保留历史,避免返回到 sso 页造成循环 setTimeout(() => { diff --git a/frontend/src/utils/request.js b/frontend/src/utils/request.js index 0cee5b2..5e73870 100644 --- a/frontend/src/utils/request.js +++ b/frontend/src/utils/request.js @@ -24,6 +24,39 @@ const BASE_URL = (typeof __VITE_API_BASE__ !== 'undefined' && __VITE_API_BASE__) // 请求超时时间(毫秒) const TIMEOUT = 15000 +/** + * 判断当前页面是否处于 SSO 桥接或会议嵌入流程 + * 这些场景下出现 401 时不应清 token / 跳登录页: + * 1. SSO 页正在写入新 token,跳登录会清掉刚拿到的凭证 + * 2. 嵌入到外部容器(小程序 web-view / PC iframe)打开的会议页,跳登录纯干扰业务 + */ +function isOnSsoOrEmbeddedMeeting() { + // #ifdef H5 + try { + const hash = String((typeof window !== 'undefined' && window.location && window.location.hash) || '') + const search = String((typeof window !== 'undefined' && window.location && window.location.search) || '') + if (hash.indexOf('/pages/auth/sso') >= 0) return true + if (search.indexOf('pages/auth/sso') >= 0 || search.indexOf('payload=') >= 0 || search.indexOf('cloudLawSso=') >= 0) return true + if (window.sessionStorage && window.sessionStorage.getItem('echo_cloud_law_meeting_embed') === '1') return true + // 仅当作为 iframe 子页运行时,才把 meeting 路径视为嵌入 + if (typeof window !== 'undefined' && window.parent && window.parent !== window) { + if (hash.indexOf('/pages/meeting/') >= 0) return true + } + } catch (e) { + // ignore + } + // #endif + try { + const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : [] + const current = pages && pages.length ? pages[pages.length - 1] : null + const route = current && (current.route || current.$page?.fullPath || '') + if (typeof route === 'string' && route.indexOf('pages/auth/sso') >= 0) return true + } catch (e) { + // ignore + } + return false +} + /** * 统一请求方法 * @@ -84,8 +117,12 @@ const request = (options) => { } else if (statusCode === 401) { const isAuthEndpoint = /\/auth\/(login|register)$/.test(options.url) const message = data?.message || '登录已过期,请重新登录' - toastIfNotSilent({ title: message, icon: 'none', duration: 2000 }) - if (!isAuthEndpoint) { + // 外部 SSO 或会议嵌入场景:跳登录会清掉刚写入的 token,且在 iframe 里跳登录纯干扰业务,静默报错 + const isExternalEmbed = isOnSsoOrEmbeddedMeeting() + if (!isExternalEmbed) { + toastIfNotSilent({ title: message, icon: 'none', duration: 2000 }) + } + if (!isAuthEndpoint && !isExternalEmbed) { removeToken() setTimeout(() => { uni.reLaunch({ url: '/pages/auth/login' })