视频会议
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,9 +13,34 @@ 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'
|
||||||
|
|
||||||
|
function _isSsoLaunch(options) {
|
||||||
|
// 来自外部系统 SSO 桥接:onLaunch 阶段不应使用 localStorage 旧 token 调任何接口,
|
||||||
|
// 避免触发 request.js 401 → reLaunch 到登录页。SSO 页 onLoad 完成 token 写入后再统一初始化。
|
||||||
|
if (!options) return false
|
||||||
|
const path = String(options.path || '')
|
||||||
|
const url = String(options.query && options.query.path ? options.query.path : '')
|
||||||
|
if (path.indexOf('pages/auth/sso') >= 0 || url.indexOf('pages/auth/sso') >= 0) return true
|
||||||
|
// #ifdef H5
|
||||||
|
try {
|
||||||
|
const hash = String(window.location?.hash || '')
|
||||||
|
const search = String(window.location?.search || '')
|
||||||
|
return hash.indexOf('/pages/auth/sso') >= 0
|
||||||
|
|| search.indexOf('pages/auth/sso') >= 0
|
||||||
|
|| search.indexOf('cloudLawSso=') >= 0
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
onLaunch() {
|
onLaunch(options) {
|
||||||
console.log('App Launch')
|
console.log('App Launch', options)
|
||||||
|
if (_isSsoLaunch(options)) {
|
||||||
|
console.log('[App] launched via SSO, defer global init until token refresh')
|
||||||
|
return
|
||||||
|
}
|
||||||
this._initGlobalWS()
|
this._initGlobalWS()
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
Reference in New Issue
Block a user