电话咨询
This commit is contained in:
@@ -81,13 +81,30 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, a
|
|||||||
|
|
||||||
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
|
// ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致
|
||||||
// clientType 用于定位正确的 Redis key(前台 vs 管理端)
|
// 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 {
|
func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool {
|
||||||
funcName := "service.token_store.ValidateAccessToken"
|
funcName := "service.token_store.ValidateAccessToken"
|
||||||
|
|
||||||
|
cloudLaw := isCloudLawUsername(username)
|
||||||
|
|
||||||
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID)
|
||||||
stored, err := s.redis.Get(ctx, key).Result()
|
stored, err := s.redis.Get(ctx, key).Result()
|
||||||
if err == redis.Nil {
|
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 不存在(已登出或过期)",
|
logs.Debug(ctx, funcName, "Token 不存在(已登出或过期)",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
zap.String("client_type", clientType),
|
zap.String("client_type", clientType),
|
||||||
@@ -95,6 +112,15 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if err != nil {
|
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 失败",
|
logs.Error(ctx, funcName, "Redis 查询 Token 失败",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
zap.String("client_type", clientType),
|
zap.String("client_type", clientType),
|
||||||
@@ -107,11 +133,8 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 云律临时会议账号:同一手机号在「双方进会 / SSO 多跳 / 多端」场景会被重复签发会话,
|
// 云律临时会议账号:token 已被新会话挤号(stored != token)也放行
|
||||||
// 单点登录会把先签发的 token 挤掉,导致 web-view 进会报「认证已失效」。
|
if cloudLaw {
|
||||||
// 这里只要该账号在 Redis 中仍存在有效会话(stored 非空,未登出/未过期),
|
|
||||||
// 且 token 已通过 JWT 签名与有效期校验(调用方 ParseToken 已保证),即放行。
|
|
||||||
if stored != "" && isCloudLawUsername(username) {
|
|
||||||
logs.Debug(ctx, funcName, "云律临时账号 token 已被新会话覆盖,按宽松策略放行",
|
logs.Debug(ctx, funcName, "云律临时账号 token 已被新会话覆盖,按宽松策略放行",
|
||||||
zap.Int64("user_id", userID),
|
zap.Int64("user_id", userID),
|
||||||
zap.String("client_type", clientType),
|
zap.String("client_type", clientType),
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ const consultId = ref('')
|
|||||||
const miniProgramReturnUrl = ref('')
|
const miniProgramReturnUrl = ref('')
|
||||||
const cloudLawApiBase = ref('')
|
const cloudLawApiBase = ref('')
|
||||||
const cloudLawToken = ref('')
|
const cloudLawToken = ref('')
|
||||||
|
// 调用方角色:'law' = 律师端(接单端),其余/空 = 用户端(applet)。
|
||||||
|
// 律师为参会者(非主持人),结束会议走 /api/law/voiceCall/{callId}/end;用户端默认 /api/applet/voiceConnection。
|
||||||
|
const cloudLawRole = ref('')
|
||||||
const callMode = ref('video')
|
const callMode = ref('video')
|
||||||
const joining = ref(false)
|
const joining = ref(false)
|
||||||
const joinError = ref('')
|
const joinError = ref('')
|
||||||
@@ -209,6 +212,9 @@ const audioEnabled = computed(() => !!meetingStore.localAudioEnabled)
|
|||||||
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
|
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
|
||||||
const speakingMap = computed(() => meetingStore.speakingMap || {})
|
const speakingMap = computed(() => meetingStore.speakingMap || {})
|
||||||
const isVoiceMode = computed(() => callMode.value === 'voice')
|
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 cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon)
|
||||||
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
|
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
|
||||||
const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon)
|
const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon)
|
||||||
@@ -403,7 +409,7 @@ const notifyCloudLawEnd = () => {
|
|||||||
if (!callId.value || !cloudLawApiBase.value) return Promise.resolve()
|
if (!callId.value || !cloudLawApiBase.value) return Promise.resolve()
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: resolveCloudLawApiUrl(`/api/applet/voiceConnection/${encodeURIComponent(callId.value)}/end`),
|
url: resolveCloudLawApiUrl(`${voiceEndBase.value}/${encodeURIComponent(callId.value)}/end`),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {},
|
data: {},
|
||||||
header: cloudLawToken.value ? { 'Content-Type': 'application/json', token: `Bearer ${cloudLawToken.value}` } : { 'Content-Type': 'application/json' },
|
header: cloudLawToken.value ? { 'Content-Type': 'application/json', token: `Bearer ${cloudLawToken.value}` } : { 'Content-Type': 'application/json' },
|
||||||
@@ -499,6 +505,8 @@ const pollCommandsOnce = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startCommandPolling = () => {
|
const startCommandPolling = () => {
|
||||||
|
// 律师端(接单端)无 publicCommands 远程指令通道,跳过轮询避免无谓 404
|
||||||
|
if (isLawRole.value) return
|
||||||
if (!callId.value || commandPollHandle) return
|
if (!callId.value || commandPollHandle) return
|
||||||
pollCommandsOnce()
|
pollCommandsOnce()
|
||||||
commandPollHandle = setInterval(pollCommandsOnce, 1200)
|
commandPollHandle = setInterval(pollCommandsOnce, 1200)
|
||||||
@@ -840,9 +848,21 @@ const hangup = async () => {
|
|||||||
leaving.value = true
|
leaving.value = true
|
||||||
stopCommandPolling()
|
stopCommandPolling()
|
||||||
try {
|
try {
|
||||||
|
// 先通知云律后端结束本次通话:
|
||||||
|
// - 用户端:/api/applet/voiceConnection/{callId}/end
|
||||||
|
// - 律师端:/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议(双方都收到 room.ended)
|
||||||
await notifyCloudLawEnd()
|
await notifyCloudLawEnd()
|
||||||
|
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,
|
// 云律视频咨询:发起人(小程序端用户)在 CreateRoomForInternal 时被加成 host,
|
||||||
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师 iframe 收到 room.ended 自动退出。
|
// 用户挂机 = 咨询结束,无条件 endMeeting 让律师端收到 room.ended 自动退出。
|
||||||
// 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave,
|
// 不依赖 meetingStore.isHost(host_id/userId 偶发类型不一致导致误判 false 只走 leave,
|
||||||
// 表现就是"用户端挂机后 PC 还停在会议里")。
|
// 表现就是"用户端挂机后 PC 还停在会议里")。
|
||||||
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
|
console.log('[CloudLawVideoCall] hangup -> endMeeting', {
|
||||||
@@ -852,9 +872,10 @@ const hangup = async () => {
|
|||||||
isHostComputed: meetingStore.isHost
|
isHostComputed: meetingStore.isHost
|
||||||
})
|
})
|
||||||
await meetingStore.endMeeting()
|
await meetingStore.endMeeting()
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[CloudLawVideoCall] endMeeting failed, fallback leave', err)
|
console.warn('[CloudLawVideoCall] end/hangup failed, fallback leave', err)
|
||||||
// endMeeting 失败时兜底 leave,至少自己先退出,避免卡死
|
// 失败时兜底 leave,至少自己先退出,避免卡死
|
||||||
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
|
try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
|
||||||
} finally {
|
} finally {
|
||||||
ended.value = true
|
ended.value = true
|
||||||
@@ -871,6 +892,7 @@ onLoad(query => {
|
|||||||
miniProgramReturnUrl.value = query?.miniProgramReturnUrl || query?.mini_program_return_url || ''
|
miniProgramReturnUrl.value = query?.miniProgramReturnUrl || query?.mini_program_return_url || ''
|
||||||
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
|
cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
|
||||||
cloudLawToken.value = query?.cloudLawToken || query?.cloud_law_token || ''
|
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()
|
const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase()
|
||||||
callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
|
callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
|
||||||
joinMeeting()
|
joinMeeting()
|
||||||
|
|||||||
@@ -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<boolean>}
|
||||||
|
*/
|
||||||
|
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 - 事件类型
|
* @param {string} event - 事件类型
|
||||||
|
|||||||
@@ -975,6 +975,16 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
}
|
}
|
||||||
localState.value = MEETING_LOCAL_STATE_CONNECTING
|
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 userStore = useUserStore()
|
||||||
const uid = userStore.userInfo?.id
|
const uid = userStore.userInfo?.id
|
||||||
if (!uid) throw new Error('当前用户未登录')
|
if (!uid) throw new Error('当前用户未登录')
|
||||||
|
|||||||
Reference in New Issue
Block a user