feat: 添加 H5 端 openid 一致性检查,更新路由守卫逻辑以清除不一致的缓存

This commit is contained in:
2026-06-25 18:00:00 +08:00
parent 8bfdf37a07
commit 95f5879c9a
2 changed files with 162 additions and 134 deletions

View File

@@ -159,6 +159,18 @@ server: {
- CRMEB 客户端**任何异常都优雅降级**(返回空昵称/头像,不阻断登录),并 `logger.warning` 记录,便于排查。 - CRMEB 客户端**任何异常都优雅降级**(返回空昵称/头像,不阻断登录),并 `logger.warning` 记录,便于排查。
- `DEV_MODE=1` 才启用 `POST /api/auth/dev-login`,生产必须为 `0`。 - `DEV_MODE=1` 才启用 `POST /api/auth/dev-login`,生产必须为 `0`。
- **CRMEB 匹配口径**:openid 按 appid 隔离。若 CRMEB 与小程序非同一 appid,应改用 **unionid** 匹配(小程序传 unionid,CRMEB 内部接口按 unionid 查;openid 仍作本系统主键)。 - **CRMEB 匹配口径**:openid 按 appid 隔离。若 CRMEB 与小程序非同一 appid,应改用 **unionid** 匹配(小程序传 unionid,CRMEB 内部接口按 unionid 查;openid 仍作本系统主键)。
- **H5 端 openid 一致性检查**(分享场景必做):当 H5 被其他用户分享的链接打开时,web-view 的 `localStorage` 可能残留原用户的 token。H5 的路由守卫(如 `ProtectedRoute`)必须比对**当前 URL 中的 openid** 与 **本地缓存 token 中的 openid**,不一致时清除缓存重新走登录流程:
```tsx
const urlOpenid = new URLSearchParams(window.location.search).get('openid');
if (urlOpenid && token) {
const payload = decodeToken(token);
if (payload.openid !== urlOpenid) {
clearToken(); // 清除原用户缓存
return <Navigate to="/" replace />; // 重新登录
}
}
```
> 否则会出现:B 打开 A 分享的链接,URL 中已是 B 的 openid,但 H5 读取到 A 的缓存 token,仍然展示 A 的数据。
--- ---
@@ -776,3 +788,4 @@ async function handleCopyLink(url: string) {
- [ ] WebView Bridge: H5 复制链接优先用 `document.execCommand('copy')`,bridge 兜底 - [ ] WebView Bridge: H5 复制链接优先用 `document.execCommand('copy')`,bridge 兜底
- [ ] WebView Bridge: 确认微信 JS-SDK 已加载(`jweixin-1.2.0.js`),否则 `wx.miniProgram` 不可用 - [ ] WebView Bridge: 确认微信 JS-SDK 已加载(`jweixin-1.2.0.js`),否则 `wx.miniProgram` 不可用
- [ ] WebView Bridge: 生成分享图片场景使用 `html-to-image` 等截图库 - [ ] WebView Bridge: 生成分享图片场景使用 `html-to-image` 等截图库
- [ ] H5 身份一致性: 路由守卫中比对 URL openid 与缓存 token openid,不一致时清除缓存重新登录

View File

@@ -1,134 +1,149 @@
import { createBrowserRouter, Navigate, Outlet } from "react-router"; import { createBrowserRouter, Navigate, Outlet } from "react-router";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { OnboardingPage } from "./pages/OnboardingPage"; import { OnboardingPage } from "./pages/OnboardingPage";
import { HomePage } from "./pages/HomePage"; import { HomePage } from "./pages/HomePage";
import { CameraCapturePage } from "./pages/CameraCapturePage"; import { CameraCapturePage } from "./pages/CameraCapturePage";
import { DataVerificationPage } from "./pages/DataVerificationPage"; import { DataVerificationPage } from "./pages/DataVerificationPage";
import { AIAnalysisPage } from "./pages/AIAnalysisPage"; import { AIAnalysisPage } from "./pages/AIAnalysisPage";
import { ReportDetailPage } from "./pages/ReportDetailPage"; import { ReportDetailPage } from "./pages/ReportDetailPage";
import { DevNavigationPage } from "./pages/DevNavigationPage"; import { DevNavigationPage } from "./pages/DevNavigationPage";
import { AIChatPage } from "./pages/AIChatPage"; import { AIChatPage } from "./pages/AIChatPage";
import { HealthRecordsPage } from "./pages/HealthRecordsPage"; import { HealthRecordsPage } from "./pages/HealthRecordsPage";
import { GrowthCurvePage } from "./pages/GrowthCurvePage"; import { GrowthCurvePage } from "./pages/GrowthCurvePage";
import { TimelinePage } from "./pages/TimelinePage"; import { TimelinePage } from "./pages/TimelinePage";
import { CalendarPage } from "./pages/CalendarPage"; import { CalendarPage } from "./pages/CalendarPage";
import { DoctorQuestionsPage } from "./pages/DoctorQuestionsPage"; import { DoctorQuestionsPage } from "./pages/DoctorQuestionsPage";
import { SharePage } from "./pages/SharePage"; import { SharePage } from "./pages/SharePage";
import { ExpertConsultationPage } from "./pages/ExpertConsultationPage"; import { ExpertConsultationPage } from "./pages/ExpertConsultationPage";
import { ProfilePage } from "./pages/ProfilePage"; import { ProfilePage } from "./pages/ProfilePage";
import { VoiceRecordPage } from "./pages/VoiceRecordPage"; import { VoiceRecordPage } from "./pages/VoiceRecordPage";
import { NotificationPage } from "./pages/NotificationPage"; import { NotificationPage } from "./pages/NotificationPage";
import { MobileLayout } from "./components/MobileLayout"; import { MobileLayout } from "./components/MobileLayout";
import { getToken } from "./services/api"; import { getToken, clearToken, decodeToken } from "./services/api";
/** 路由守卫:未登录跳转登录页 */ /** 路由守卫:未登录跳转登录页 */
function ProtectedRoute() { function ProtectedRoute() {
const token = getToken(); const token = getToken();
const urlToken = new URLSearchParams(window.location.search).get('token'); const urlToken = new URLSearchParams(window.location.search).get('token');
const urlOpenid = new URLSearchParams(window.location.search).get('openid');
// 来自小程序的 token → 让 LoginPage 处理
if (urlToken) { // 来自小程序的 token → 让 LoginPage 处理
return <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />; if (urlToken) {
} return <Navigate to={`/?token=${encodeURIComponent(urlToken)}`} replace />;
}
if (!token) {
return <Navigate to="/" replace />; // URL 中有 openid 且与本地缓存的 token 中的 openid 不一致 → 清除缓存重新登录
} if (urlOpenid && token) {
return <Outlet />; try {
} const payload = decodeToken(token);
if (payload.openid !== urlOpenid) {
export const router = createBrowserRouter([ console.log('[auth] openid 不一致: URL=' + urlOpenid + ', cache=' + payload.openid + ',清除缓存');
{ clearToken();
// 全局 H5 移动端布局容器 return <Navigate to="/" replace />;
element: <MobileLayout />, }
children: [ } catch (e) {
// 公开路由 console.warn('[auth] token 解码失败', e);
{ }
path: "/dev", }
Component: DevNavigationPage,
}, if (!token) {
{ return <Navigate to="/" replace />;
path: "/", }
Component: LoginPage, return <Outlet />;
}, }
{
path: "/onboarding", export const router = createBrowserRouter([
Component: OnboardingPage, {
}, // 全局 H5 移动端布局容器
// 需要登录的路由 element: <MobileLayout />,
{ children: [
element: <ProtectedRoute />, // 公开路由
children: [ {
{ path: "/dev",
path: "/home", Component: DevNavigationPage,
Component: HomePage, },
}, {
{ path: "/",
path: "/profile", Component: LoginPage,
Component: ProfilePage, },
}, {
{ path: "/onboarding",
path: "/camera", Component: OnboardingPage,
Component: CameraCapturePage, },
}, // 需要登录的路由
{ {
path: "/review", element: <ProtectedRoute />,
Component: DataVerificationPage, children: [
}, {
{ path: "/home",
path: "/verify", Component: HomePage,
Component: DataVerificationPage, },
}, {
{ path: "/profile",
path: "/analysis/:reportId", Component: ProfilePage,
Component: AIAnalysisPage, },
}, {
{ path: "/camera",
path: "/report/:reportId", Component: CameraCapturePage,
Component: ReportDetailPage, },
}, {
{ path: "/review",
path: "/chat/:reportId", Component: DataVerificationPage,
Component: AIChatPage, },
}, {
{ path: "/verify",
path: "/voice-record", Component: DataVerificationPage,
Component: VoiceRecordPage, },
}, {
{ path: "/analysis/:reportId",
path: "/health-records", Component: AIAnalysisPage,
Component: HealthRecordsPage, },
}, {
{ path: "/report/:reportId",
path: "/growth-curve", Component: ReportDetailPage,
Component: GrowthCurvePage, },
}, {
{ path: "/chat/:reportId",
path: "/timeline", Component: AIChatPage,
Component: TimelinePage, },
}, {
{ path: "/voice-record",
path: "/calendar", Component: VoiceRecordPage,
Component: CalendarPage, },
}, {
{ path: "/health-records",
path: "/doctor-questions/:reportId", Component: HealthRecordsPage,
Component: DoctorQuestionsPage, },
}, {
{ path: "/growth-curve",
path: "/share/:reportId", Component: GrowthCurvePage,
Component: SharePage, },
}, {
{ path: "/timeline",
path: "/expert-consultation", Component: TimelinePage,
Component: ExpertConsultationPage, },
}, {
{ path: "/calendar",
path: "/notifications", Component: CalendarPage,
Component: NotificationPage, },
}, {
], path: "/doctor-questions/:reportId",
}, Component: DoctorQuestionsPage,
], },
}, {
], { basename: "/app_03" }); path: "/share/:reportId",
Component: SharePage,
},
{
path: "/expert-consultation",
Component: ExpertConsultationPage,
},
{
path: "/notifications",
Component: NotificationPage,
},
],
},
],
},
], { basename: "/app_03" });