最近刚把某商城的登录模块整体翻新了一遍。说实话,登录页面这个需求乍一看很不起眼,就是一个“输账号密码、点登录”的活,可真要把它做成一套完整的前后端方案,里面的门道比想象中多得多。尤其是商城这种业务场景,登录页直接决定用户能不能顺利进店下单,任何一点交互卡顿、接口报错、Token过期导致被踢下线,都会变成真实的客诉。所以这篇文章就把“商城登录页面(前端+后端代码)(新)”这套东西完整的拆一遍,包括页面结构、表单校验、验证码、JWT签发与拦截、前后端联调的细节,以及我实际开发中踩过的坑。如果你正在写电商后台或者商城前端,或者想要一个能直接拿来改成自己项目的登录模板,这篇应该能省你不少时间。
这次重做的原因很简单——旧版登录页面和接口混在项目里,前端代码堆在一个大组件里,后端登录逻辑也和用户模块耦合在一起,想换验证码方案或者做个小修改,都要小心翼翼生怕带崩其他功能。新版把登录单独作为一个“微型项目”来做:前端独立成页面模块,后端单独提供认证接口,整体走前后端分离的路子。这样不仅本项目能用,以后接小程序、App、H5,登录这块代码也能直接复用。
1. 登录模块的拆分思路与需求边界
1.1 商城登录页为什么值得单独立项
很多团队会把登录当作一个“小功能”塞在每个页面里,但商城类项目的登录其实是个独立的业务子系统。你想想,登录页背后连着用户体系、Token 管理、权限拦截、防盗刷机制,还牵扯到下单、收货地址、优惠券等核心用户数据的安全边界。如果登录逻辑散落在各个 Controller 里,或者前端把登录组件复制粘贴到好几个页面里,后续维护成本会越来越高。旧版代码我拿到手的时候,就有三个页面各自维护了一份登录表单,校验规则还不一样,有的要求手机号,有的只认用户名,用户都懵了。
这一版的核心目标是把登录做成“统一入口”:全站只有一个登录页面,前端用路由统一跳转,后端只暴露三个核心接口——验证码接口、登录接口、用户信息接口。同时把登录态管理收敛到前端的一个 Auth 模块里,所有请求统一经过拦截器附加凭证,后端用 JWT 做无状态认证。这属于“先定边界再写代码”的思路,登录是做什么的、不用管订单和库存,职责单一,后续扩展也方便。
另一个让我下定决心重做的原因是旧版的验证码形同虚设。旧的验证码接口返回一张图片,但后端没有存任何校验值,前端只是拿图片展示,服务端压根没验证输入的验证码对不对。这种安全问题在商城这种真金白银的项目里是绝对不能忍的。新版引入了 Redis 存验证码校验值,并设置过期时间,还限制了错误尝试次数,算是把登录认证这条链路的底兜住了。
1.2 核心需求拆解与技术选型
先把需求清单列出来,再谈选型,不然容易写着写着就跑偏。这版登录模块的需要覆盖的点包括:
- 支持用户名或手机号登录,密码输入框有明暗文切换
- 图形验证码,点按刷新,校验不区分大小写
- 前端表单校验(非空、手机号格式、密码最小长度),后端再校验一遍
- 登录成功后返回 JWT,前端存储并在后续请求中携带
- 通过拦截器校验接口权限,未登录返回 401
- 登录失败需要提示具体原因,验证码错误、密码错误、用户不存在要区分
- 防止暴力破解,登录失败次数超过阈值后短期锁定
技术选型上我选了 Vue 3 + Vite + Pinia 作为前端基础,后端用 Spring Boot 3 + MyBatis-Plus + Redis + JWT。这套组合的好处是生态成熟、文档多、上手快。Vue 3 的组合式 API 很适合把登录页的表单状态、校验逻辑、加载状态拆得清清楚楚;Spring Boot 的后端接口写起来也利索,JWT 用现成的 java-jwt 库就能搞定。数据库用的 MySQL,用户表有id、username、phone、password_hash、status、create_time这些字段,密码只存哈希值,绝不存明文。
相比网上那些只有前端界面或只有后端接口的半成品教程,这版最大的差异在“闭环”——从页面渲染到验证码校验,再到 Token 签发、路由守卫、请求拦截,全链路都走通了。而且代码结构是按真实项目标准组织的,不是 Demo 级的堆功能。
2. 前端登录页的实现要点
2.1 页面结构与交互拆分
前端的登录页我设计成了居中卡片布局:左侧是品牌宣传区和配图,右侧是登录表单。当然这不是唯一方案,有些商城喜欢上下结构或者全屏背景加浮层,具体看你们的设计规范。表单区域我拆成三个部分:登录方式切换 Tab、表单主体、底部操作栏。登录方式支持“密码登录”和“手机验证码登录”两种,默认展示密码登录。
密码登录区域包含用户名输入框、密码输入框(带小眼睛切换明文)、图形验证码输入框(右侧是验证码图片)、记住我勾选框、忘记密码链接和登录按钮。手机验证码登录区域则是手机号输入框、短信验证码输入框(按钮带倒计时)以及登录按钮。切换 Tab 时表单校验规则也要跟着切换,不能把密码校验规则强加给短信登录。这块我踩过一个坑:两个登录方式共用一套表单数据,结果切换后还留着上一个方式的数据和错误提示,体验很差。解决方式是切换时重置表单数据并清空校验结果。
交互细节上,登录按钮在提交中要变为 loading 状态并禁用,防止用户重复点击导致重复提交;验证码图片点击后要刷新,验证码输入框聚焦时自动选中已有内容。记住我用 localStorage 存用户名,但绝不存密码——密码存本地是安全事故,哪怕加密存储也不建议。这些交互虽然小,但都是用户能直观感受到的部分。我见过有些登录页,验证码输错了直接清空所有字段,用户要重新输账号密码,能气死。
2.2 表单校验与关键字段规则
前端校验的意义是即时反馈,用户体验好;后端校验的意义是安全兜底。二者缺一不可。用户名规则我设置为 4-20 个字符,支持中文、英文、数字和下划线;手机号用最基本的^1[3-9]\d{9}$正则校验;密码要求 8-20 位,至少包含字母和数字。校验时机上有两种:填完失去焦点时校验一次,点登录时再统一校验一次。
如果密码强度要求更高,可以再加“必须包含大写字母和特殊字符”的规则,但注意商城场景别把规则定得太死,不然用户注册完第二天就忘了密码,反而增大找回密码的压力。实际项目里我用的是“8-20位、字母+数字”这档强度,配合登录失败次数限制,安全性够用了。
密码传输这块我要多说一句。项目里如果有 HTTPS,直接明文传密码是常规做法,因为 HTTPS 本身就在链路上做了加密;如果没有 HTTPS,至少要在前端做一次 SHA-256 哈希再传后端。但注意,哈希传输不能替代后端加盐哈希存储,两者不是一回事。前端哈希防的是抓包直接看到明文,后端加盐哈希防的是数据库泄露后反推密码。这次项目前后端走的是 HTTPS,所以登录接口直接接收原始密码,后端再做 BCrypt 哈希比对。
还有一个很多人容易忽略的细节:登录请求要加一个客户端状态锁。前端用一个loginLoading变量控制按钮状态,但还不够,如果用户手速快到连点三次,三次请求都发出去了,后端的失败次数统计会异常飙升。我在提交函数开头做了判断:如果loginLoading === true就直接 return,从根源上杜绝重复提交。
2.3 状态管理与登录态持久化
前端状态管理这一层,我用 Pinia 创建了一个 auth store,专门维护token、userInfo、isLoggedIn这几个状态。登录成功后的动作很明确:把 Token 存到 localStorage,把用户基本信息存到 auth store,然后跳转到之前想去的页面。
关于 Token 存储位置,localStorage 和 sessionStorage 各有说法。localStorage 的优点是刷新不丢失,关浏览器再打开还在,对商城这种“记住我”的需求很友好;缺点是有 XSS 漏洞时容易被窃取。sessionStorage 的优点是仅当前标签页有效,安全性略高,但用户新开标签就要重新登录,商场场景下有点烦。这里我做了个折中:默认用 localStorage 存 Token,勾选“记住我”则额外延长一周的有效期,不勾选则后端签发的 Token 有效期设置为 2 小时,相当于会话级别的登录。真正的金融级安全还要配合 HttpOnly Cookie 而不是 Token 存 localStorage,但这个方案对绝大多数商城够用了。
路由守卫也是关键一环。我配置了全局前置守卫,检查目标路由是否在白名单内(比如登录页、注册页、商品列表页),如果不在白名单且没有 Token,就跳转登录页并带上redirect参数,登录成功后再用router.replace(redirect || '/')回到原页面。这里要特别注意别写成router.push一直跳回登录页形成死循环,我早期就因为这个写过 Bug,守卫里没排除白名单,结果用户永远进不了登录页。
3. 后端接口设计与认证逻辑
3.1 统一响应结构与错误码设计
后端接口设计的第一步是统一响应结构。我定义了一个泛型类,字段就三个:
code:业务状态码,200 表示成功,其他为业务错误message:给前端直接展示的提示信息data:业务数据
错误码不是随便编的,我按功能域分了段:4000 系列是参数和验证码类错误,4100 系列是账号密码类错误,4200 系列是 Token 类错误。比如4001表示必填参数缺失,4002表示验证码错误,4101表示用户不存在,4102表示密码错误,4103表示账号锁定,4201表示 Token 过期,4202表示 Token 无效。
这样设计的好处是,前端拿到 code 可以直接决定行为:4002 就刷新验证码,4201 就跳登录页。有一次我和前端同事联调时发现他在每个接口回复里都写死if (res.code === 200),后来新增了一个code === 2000的成功场景,他那边全废了。这就是没有统一错误码的教训,大家事先约定好,后面就不会扯皮。
3.2 登录接口的核心流程
登录接口的完整流程比想象中长,一步都不能省。以密码登录为例,后端接收到请求后的处理顺序是:
- 校验验证码是否匹配
- 根据用户名或手机号查询用户
- 校验用户状态是否正常
- 用 BCrypt 比对密码
- 生成 JWT 并返回用户信息
验证码校验放在最前面是有道理的——先挡住那些连验证码都懒得看的机器人程序,避免无谓的数据库查询。验证码逻辑用 Redis 实现:前端请求验证码接口时,后端生成一个随机字符串(我用的 4 位字母数字混合,为了体验好点不区分大小写),同时生成一个 UUID 作为验证码标识返回给前端。Redis 存储的 key 是captcha:{uuid},value 是验证码字符串,过期时间 5 分钟。用户登录时把captchaId和captchaCode一起传过来,后端根据 captchaId 去 Redis 查,比对后立刻删除该 key,保证验证码一次性有效。
密码比对用 BCrypt。注册用户时把明文密码经过 BCrypt 哈希后存到数据库,登录时用BCryptPasswordEncoder.matches(rawPassword, passwordHash)做比对。BCrypt 的特点是每次哈希结果都不一样,但 matches 能正确判断,这是为了方便加盐且防止彩虹表攻击。千万别用 MD5 或 SHA-256 直接存密码,这俩是不适合做密码存储的,碰都不要碰。
登录失败次数限制是防暴力破解的关键。我在 Redis 里维护一个login:fail:{username}的计数器,每次密码错误就加 1,设置过期时间 15 分钟。连续失败 5 次后,账号进入锁定状态,锁定期间不执行密码比对,直接返回“尝试次数过多,请 15 分钟后再试”。这里有个细节:要在校验密码之前检查是否锁定,否则攻击者还是能通过响应时间差探测账号是否存在。锁定期间哪怕密码正确也不能登录,直到 Redis key 过期自动解锁。
3.3 JWT 与拦截器配置
JWT 生成我用的是 HS256 对称加密算法,密钥配置在配置文件里,千万别硬编码在代码里。Token 的 payload 放userId和username就够了,不要放敏感信息。有效期我设为 2 小时,结合刷新机制使用。Token 刷新这个模块可以做成按下述方案:登录成功后返回两个 Token——accessToken有效期 2 小时,refreshToken有效期 7 天。当 accessToken 过期时,前端用 refreshToken 调刷新接口换新 Token。这样用户购物过程中不会突然被踢下线,又保证了 accessToken 泄露后的影响范围可控。如果你嫌麻烦,可以先用单 Token,但一定要处理 401 跳转逻辑,不然用户逛着逛着突然得重新登录,体验很糟糕。
拦截器用的是 Spring Boot 的 HandlerInterceptor。配置拦截路径时,白名单放行/api/auth/login、/api/auth/captcha、/api/auth/register,其余/api/**全部经过 Token 校验。在校验器中先取 Header 的Authorization字段,格式是Bearer {token},然后调用 JWT 工具类解析,解析失败就返回 401。解析成功则从 Token 里取出 userId,放到当前请求上下文中。后续 Controller 需要当前用户时,直接从上下文取,不用再从参数里传 userId,接口也干净很多。
有些同学会问:为什么不用拦截器直接把用户信息塞到 Controller 参数里?那是因为 HandlerInterceptor 和 Controller 参数解析是两套机制,想要塞到参数里需要自定义 HandlerMethodArgumentResolver。我这次没搞那么复杂,定义了一个UserContext静态工具类,内部用 ThreadLocal 存用户 ID。注意,用 ThreadLocal 一定要在请求结束后清除,否则线程池复用时数据串号。我在拦截器的afterCompletion方法里做了清理,这个问题强调再多也不过分。
4. 前后端联调的关键细节
4.1 接口文档与数据格式约定
前后端联调最容易翻车的地方就是“自认为约定好了”。这版我要求前后端先写接口文档再动手。接口文档不需要用什么重型工具,一个共享表格就够,把方法、路径、请求参数、返回结构、错误码都写清楚。前端照着文档写模拟数据,后端照着文档实现,联调时冲突就会少很多。
接口清单大致如下:
GET /api/auth/captcha:生成图形验证码,返回{ captchaId, captchaImage }POST /api/auth/login:账号密码登录,请求{ username, password, captchaId, captchaCode },返回 Token 和用户信息POST /api/auth/refresh:刷新 TokenGET /api/user/info:查询当前登录用户信息,需要携带 Token
数据格式约定同样要一致。日期统一下发yyyy-MM-dd HH:mm:ss字符串,不做默认序列化;枚举值传数字而不是中文;null字段直接不返回而非传字符串"null"。这些约定不要等联调时才提,写文档的时候就写明。
4.2 跨域与请求封装
前后端分离项目必须面对跨域问题。开发环境下我推荐用 Vite 的代理配置,把/api前缀的请求代理到后端服务地址,这样浏览器看到的请求是同源的,根本不触发跨域。生产环境由 Nginx 负责转发,同样是同源访问。如果后端直接开启了 CORS,那要注意allowedOrigins别配成*,否则携带凭证的请求会失败。
前端请求封装这一层,我创建了一个 axios 实例,统一设置baseURL: '/api'和timeout: 10000。请求拦截器里做两件事:一是从 localStorage 读 Token,有就加到Authorization头;二是给每个请求附加一个随机参数,防止浏览器缓存 GET 请求。响应拦截器里统一处理业务码:
code === 200直接返回datacode === 4002刷新验证码code === 4103显示账号锁定提示code === 4201清除本地登录态并跳转登录页
这里的关键是响应拦截器绝不能只写成功分支,错误分支必须和 UI 组件有联动。比如验证码错误时,前端要把验证码图片刷新、把验证码输入框清空,否则用户总觉得是自己输错了。
4.3 Token 过期与自动续期方案
Token 过期是商城项目里一个绕不开的体验痛点。用户逛了半小时,刚要下单,Token 过期了,请求直接 401,这时候如果把用户硬踢回登录页,购物车里的东西虽然还在,但那种“被强制退出”的感觉非常劝退人。
我这次用的是 refreshToken 方案:登录接口返回两个 Token 和各自的过期时间。前端封装了一个refreshToken()函数,在响应拦截器收到 4201 时调用。为了处理多个请求同时过期时重复刷新 Token 的问题,我用一个isRefreshing标志位加一个等待队列:第一个 401 触发刷新,后续的 401 挂到队列里等新 Token 就绪,再统一重发放行。这个逻辑看起来复杂,其实核心代码也就四五十行,但体验提升很明显。
如果你不想上 refreshToken,至少要做到两点:401 时不要立刻跳登录页,多给一次静默重试机会;跳转登录页时带上当前路由,登录成功后回跳。我见过最粗暴的做法是后端一张 401,前端就直接window.location.href = '/login',用户正在看商品详情页呢,突然跳到登录页,回来都不知道刚才看到哪了。
5. 踩坑记录与排查实录
5.1 验证码校验失败的几个典型原因
验证码是这次项目里比较容易出问题的模块,我把实际遇到过的情况整理成了一张速查表,方便你们对照排查。
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 验证码图片加载不出来 | 后端接口路径错误或跨域受阻 | 检查代理配置和接口地址 |
| 验证码明明输对了却提示错误 | Redis 里 key 已过期 | 检查 Redis 键的过期时间设置 |
| 验证码大小写不统一 | 生成时用了大写,校验时用了忽略大小写 | 统一转成小写再比对 |
| 刷新验证码后旧的还能用 | 校验成功后 key 未删除 | 比完立刻删除 Redis key |
| 高频刷新导致接口被刷 | 验证码接口没做频率限制 | 按 IP 限制每分钟最多获取 5 次 |
特别提醒一下验证码生成那个图形,网上很多代码用的字体需要服务器支持,如果服务器上没有对应字体,生成出来的图片可能是一堆方块。稳妥的办法是直接用 JDK 自带的Font.SANS_SERIF,至少不会出现乱码。
5.2 请求拦截器常见的报错排查
联调的时候报CORS error,多半是代理没生效,或者请求头没加全。排查思路是先看浏览器 Network 面板,确认请求的 URL 是不是走了代理。如果走了代理还报跨域,检查后端 CORS 配置里allowedHeaders是否包含Authorization。预检请求OPTIONS如果失败,通常是后端没有放行预检,或者放行的方法列表里缺少对应方法。
请求头没有携带 Token的报错,通常是因为登录后没有把 Token 写入 localStorage,或者 axios 实例的请求拦截器在添加 Token 之前就发请求了。这种问题可以通过在浏览器控制台输入localStorage.getItem('token')快速定位。如果为空,就去看 auth store 的登录动作执行了没有;如果不为空但请求头里没有,那就是拦截器里取值写错了 key。
还有一个容易忽略的点:如果 Token 里带有小数点(JWT 三个部分之间用点分隔),在后端拼接Bearer时注意别把空格丢了。之前见过同事写"Bearer" + token,中间没有空格,解析时一直报错,排查了一个小时才发现是这个低级问题。
5.3 登录态丢失与路由守卫死循环
登录态“莫名其妙”丢失,最常见的原因是刷新页面时 Pinia 状态被重置,但 localStorage 里的 Token 没读回来。解决方案是在 auth store 初始化时先从 localStorage 恢复 Token,并解析 Token 有效期。如果解析发现已过期,再清除缓存并跳转登录页。
路由守卫死循环也很常见。具体场景:未登录用户访问受保护页面,守卫跳转/login?redirect=/order,登录成功后代码里router.replace(redirect),但如果页面里又写了个“未登录就跳登录页”的逻辑,就会造成跳转回路。我的处理原则是:路由守卫里只判断“有没有 Token”,不判断“是不是登录页”,而且白名单数组里明确包含/login。这样既保证登录页始终可访问,又不会让受保护页面绕过守卫。
另外,跳转时最好记录来源路由。当初做登录回跳的时候,我直接在登录页写死了跳回首页,结果用户从结算页面被踢到登录页后,登录完直接到了首页而不是结算页,白白多了一步操作。后来改成从route.query.redirect里取回跳目标,这个体验问题才算解决。
5.4 安全与性能相关提醒
出于篇幅考虑,最后补充几个安全相关的点。密码传输务必依赖 HTTPS;后端接口要对输入做长度限制,防止超大参数拖垮接口响应;验证码接口和登录接口建议都加简单的主机限流,比如基于 IP 的速率限制。虽然说这些不是登录模块的核心功能,但在商城这种业务里,登录就是安全的第一道门,防线筑得越靠前,后面越省心。
最后再分享一点实际体会
整个登录模块从梳理需求到前后端联调通过,我大概花了一个多星期。其中联调阶段花费的时间并不比开发阶段少,原因就是前面提到的 Token 过期、验证码校验这些边界情况,前端和后端往往各写各的,直到对接才发现语义不一致。如果让我重来一次,我会先把接口文档和错误码表定死,再让前后端并行开发,这样至少能省两三天。
另一个体会是模板代码这件事。商城登录页面在国内开发场景里太常见了,与其每次接新项目都从零开始写一遍,不如把这次沉淀的前端表单组件、后端认证接口,以及 JWT 解析、验证码、登录态存储这些逻辑抽成公共模块。我目前已经把它们整理成了项目内的一套auth-starter模板,后面接新的商城后台、管理系统都能直接复用,改动量很小。
如果你也想做一个类似的项目,我建议不要只照着抄代码,要理解每一个设计选择背后的原因:为什么验证码校验要先于账号密码校验、为什么 JWT payload 不塞太多数据、为什么前端要有独立的 auth store。这些细节表面上不是“功能”,但正是它们决定了一个登录模块靠不靠得住。这套新版登录页面目前在我们的商城项目里已经稳定跑了一段时间,没有出过登录相关的线上事故,希望我这篇拆解能给你们自己的项目帮上点忙。