视频会议

This commit is contained in:
duoaohui
2026-05-23 09:35:03 +08:00
parent 4c9f462ff1
commit 6347fdd2db
5 changed files with 255 additions and 85 deletions

View File

@@ -305,7 +305,10 @@ func (ctl *MeetingController) CloudLawSession(c *gin.Context) {
ctl.handleError(c, err, "创建云律 EchoChat 用户失败") ctl.handleError(c, err, "创建云律 EchoChat 用户失败")
return 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 { if err != nil {
utils.ResponseError(c, "创建云律 EchoChat 登录态失败") utils.ResponseError(c, "创建云律 EchoChat 登录态失败")
return return

View File

@@ -62,6 +62,7 @@ const (
redisKeyInvitePrefix = "echo:meeting:invite:" // 邀请 Token redisKeyInvitePrefix = "echo:meeting:invite:" // 邀请 Token
redisKeyPasswordLockPrefix = "echo:meeting:lock:" // 密码错误锁code:user_id redisKeyPasswordLockPrefix = "echo:meeting:lock:" // 密码错误锁code:user_id
redisPasswordAttemptPrefix = "echo:meeting:pwd_attempt:" // 密码错误计数 redisPasswordAttemptPrefix = "echo:meeting:pwd_attempt:" // 密码错误计数
CloudLawDefaultPassword = "123456"
// Task 16 P2-7会议聊天限流计数键格式 "echo:meeting:chat_rate:<room>:<user_id>" // Task 16 P2-7会议聊天限流计数键格式 "echo:meeting:chat_rate:<room>:<user_id>"
redisKeyChatRatePrefix = "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 var existing authModel.User
err := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&existing).Error err := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&existing).Error
if err == nil { if err == nil {
s.ensureCloudLawDefaultPassword(ctx, &existing)
return &existing, false, nil return &existing, false, nil
} }
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -274,7 +276,7 @@ func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone st
} }
username := cloudLawUsername(phone) username := cloudLawUsername(phone)
passwordHash, err := utils.HashPassword(uuid.New().String()) passwordHash, err := utils.HashPassword(CloudLawDefaultPassword)
if err != nil { if err != nil {
return nil, false, err 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 { if err = s.db.WithContext(ctx).Create(user).Error; err != nil {
var retry authModel.User var retry authModel.User
if retryErr := s.db.WithContext(ctx).Where("phone = ?", phone).Order("id ASC").First(&retry).Error; retryErr == nil { 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 &retry, false, nil
} }
return nil, false, err return nil, false, err
@@ -302,6 +305,25 @@ func (s *MeetingService) EnsureCloudLawUserByPhone(ctx context.Context, phone st
return user, true, nil 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) { func (s *MeetingService) assignCloudLawDefaultRole(ctx context.Context, userID int64) {
funcName := "service.meeting_service.assignCloudLawDefaultRole" funcName := "service.meeting_service.assignCloudLawDefaultRole"
var role authModel.Role var role authModel.Role

View File

@@ -1,74 +1,99 @@
<script> <script>
/** /**
* 应用入口 * 应用入口
* *
* 全局生命周期: * 全局生命周期:
* - onLaunch: 初始化 WebSocket 连接(如果用户已登录)和事件监听 * - onLaunch: 初始化 WebSocket 连接(如果用户已登录)和事件监听
* - onShow: 检查 WebSocket 连接状态并恢复 * - onShow: 检查 WebSocket 连接状态并恢复
*/ */
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { useWebSocketStore } from '@/store/websocket' 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 { useGroupStore } from '@/store/group' import { useGroupStore } from '@/store/group'
import { useNotifyStore } from '@/store/notify' import { useNotifyStore } from '@/store/notify'
export default { function _isSsoLaunch(options) {
onLaunch() { // 来自外部系统 SSO 桥接onLaunch 阶段不应使用 localStorage 旧 token 调任何接口,
console.log('App Launch') // 避免触发 request.js 401 → reLaunch 到登录页。SSO 页 onLoad 完成 token 写入后再统一初始化。
this._initGlobalWS() if (!options) return false
}, const path = String(options.path || '')
onShow() { const url = String(options.query && options.query.path ? options.query.path : '')
console.log('App Show') if (path.indexOf('pages/auth/sso') >= 0 || url.indexOf('pages/auth/sso') >= 0) return true
const userStore = useUserStore() // #ifdef H5
const wsStore = useWebSocketStore() try {
if (userStore.isLoggedIn && !wsStore.isConnected) { const hash = String(window.location?.hash || '')
wsStore.connect() const search = String(window.location?.search || '')
} return hash.indexOf('/pages/auth/sso') >= 0
}, || search.indexOf('pages/auth/sso') >= 0
onHide() { || search.indexOf('cloudLawSso=') >= 0
console.log('App Hide') } catch (e) {
}, return false
methods: { }
_initGlobalWS() { // #endif
const userStore = useUserStore() return false
if (!userStore.isLoggedIn) return }
const wsStore = useWebSocketStore()
wsStore.connect() export default {
const chatStore = useChatStore() onLaunch(options) {
const contactStore = useContactStore() console.log('App Launch', options)
const groupStore = useGroupStore() if (_isSsoLaunch(options)) {
const notifyStore = useNotifyStore() console.log('[App] launched via SSO, defer global init until token refresh')
chatStore.initWsListeners() return
contactStore.initWsListeners() }
groupStore.initWsListeners() this._initGlobalWS()
notifyStore.initWsListeners() },
contactStore.fetchPendingRequests().catch(() => {}) onShow() {
notifyStore.fetchUnreadCount().catch(() => {}) console.log('App Show')
} const userStore = useUserStore()
} const wsStore = useWebSocketStore()
} if (userStore.isLoggedIn && !wsStore.isConnected) {
</script> wsStore.connect()
}
<style> },
/* 每个页面公共 css */ onHide() {
console.log('App Hide')
/* },
* 基础全屏容器:防止 uni-app H5 在大窗口下祖先容器尺寸小于视口 methods: {
* 导致 position: fixed 元素(如会议房间 .room"偏左上"。 _initGlobalWS() {
* 同时让普通页面在桌面宽屏下仍能把内容区(.page / uni-page-body const userStore = useUserStore()
* 拉伸到视口高度,避免下方出现大片浏览器默认底色。 if (!userStore.isLoggedIn) return
*/ const wsStore = useWebSocketStore()
/* #ifdef H5 */ wsStore.connect()
html, body, #app { const chatStore = useChatStore()
width: 100%; const contactStore = useContactStore()
height: 100%; const groupStore = useGroupStore()
margin: 0; const notifyStore = useNotifyStore()
padding: 0; chatStore.initWsListeners()
} contactStore.initWsListeners()
uni-app, uni-app > uni-page, uni-page-wrapper, uni-page-body { groupStore.initWsListeners()
width: 100%; notifyStore.initWsListeners()
min-height: 100%; contactStore.fetchPendingRequests().catch(() => {})
} notifyStore.fetchUnreadCount().catch(() => {})
/* #endif */ }
</style> }
}
</script>
<style>
/* 每个页面公共 css */
/*
* 基础全屏容器:防止 uni-app H5 在大窗口下祖先容器尺寸小于视口
* 导致 position: fixed 元素(如会议房间 .room"偏左上"。
* 同时让普通页面在桌面宽屏下仍能把内容区(.page / uni-page-body
* 拉伸到视口高度,避免下方出现大片浏览器默认底色。
*/
/* #ifdef H5 */
html, body, #app {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
uni-app, uni-app > uni-page, uni-page-wrapper, uni-page-body {
width: 100%;
min-height: 100%;
}
/* #endif */
</style>

View File

@@ -21,20 +21,100 @@ import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { saveToken, saveUserInfo } from '@/utils/storage' import { saveToken, saveUserInfo } from '@/utils/storage'
import { useUserStore } from '@/store/user' 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('正在登录…') 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) => { onLoad((query) => {
try { try {
if (!query?.payload) { const payload = resolveSsoPayload(query || {})
if (!payload.token || !payload.user) {
statusText.value = '缺少 payload 参数' statusText.value = '缺少 payload 参数'
return return
} }
const payload = JSON.parse(decodeURIComponent(query.payload)) markCloudLawEmbed()
if (!payload.token || !payload.user) {
statusText.value = 'payload 字段不完整'
return
}
// 写入持久化存储 // 写入持久化存储
saveToken(payload.token, payload.refresh_token || '', payload.expires_in || 0) saveToken(payload.token, payload.refresh_token || '', payload.expires_in || 0)
saveUserInfo(payload.user) saveUserInfo(payload.user)
@@ -43,7 +123,10 @@ onLoad((query) => {
userStore.token = payload.token userStore.token = payload.token
userStore.userInfo = payload.user 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 = '登录成功,跳转中…' statusText.value = '登录成功,跳转中…'
// redirectTo 不保留历史,避免返回到 sso 页造成循环 // redirectTo 不保留历史,避免返回到 sso 页造成循环
setTimeout(() => { setTimeout(() => {

View File

@@ -24,6 +24,39 @@ const BASE_URL = (typeof __VITE_API_BASE__ !== 'undefined' && __VITE_API_BASE__)
// 请求超时时间(毫秒) // 请求超时时间(毫秒)
const TIMEOUT = 15000 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) { } else if (statusCode === 401) {
const isAuthEndpoint = /\/auth\/(login|register)$/.test(options.url) const isAuthEndpoint = /\/auth\/(login|register)$/.test(options.url)
const message = data?.message || '登录已过期,请重新登录' const message = data?.message || '登录已过期,请重新登录'
toastIfNotSilent({ title: message, icon: 'none', duration: 2000 }) // 外部 SSO 或会议嵌入场景:跳登录会清掉刚写入的 token且在 iframe 里跳登录纯干扰业务,静默报错
if (!isAuthEndpoint) { const isExternalEmbed = isOnSsoOrEmbeddedMeeting()
if (!isExternalEmbed) {
toastIfNotSilent({ title: message, icon: 'none', duration: 2000 })
}
if (!isAuthEndpoint && !isExternalEmbed) {
removeToken() removeToken()
setTimeout(() => { setTimeout(() => {
uni.reLaunch({ url: '/pages/auth/login' }) uni.reLaunch({ url: '/pages/auth/login' })