From 3b51aba8ec095b44eb39e9f24f9b438857eaa7c8 Mon Sep 17 00:00:00 2001 From: duoaohui <928970622@qq.com> Date: Thu, 4 Jun 2026 12:23:14 +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/auth_service.go | 7 +- .../app/auth/service/token_store.go | 32 ++++++- backend/go-service/app/ws/handler.go | 4 +- backend/go-service/pkg/middleware/auth.go | 4 +- frontend/index.html | 3 + .../src/components/meeting/MeetingToolbar.vue | 84 ++++++++++++------- frontend/src/pages/cloud-law/video-call.vue | 28 +++++-- frontend/src/pages/meeting/room.vue | 58 ++++++++++++- 8 files changed, 172 insertions(+), 48 deletions(-) diff --git a/backend/go-service/app/auth/service/auth_service.go b/backend/go-service/app/auth/service/auth_service.go index d481faa..5b9b0f4 100644 --- a/backend/go-service/app/auth/service/auth_service.go +++ b/backend/go-service/app/auth/service/auth_service.go @@ -298,9 +298,10 @@ func (s *AuthService) Logout(ctx context.Context, userID int64, clientType strin } // ValidateAccessToken 校验 Access Token 是否在 Redis 中有效 -// 供 JWT 中间件调用,实现有状态 JWT 验证,按 clientType 隔离校验 -func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool { - return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token) +// 供 JWT 中间件 / WS 握手调用,实现有状态 JWT 验证,按 clientType 隔离校验; +// username 用于识别云律临时会议账号并放宽「单点登录挤号」校验 +func (s *AuthService) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool { + return s.tokenStore.ValidateAccessToken(ctx, userID, clientType, token, username) } // GetProfile 获取用户个人信息 diff --git a/backend/go-service/app/auth/service/token_store.go b/backend/go-service/app/auth/service/token_store.go index 2677281..80b5850 100644 --- a/backend/go-service/app/auth/service/token_store.go +++ b/backend/go-service/app/auth/service/token_store.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "strings" "time" "github.com/echochat/backend/config" @@ -17,8 +18,18 @@ import ( const ( keyPrefixAccessToken = "echo:auth:token:" // echo:auth:token:{client_type}:{user_id} keyPrefixRefreshToken = "echo:auth:refresh:" // echo:auth:refresh:{client_type}:{user_id} + + // cloudLawUsernamePrefix 云律临时会议账号用户名前缀(与 meeting.cloudLawUsername 保持一致)。 + // 这类账号按手机号即时创建、仅用于视频/语音会议,会在「双方进会 / SSO 多跳 / 多端」时被重复签发, + // 单点登录语义会把先签发的 token 挤掉。对其放宽校验,避免 web-view 进会误报「认证已失效」。 + cloudLawUsernamePrefix = "cloudlaw_" ) +// isCloudLawUsername 判断是否为云律临时会议账号 +func isCloudLawUsername(username string) bool { + return strings.HasPrefix(username, cloudLawUsernamePrefix) +} + // TokenStore 管理 Token 在 Redis 中的存取 // 实现有状态 JWT:登录存入、验证时校验、登出时删除 type TokenStore struct { @@ -70,7 +81,8 @@ func (s *TokenStore) SaveTokens(ctx context.Context, userID int64, clientType, a // ValidateAccessToken 校验 Access Token 是否与 Redis 中存储的一致 // clientType 用于定位正确的 Redis key(前台 vs 管理端) -func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool { +// username 用于识别云律临时会议账号,对其放宽「单点登录挤号」校验 +func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool { funcName := "service.token_store.ValidateAccessToken" key := fmt.Sprintf("%s%s:%d", keyPrefixAccessToken, clientType, userID) @@ -91,7 +103,23 @@ func (s *TokenStore) ValidateAccessToken(ctx context.Context, userID int64, clie return false } - return stored == token + if stored == token { + return true + } + + // 云律临时会议账号:同一手机号在「双方进会 / SSO 多跳 / 多端」场景会被重复签发会话, + // 单点登录会把先签发的 token 挤掉,导致 web-view 进会报「认证已失效」。 + // 这里只要该账号在 Redis 中仍存在有效会话(stored 非空,未登出/未过期), + // 且 token 已通过 JWT 签名与有效期校验(调用方 ParseToken 已保证),即放行。 + if stored != "" && isCloudLawUsername(username) { + logs.Debug(ctx, funcName, "云律临时账号 token 已被新会话覆盖,按宽松策略放行", + zap.Int64("user_id", userID), + zap.String("client_type", clientType), + ) + return true + } + + return false } // ValidateRefreshToken 校验 Refresh Token 是否与 Redis 中存储的一致 diff --git a/backend/go-service/app/ws/handler.go b/backend/go-service/app/ws/handler.go index d50d57b..743d6b4 100644 --- a/backend/go-service/app/ws/handler.go +++ b/backend/go-service/app/ws/handler.go @@ -20,7 +20,7 @@ import ( // TokenValidator 有状态 JWT 验证接口(检查 Token 是否在 Redis 中有效) // 由 auth.AuthService 实现,用于防止已登出用户建立 WebSocket 连接 type TokenValidator interface { - ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool + ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool } // OfflineMessagePusher 离线消息推送接口 @@ -160,7 +160,7 @@ func (h *Handler) Upgrade(c *gin.Context) { if clientType == "" { clientType = "frontend" } - if h.tokenValidator != nil && !h.tokenValidator.ValidateAccessToken(c.Request.Context(), claims.UserID, clientType, token) { + if h.tokenValidator != nil && !h.tokenValidator.ValidateAccessToken(c.Request.Context(), claims.UserID, clientType, token, claims.Username) { logs.Warn(nil, funcName, "WebSocket Token 已失效(Redis 校验)", zap.Int64("user_id", claims.UserID)) utils.ResponseUnauthorized(c, "认证已失效,请重新登录") diff --git a/backend/go-service/pkg/middleware/auth.go b/backend/go-service/pkg/middleware/auth.go index 1696ba2..4c67d18 100644 --- a/backend/go-service/pkg/middleware/auth.go +++ b/backend/go-service/pkg/middleware/auth.go @@ -21,7 +21,7 @@ const ( // TokenValidator Token 有效性校验接口 // 由 AuthService 实现,中间件通过此接口检查 Token 是否在 Redis 中有效 type TokenValidator interface { - ValidateAccessToken(ctx context.Context, userID int64, clientType, token string) bool + ValidateAccessToken(ctx context.Context, userID int64, clientType, token, username string) bool } // JWTAuth JWT 认证中间件(有状态 JWT) @@ -70,7 +70,7 @@ func JWTAuth(jwtCfg *config.JWTConfig, validator TokenValidator) gin.HandlerFunc } // 校验 Token 是否在 Redis 中有效(有状态 JWT 核心逻辑,按 clientType 隔离) - if !validator.ValidateAccessToken(ctx, claims.UserID, clientType, tokenStr) { + if !validator.ValidateAccessToken(ctx, claims.UserID, clientType, tokenStr, claims.Username) { logs.Warn(ctx, funcName, "Token 已失效(已登出或被覆盖)", zap.Int64("user_id", claims.UserID), zap.String("client_type", clientType), diff --git a/frontend/index.html b/frontend/index.html index fb81ddc..31d3b49 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,6 +10,9 @@ (coverSupport ? ', viewport-fit=cover' : '') + '" />') + + diff --git a/frontend/src/components/meeting/MeetingToolbar.vue b/frontend/src/components/meeting/MeetingToolbar.vue index 8e0ae4a..f31ce1b 100644 --- a/frontend/src/components/meeting/MeetingToolbar.vue +++ b/frontend/src/components/meeting/MeetingToolbar.vue @@ -245,15 +245,21 @@ const emitIf = (name) => { width: 72rpx; height: 72rpx; border-radius: 50%; - background: rgba(55, 65, 81, 0.9); + background: linear-gradient(180deg, rgba(71, 85, 105, 0.95), rgba(51, 65, 81, 0.95)); display: flex; align-items: center; justify-content: center; color: #FFFFFF; - transition: background-color 0.15s ease; + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.12), 0 4rpx 10rpx rgba(0, 0, 0, 0.28); + transition: background 0.18s ease, transform 0.12s ease, box-shadow 0.18s ease; } -.btn.active .icon { background: #3B82F6; } +.btn.active .icon { + background: linear-gradient(180deg, #4F8DFF, #2563EB); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.28), 0 4rpx 14rpx rgba(37, 99, 235, 0.5); +} +.btn.active .label { color: #BFDBFE; } .btn.loading .icon { opacity: 0.6; } +.btn:active .icon { transform: scale(0.92); } .label { font-size: 22rpx; @@ -262,8 +268,12 @@ const emitIf = (name) => { } .label-mobile { display: none; } -.btn-leave .icon { background: #EF4444; } -.btn-leave:hover .icon { background: #DC2626; } +.btn-leave .icon { + background: linear-gradient(180deg, #FB5B5B, #EF4444); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.28), 0 4rpx 14rpx rgba(239, 68, 68, 0.5); +} +.btn-leave:hover .icon { background: linear-gradient(180deg, #F87171, #DC2626); } +.btn-leave .label { color: #FECACA; } /* Phase B 录制按钮:录制中红色脉动 */ .btn-recording.is-recording .icon { @@ -283,16 +293,17 @@ const emitIf = (name) => { height: 32rpx; padding: 0 6rpx; border-radius: 999rpx; - background: #10B981; + background: linear-gradient(180deg, #34D399, #10B981); color: #FFFFFF; font-size: 18rpx; - font-weight: 600; + font-weight: 700; display: inline-flex; align-items: center; justify-content: center; - box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.4); + border: 2rpx solid rgba(13, 21, 38, 0.95); + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.45); } -.badge.badge-red { background: #EF4444; } +.badge.badge-red { background: linear-gradient(180deg, #FB7185, #EF4444); } @media (max-width: 750px) { .toolbar { @@ -301,52 +312,61 @@ const emitIf = (name) => { right: 14rpx; bottom: calc(12rpx + env(safe-area-inset-bottom)); justify-content: space-between; - gap: 6rpx; + gap: 4rpx; box-sizing: border-box; - padding: 14rpx 14rpx; - background: rgba(7, 15, 30, 0.9); - backdrop-filter: blur(18px); - border: 1rpx solid rgba(148, 163, 184, 0.18); - border-radius: 32rpx; - box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.42); + padding: 18rpx 12rpx; + background: linear-gradient(180deg, rgba(17, 26, 46, 0.92), rgba(7, 15, 30, 0.94)); + backdrop-filter: blur(20px); + border: 1rpx solid rgba(148, 163, 184, 0.2); + border-radius: 34rpx; + box-shadow: 0 16rpx 40rpx rgba(0, 0, 0, 0.48), inset 0 1rpx 0 rgba(255, 255, 255, 0.06); overflow-x: hidden; overflow-y: hidden; } .btn { min-width: 0; flex: 1 1 0; - gap: 6rpx; + gap: 10rpx; padding: 4rpx 0; border-radius: 18rpx; } .btn:hover { background: transparent; } - .btn:active { background: rgba(255, 255, 255, 0.08); } + .btn:active { background: transparent; } + .btn:active .icon { transform: scale(0.9); } .btn-screen { display: none; } .icon { - width: 56rpx; - height: 56rpx; - background: rgba(51, 65, 85, 0.92); - box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.08); + width: 64rpx; + height: 64rpx; + background: linear-gradient(180deg, rgba(71, 85, 105, 0.95), rgba(44, 56, 78, 0.96)); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.12), 0 6rpx 16rpx rgba(0, 0, 0, 0.36); } .icon svg { - width: 40rpx; - height: 40rpx; + width: 42rpx; + height: 42rpx; + } + .btn.active .icon { + background: linear-gradient(180deg, #4F8DFF, #2563EB); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.3), 0 6rpx 18rpx rgba(37, 99, 235, 0.55); } - .btn.active .icon { background: #2563EB; } .btn[disabled] .icon { background: rgba(30, 41, 59, 0.72); color: rgba(255, 255, 255, 0.38); box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.05); } .btn[disabled] .label { color: rgba(255, 255, 255, 0.36); } - .btn-leave .icon { background: #EF4444; } + .btn-leave .icon { + background: linear-gradient(180deg, #FB5B5B, #EF4444); + box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.3), 0 6rpx 18rpx rgba(239, 68, 68, 0.55); + } .btn-leave .label { color: #FECACA; } .label { max-width: 100%; - font-size: 19rpx; + font-size: 20rpx; line-height: 1; + font-weight: 500; + color: rgba(255, 255, 255, 0.74); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -370,15 +390,15 @@ const emitIf = (name) => { padding-right: 8rpx; } .icon { - width: 50rpx; - height: 50rpx; + width: 56rpx; + height: 56rpx; } .icon svg { - width: 36rpx; - height: 36rpx; + width: 38rpx; + height: 38rpx; } .label { - font-size: 17rpx; + font-size: 18rpx; } } diff --git a/frontend/src/pages/cloud-law/video-call.vue b/frontend/src/pages/cloud-law/video-call.vue index 77109d2..7805bdc 100644 --- a/frontend/src/pages/cloud-law/video-call.vue +++ b/frontend/src/pages/cloud-law/video-call.vue @@ -427,25 +427,43 @@ const buildMiniProgramReturnUrl = () => { } let endedRedirectTimer = null +let miniProgramRedirected = false + +const getMiniProgram = () => { + return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null +} const notifyMiniProgramCallEnded = () => { + const returnUrl = buildMiniProgramReturnUrl() const payload = { type: 'cloudLawCallEnded', action: 'cloudLawCallEnded', callId: callId.value, consultId: consultId.value, - returnUrl: buildMiniProgramReturnUrl() + returnUrl } + const miniProgram = getMiniProgram() try { - const miniProgram = typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null + // postMessage 只在 web-view 销毁/分享等时机投递;先发一份,作为小程序侧 onMessage 的兜底 if (miniProgram && typeof miniProgram.postMessage === 'function') { miniProgram.postMessage({ data: payload }) } - if (miniProgram && typeof miniProgram.redirectTo === 'function' && payload.returnUrl) { - miniProgram.redirectTo({ url: payload.returnUrl }) + } catch (e) { + console.warn('[CloudLawVideoCall] postMessage failed', e) + } + if (miniProgramRedirected) return + try { + if (miniProgram && returnUrl && typeof miniProgram.redirectTo === 'function') { + miniProgramRedirected = true + miniProgram.redirectTo({ url: returnUrl }) + } else if (miniProgram && typeof miniProgram.navigateBack === 'function') { + // 没有可跳转的咨询详情地址时,至少退回上一页(等待页),由小程序侧轮询兜底跳转 + miniProgramRedirected = true + miniProgram.navigateBack({ delta: 1 }) } } catch (e) { - console.warn('[CloudLawVideoCall] notify mini program failed', e) + miniProgramRedirected = false + console.warn('[CloudLawVideoCall] redirect mini program failed', e) } } diff --git a/frontend/src/pages/meeting/room.vue b/frontend/src/pages/meeting/room.vue index b582955..b73de3e 100644 --- a/frontend/src/pages/meeting/room.vue +++ b/frontend/src/pages/meeting/room.vue @@ -739,15 +739,21 @@ const onLeaveClick = () => { leaveDialogVisible.value = true } const confirmLeaveSelf = async () => { leaveDialogVisible.value = false + let failed = false try { await meetingStore.leave() + } catch (err) { + failed = true + uni.showToast({ title: err?.message || '操作失败', icon: 'none' }) } finally { - redirectHome() + leaveAndExit(failed ? '' : '已离开会议') } } const confirmEndOrLeave = async () => { leaveDialogVisible.value = false + const tip = isHost.value ? '会议已结束' : '已离开会议' + let failed = false try { if (isHost.value) { await meetingStore.endMeeting() @@ -757,13 +763,61 @@ const confirmEndOrLeave = async () => { await meetingStore.leave() } } catch (err) { + failed = true uni.showToast({ title: err?.message || '操作失败', icon: 'none' }) } finally { - redirectHome() + leaveAndExit(failed ? '' : tip) } } +// 是否运行在云律小程序 web-view 内(SSO 进入时打的标记) +const isCloudLawEmbed = () => { + try { + return typeof window !== 'undefined' + && !!window.sessionStorage + && window.sessionStorage.getItem('echo_cloud_law_meeting_embed') === '1' + } catch (e) { + return false + } +} + +const getMiniProgram = () => { + return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null +} + +// 退出会议视频界面:先给提示,再退出。 +// 在云律小程序 web-view 内 → 通知并 navigateBack 回到小程序(关闭会议 web-view 页); +// 普通浏览器/PC → 回 EchoChat 首页。 +const leaveAndExit = (tip) => { + if (tip) { + uni.showToast({ title: tip, icon: 'none', duration: 1200 }) + } + // 留一点时间让提示可见,再退出视频界面 + setTimeout(redirectHome, tip ? 600 : 200) +} + +let meetingExited = false const redirectHome = () => { + if (meetingExited) return + const miniProgram = getMiniProgram() + if (isCloudLawEmbed() && miniProgram) { + meetingExited = true + try { + miniProgram.postMessage({ data: { type: 'cloudLawCallEnded', action: 'cloudLawCallEnded' } }) + } catch (e) {} + try { + if (typeof miniProgram.navigateBack === 'function') { + miniProgram.navigateBack({ delta: 1 }) + return + } + if (typeof miniProgram.redirectTo === 'function') { + miniProgram.redirectTo({ url: '/pages/index/home_page/home_page' }) + return + } + } catch (e) { + meetingExited = false + } + } uni.reLaunch({ url: '/pages/index/index' }) }