Vite/React 运营管理台复盘:路由权限、图片上传与列表竞态
本文目录
本文基于一个个人运营管理台的默认分支源码做静态审阅,只讨论可复用的工程经验。仓库名、业务主体、服务地址、上传凭据和用户数据均不在本文中;原项目没有被修改。没有安装依赖、运行构建、登录真实服务或验证生产配置,所列问题是源码证据与风险推断,不是线上事故报告。
先认清项目边界
仓库名容易让人误以为它是服务端项目,但当前被审阅的应用入口实际是浏览器管理台:React 18、TypeScript、Vite、React Router、Ant Design、Zustand 和 Recharts。路由使用 admin 部署前缀,页面包含仪表盘、商品/服务、活动、公告、用户、库存、报价和账号等管理视图;另有面向访客的浏览路由。Zustand 的主题状态使用持久化中间件保存暗色模式与主题色。
这个组合适合快速交付有多种管理场景的运营工具,但页面组件承担了不少数据获取、筛选、表格、弹窗与表单逻辑。后续维护应把“列表查询状态”“领域编辑表单”和“呈现组件”逐步分开,并保持请求参数与后端 DTO 一致。这里没有审阅对应服务器端实现,因此不能判断某个 API 是否实际完成角色鉴权或数据校验。
UI 路由门禁不是后端授权
管理路由会检查登录信息,并对大多数页面要求管理员角色;访客浏览页不要求管理员角色。这能改善导航和交互,但浏览器代码可以被跳过,调用方也能直接请求 API。每个写操作和敏感读取仍必须由服务端从已验证会话中识别身份、检查角色与对象级权限,并对被禁用账号即时拒绝。前端本地存储的角色字段只能决定是否显示界面,不能成为授权凭据。
验收要分开进行:用访客、普通账户、管理员分别检查深链接与刷新路由;再绕过 UI 直接请求管理 API,确认服务端按角色拒绝。还要检查注销、令牌失效、角色被撤销后的当前页面与并发请求是否及时清理。本文只看到了管理台路由结构,没有对应后端源代码,因此这些是待验证边界。
持久化登录标志与用户资料不一致时,角色门禁会跳过检查
路由组件先以“已登录”标志拦截未登录状态;但角色判断写成了“配置了角色要求、且用户资料存在、且角色不匹配”才跳转。认证 store 又把用户资料与登录标志放在同一份持久化状态中。因此,一旦恢复出“登录标志为真、用户资料为空”这样的不一致状态,登录检查会通过,而角色分支也会被跳过,组件随后继续渲染受角色限制的前端路由。这是客户端门禁的 fail-open 缺口;持久化浏览器状态可被修改,不能作为安全边界。
这项静态发现不等于证明受保护数据可读或后端授权被绕过:后端接口是否独立验证 JWT、账号状态和角色没有在本次源码范围内确认。前端应对角色路由 fail-closed,例如角色要求存在时将缺失用户资料视为拒绝,并统一清理不完整会话、引导重新登录;服务端仍须对每个敏感 API 独立鉴权。回归测试应覆盖正常管理员、角色不符、登录标志为真但用户资料为空、存储损坏和退出后残留状态,并单独对 API 做匿名及低权限拒绝测试。
401 要同时收敛路由与持久化会话
源码将管理路由挂在 /admin basename 下,但 Axios 收到非登录接口的 401 后直接把浏览器地址设为站点根路径 /login,并且只删除单独存放的令牌。认证 store 另行持久化用户资料和 isAuthenticated;拦截器没有调用统一 logout,因此地址前缀与会话状态可能分叉:浏览器被送到 basename 外,而持久化 UI 状态仍显示已登录。这里是静态源码推断,没有运行部署路由或真实 401 来复现。
让 401 进入一个幂等的“会话过期”流程:由统一认证边界清除令牌、store 与该账号私有缓存,再导航到按当前 basename 生成的登录路由,并保留受控回跳。不要在 Axios 模块里随意反向导入创建该 Axios 实例的 store,以免形成循环依赖;可通过独立 session 模块或一次性过期事件协调。验证部署前缀下的硬刷新、并发 401、登录接口自身失败、回跳地址校验,以及页面和持久化存储都已清理;客户端处理只负责一致性,服务端仍必须独立授权。
前端包里的上传凭据必须视为已暴露
图片上传组件把第三方上传服务凭据写成源码常量,并由浏览器直接将文件发送到外部服务。构建工具会把这段代码送到访客的浏览器;压缩、混淆或改名都不能把客户端凭据变成秘密。请求 URL 也会出现在浏览器网络面板及可能的服务日志中。无论该凭据当前的额度或权限如何,都应按已经暴露处理:尽快在服务商控制台撤销旧凭据,再决定是否还要保留这个外部上传方案。本文刻意不复述凭据、请求值或源仓库链接。
更稳妥的架构是由经过授权的后端接收文件,校验文件大小、声明类型和实际文件签名,生成随机对象名并保存到受控存储;浏览器只收到有限期的上传授权或站内媒体 URL。若业务确实选择第三方直传,应由服务端签发短时、限目录/额度的上传策略,并限制可上传内容和回调目标;不要把长期 API 密钥塞进 Vite 环境变量或前端 bundle。上传失败、重复点击、用户离开页面时,也应清除 loading 状态并阻止过期结果覆盖新值。
多图选择还有一个状态闭环风险:上传控件允许一次选择多个文件,而每个异步上传完成时都从组件当前 render 捕获的 value 生成 [...value, url]。同一批上传通常共享这份旧数组;若两个请求都成功,后完成的 onChange 可能覆盖先完成的结果,最终只保留一张。这个问题来自回调闭包与并发选择代码,尚未在运行时复现。修复时要么串行化上传,要么让状态所有者用函数式 reducer 基于最新数组合并稳定文件 ID,并以延迟不同的两个成功响应编写回归测试;不能只在组件里用一个布尔 loading 锁住整个批次。
组件还允许用宽松的 HTTP/HTTPS 字符串检查添加远程图片地址。字符串前缀不是 URL 解析或内容安全校验:应使用 URL parser,明确允许的协议与来源策略;对 HTTP 图片要考虑 HTTPS 页面混合内容,对任意第三方图片要考虑隐私请求、资源失效和内容变化。若允许任意来源,应提供明确的用户预期和可移除的资源引用,不要把“能显示”当成可信或永久可用。
筛选与分页要防止重复请求和旧响应
活动列表的查询由带筛选状态依赖的 useCallback 与 useEffect 驱动。回车处理器同时重置页码并立即调用闭包中的旧查询函数;当用户位于非第一页时,这可能先请求旧页,再由状态变化触发第一页请求,造成重复流量与短暂闪动。列表请求没有取消或请求序号保护,较早发出的慢响应也可能晚于新筛选结果返回并覆盖列表。这个推断来自页面状态更新顺序,尚未用网络延迟或真实接口复现。
将筛选条件收敛成一个不可变 query 状态,提交筛选时一次性设置 page=1 和最新关键字;让单一 effect 或查询库负责请求,不要在同一事件里同时手动请求和依赖 effect 再请求。为每个请求绑定 query key,取消旧请求或在响应提交前比较序号;页面卸载时清理。测试中故意延迟旧筛选请求,确认旧结果不能覆盖新筛选;从第二页按回车也应只产生符合预期的一次请求。
另一个容易忽略的体验问题是表单保存把字段校验失败与网络保存失败都落到同一条“保存失败”提示。应让表单验证错误留在字段旁,只对服务端失败展示操作级错误,并保留用户已填写内容和可重试状态。
可复用的交付检查表
- 确认路由 basename、Web 服务器 history fallback、登录回跳和刷新深链在部署前缀下都正常。
- 对每个管理 API 做匿名、访客、普通用户、管理员四类服务端权限测试,而非只截图验证路由跳转。
- 扫描生产 bundle 与网络请求,确认长期密钥不进入浏览器;若此前已出现,先撤销再发布干净版本。
- 对图片文件类型、体积、真实格式、来源 URL、失败重试和并发选择分别设计边界测试;并发选择时让两个上传以相反顺序完成,确认两张成功图片都保留。
- 对搜索、排序、分页、筛选和详情切换做乱序响应测试,确认过期请求不能写入当前视图。
- 区分字段校验错误、网络错误和权限错误;保存失败不得清空用户输入。
审阅边界
本文只审阅浏览器端源码快照。未检查服务端路由、真实权限策略、外部上传账号权限、生产 bundle、部署配置或实际用户数据;没有证明凭据是否被滥用,也没有复现列表竞态。应立即轮换源码中的第三方凭据,并在确认后端上传/权限实现后再决定是否公开项目链接。
参考:Vite 环境变量与客户端暴露边界、React Effect、依赖与清理、OWASP Secrets Management Cheat Sheet。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。