From b89b930c41cc4ab87b58cccb5bd8794f1709b909 Mon Sep 17 00:00:00 2001 From: duoaohui <928970622@qq.com> Date: Thu, 4 Jun 2026 14:32:52 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=B5=E8=AF=9D=E5=92=A8=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/auth/service/token_store.go | 35 +++++++++-- frontend/src/pages/cloud-law/video-call.vue | 50 +++++++++++----- frontend/src/services/websocket.js | 59 +++++++++++++++++++ frontend/src/store/meeting.js | 10 ++++ 4 files changed, 134 insertions(+), 20 deletions(-) diff --git a/backend/go-service/app/auth/service/token_store.go b/backend/go-service/app/auth/service/token_store.go index 80b5850..145de1c 100644 --- a/backend/go-service/app/auth/service/token_store.go +++ b/backend/go-service/app/auth/service/token_store.go @@ -81,13 +81,30 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, a // ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致 // clientType 用于定位正确的 Redis key(前台 vs 管理端) -// username 用于识别云律临时会议账号,对其放宽「单点登录挤号」校验 +// username 用于识别云律临时会议账号,对其放宽有状态校验 +// +// 云律临时会议账号(cloudlaw_*)说明:按手机号即时创建、默认口令、仅用于视频/语音会议、角色受限, +// 在「双方进会 / SSO 多跳 / 多端 / 重进会议」时会被重复签发,单点登录语义会把先签发的 token 挤掉; +// 叠加 Redis key 过期/内存淘汰/重启、登出清理、并发等情况,Redis 侧可能出现「不一致 / 缺失 / 查询失败」。 +// 由于调用方(中间件/WS 握手)在调用本方法前已用 ParseToken 校验过 JWT 的签名与有效期, +// 对这类临时账号只要 JWT 有效即放行(不强依赖 Redis 有状态记录),避免 web-view 进会偶发「认证已失效」。 +// 普通账号(前台用户/管理端)仍维持严格的有状态校验。 func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool { funcName := "service.token_store.ValidateAccessToken" + cloudLaw := isCloudLawUsername(username) + key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID) stored, err := s.redis.Get(ctx, key).Result() if err == redis.Nil { + // 云律临时账号:Redis 无记录(挤号后过期 / 内存淘汰 / 重启 / 登出清理)也凭有效 JWT 放行 + if cloudLaw { + logs.Debug(ctx, funcName, "云律临时账号 Redis 无 token 记录,按宽松策略放行(仅凭有效 JWT)", + zap.Int64("user_id", userID), + zap.String("client_type", clientType), + ) + return true + } logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)", zap.Int64("user_id", userID), zap.String("client_type", clientType), @@ -95,6 +112,15 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie return false } if err != nil { + // 云律临时账号:Redis 抖动/不可用时凭有效 JWT 放行,避免阻断进会 + if cloudLaw { + logs.Warn(ctx, funcName, "Redis 查询 token 失败,云律临时账号按宽松策略放行(仅凭有效 JWT)", + zap.Int64("user_id", userID), + zap.String("client_type", clientType), + zap.Error(err), + ) + return true + } logs.Error(ctx, funcName, "Redis 查询 Token 失败", zap.Int64("user_id", userID), zap.String("client_type", clientType), @@ -107,11 +133,8 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie return true } - // 云律临时会议账号:同一手机号在「双方进会 / SSO 多跳 / 多端」场景会被重复签发会话, - // 单点登录会把先签发的 token 挤掉,导致 web-view 进会报「认证已失效」。 - // 这里只要该账号在 Redis 中仍存在有效会话(stored 非空,未登出/未过期), - // 且 token 已通过 JWT 签名与有效期校验(调用方 ParseToken 已保证),即放行。 - if stored != "" && isCloudLawUsername(username) { + // 云律临时会议账号:token 已被新会话挤号(stored != token)也放行 + if cloudLaw { logs.Debug(ctx, funcName, "云律临时账号 token 已被新会话覆盖,按宽松策略放行", zap.Int64("user_id", userID), zap.String("client_type", clientType), diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue index 7805bdc..e8964f9 100644 --- a/frontend/src/pages/cloud-law/video-call.vue +++ b/frontend/src/pages/cloud-law/video-call.vue @@ -169,6 +169,9 @@ const consultId = ref('') const miniProgramReturnUrl = ref('') const cloudLawApiBase = ref('') const cloudLawToken = ref('') +// 调用方角色:'law' = 律师端(接单端),其余/空 = 用户端(applet)。 +// 律师为参会者(非主持人),结束会议走 /api/law/voiceCall/{callId}/end;用户端默认 /api/applet/voiceConnection。 +const cloudLawRole = ref('') const callMode = ref('video') const joining = ref(false) const joinError = ref('') @@ -209,6 +212,9 @@ const audioEnabled = computed(() => !!meetingStore.localAudioEnabled) const videoEnabled = computed(() => !!meetingStore.localVideoEnabled) const speakingMap = computed(() => meetingStore.speakingMap || {}) const isVoiceMode = computed(() => callMode.value === 'voice') +const isLawRole = computed(() => String(cloudLawRole.value).toLowerCase() === 'law') +// 结束会议接口基址:律师端 /api/law/voiceCall,用户端 /api/applet/voiceConnection +const voiceEndBase = computed(() => isLawRole.value ? '/api/law/voiceCall' : '/api/applet/voiceConnection') const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon) const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon) const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon) @@ -403,7 +409,7 @@ const notifyCloudLawEnd = () => { if (!callId.value || !cloudLawApiBase.value) return Promise.resolve() return new Promise(resolve => { uni.request({ - url: resolveCloudLawApiUrl(`/api/applet/voiceConnection/${encodeURIComponent(callId.value)}/end`), + url: resolveCloudLawApiUrl(`${voiceEndBase.value}/${encodeURIComponent(callId.value)}/end`), method: 'POST', data: {}, header: cloudLawToken.value ? { 'Content-Type': 'application/json', token: `Bearer ${cloudLawToken.value}` } : { 'Content-Type': 'application/json' }, @@ -499,6 +505,8 @@ const pollCommandsOnce = async () => { } const startCommandPolling = () => { + // 律师端(接单端)无 publicCommands 远程指令通道,跳过轮询避免无谓 404 + if (isLawRole.value) return if (!callId.value || commandPollHandle) return pollCommandsOnce() commandPollHandle = setInterval(pollCommandsOnce, 1200) @@ -840,21 +848,34 @@ const hangup = async () => { leaving.value = true stopCommandPolling() try { + // 先通知云律后端结束本次通话: + // - 用户端:/api/applet/voiceConnection/{callId}/end + // - 律师端:/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议(双方都收到 room.ended) await notifyCloudLawEnd() - // 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host, - // 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。 - // 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave, - // 表现就是"用户端挂机后 PC 还停在会议里")。 - console.log('[CloudLawVideoCall] hangup -> endMeeting', { - roomCode: meetingStore.currentRoom?.room_code, - hostId: meetingStore.currentRoom?.host_id, - myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id, - isHostComputed: meetingStore.isHost - }) - await meetingStore.endMeeting() + if (isLawRole.value) { + // 律师为参会者(非主持人),EndRoom 是 host-only。会议已由上面的 /end 在后端结束, + // 这里仅本地离会即可(直接 endMeeting 会因非主持人 403,徒增一次失败请求)。 + console.log('[CloudLawVideoCall] hangup(law) -> backend end + local leave', { + roomCode: meetingStore.currentRoom?.room_code, + callId: callId.value + }) + await meetingStore.leave().catch(() => {}) + } else { + // 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host, + // 用户挂机 = 咨询结束,无条件 endMeeting 让律师端收到 room.ended 自动退出。 + // 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave, + // 表现就是"用户端挂机后 PC 还停在会议里")。 + console.log('[CloudLawVideoCall] hangup -> endMeeting', { + roomCode: meetingStore.currentRoom?.room_code, + hostId: meetingStore.currentRoom?.host_id, + myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id, + isHostComputed: meetingStore.isHost + }) + await meetingStore.endMeeting() + } } catch (err) { - console.warn('[CloudLawVideoCall] endMeeting failed, fallback leave', err) - // endMeeting 失败时兜底 leave,至少自己先退出,避免卡死 + console.warn('[CloudLawVideoCall] end/hangup failed, fallback leave', err) + // 失败时兜底 leave,至少自己先退出,避免卡死 try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) } } finally { ended.value = true @@ -871,6 +892,7 @@ onLoad(query => { miniProgramReturnUrl.value = query?.miniProgramReturnUrl || query?.mini_program_return_url || '' cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || '' cloudLawToken.value = query?.cloudLawToken || query?.cloud_law_token || '' + cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || '' const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase() callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video' joinMeeting() diff --git a/frontend/src/services/websocket.js b/frontend/src/services/websocket.js index 58eba10..307b4df 100644 --- a/frontend/src/services/websocket.js +++ b/frontend/src/services/websocket.js @@ -184,6 +184,65 @@ class WebSocketService { }) } + /** + * 当前连接是否处于可发送状态(H5 下要求 readyState === OPEN) + * 与 send() 的就绪判断保持一致 + * @returns {boolean} + */ + isConnected() { + // #ifdef H5 + return !!(this.ws && this.ws.readyState === WebSocket.OPEN) + // #endif + // #ifndef H5 + return !!this.ws + // #endif + } + + /** + * 等待 WebSocket 进入可用(OPEN)状态 + * + * 场景:SSO / web-view 登录后 connect() 为异步建链(CONNECTING→OPEN)。 + * 设备预览结束立即入会时,首个 sendWithAck 可能早于 socket OPEN, + * 导致 send() 返回 -1 → 业务侧抛 "连接未就绪"。 + * 本方法在未连接时主动发起一次连接(connect 对 OPEN/CONNECTING 幂等), + * 并等待 _connected 事件;已连接则立即 resolve;超时 reject。 + * + * @param {number} [timeoutMS=10000] - 最长等待时间 + * @returns {Promise} + */ + waitForConnected(timeoutMS = 10000) { + if (this.isConnected()) return Promise.resolve(true) + // 未建链 / 已断开:主动发起一次连接(幂等,不会重复建链) + this.connect() + return new Promise((resolve, reject) => { + let settled = false + let timer = null + let poll = null + const cleanup = () => { + this.off('_connected', onConnected) + if (timer) clearTimeout(timer) + if (poll) clearInterval(poll) + } + const onConnected = () => { + if (settled) return + settled = true + cleanup() + resolve(true) + } + timer = setTimeout(() => { + if (settled) return + settled = true + cleanup() + reject({ code: -1, message: '连接会议服务器超时' }) + }, timeoutMS) + // 轮询兜底:防止 _onOpen 在 listener 注册前已触发而错过事件 + poll = setInterval(() => { + if (this.isConnected()) onConnected() + }, 100) + this.on('_connected', onConnected) + }) + } + /** * 注册事件监听 * @param {string} event - 事件类型 diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js index 6126547..4659b3e 100644 --- a/frontend/src/store/meeting.js +++ b/frontend/src/store/meeting.js @@ -975,6 +975,16 @@ export const useMeetingStore = defineStore('meeting', () => { } localState.value = MEETING_LOCAL_STATE_CONNECTING + // 入会前确保 WS 已 OPEN:SSO/web-view 登录后 connect() 为异步建链, + // 设备预览后立即入会时,首个 sendWithAck 可能早于 socket OPEN, + // 表现为 "加入失败:连接未就绪"。此处显式等待(必要时主动发起连接),最多 10s。 + try { + await wsService.waitForConnected(10000) + } catch (e) { + _log('error', '[Meeting] 入会前等待 WS 连接超时', e) + throw new Error('连接会议服务器失败,请检查网络后重试') + } + const userStore = useUserStore() const uid = userStore.userInfo?.id if (!uid) throw new Error('当前用户未登录')