一、前言
欢迎回来!🔑 今天我们聚焦 用户登录 模块 —— 这是mypet项目中用户进入系统的 “大门”。用户完成 Day9 的注册后,需通过登录获取Token(访问令牌) ,才能访问宠物用品购买、美容预约等核心功能。
本次登录实现的核心逻辑:
用 JWT(JSON Web Token) 生成加密令牌,搭配拦截器验证身份,同时加入登录态保持(刷新页面 / 重启 APP 不丢失登录状态),即使是新手也能轻松跟上。
📌 学习目标:
- 实现后端登录接口,用 JWT 生成 Token;
- 配置AuthorizationInterceptor拦截器,保护私有接口;
- 掌握uni.setStorageSync()实现跨端 Token 存储;
- 理解 “前端验证辅助 + 后端验证兜底” 的安全逻辑。
二、前置准备
开始编码前,请确认以下内容已完成,避免后续报错:
| 项目 | 检查内容 | 注意事项 |
|---|---|---|
| 数据基础 | Day9 注册功能正常,yonghu表已有测试用户(如手机号 13812345678,密码 123456) | 若无测试数据,需先通过 Day9 的注册接口新增用户 |
| 后端依赖 | pom.xml需包含 JWT 依赖(以 jjwt 为例):xmlio.jsonwebtokenjjwt0.9.1 | JWT 版本建议与 SSM 框架兼容,0.9.1 版本适配多数旧项目,高版本需调整语法 |
| 前端页面 | Uni-App 新建页面:pages/login/login.vue,并在pages.json中配置路由 | 路由配置需确保跳转正常,如添加"path": "pages/login/login" |
三、用户登录流程图
先通过流程图理解完整逻辑,再动手编码更高效:
flowchart TD
A[用户输入手机号+密码] --> B{前端基础验证<br> 非空/格式 }
B -- 否 --> C[提示手机号/密码不能为空]
B -- 是 --> D[调用后端/login接口]
D --> E{后端验证<br> 手机号存在? 密码匹配?}
E -- 否 --> F[返回登录失败]
E -- 是 --> G[JWT生成Token<br> 含过期时间]
G --> H[返回Token给前端]
H --> I[前端存储Token<br> uni.setStorageSync ]
I --> J[跳转首页/目标页面]
J --> K[后续请求携带Token<br> 拦截器验证 ]
四、代码实现
4.1 后端:UserController 登录接口
路径:src/main/java/com/controller/UserController.java
核心功能:接收前端参数→验证账号密码→生成 JWT Token
// 补充完整import(避免用户缺失依赖)
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import cn.hutool.crypto.SecureUtil;
import com.entity.YonghuEntity;
import com.service.YonghuService;
import com.utils.R;
import com.baomidou.mybatisplus.core.conditions.query.EntityWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.Map;
@RestController
@RequestMapping("/yonghu")
public class UserController {
@Autowired
private YonghuService yonghuService;
// 登录接口:允许未登录访问(需加@IgnoreAuth,同Day9注册接口)
@IgnoreAuth
@PostMapping("/login")
public R login(@RequestBody Map<String, String> params) {
// 1️⃣ 提取前端参数(手机号、密码)
String shoujihao = params.get("shoujihao");
String mima = params.get("mima");
// 2️⃣ 基础参数校验(后端兜底,避免前端跳过验证)
if (shoujihao == null || shoujihao.trim().isEmpty()) {
return R.error("手机号不能为空!");
}
if (mima == null || mima.trim().isEmpty()) {
return R.error("密码不能为空!");
}
// 3️⃣ 密码加密(与Day9注册时的MD5加密一致,确保匹配)
String encryptMima = SecureUtil.md5(mima);
// 4️⃣ 数据库查询:验证手机号+加密后密码是否匹配
YonghuEntity user = yonghuService.selectOne(
new EntityWrapper<YonghuEntity>()
.eq("shoujihao", shoujihao) // 匹配手机号
.eq("mima", encryptMima) // 匹配加密后密码
);
// 5️⃣ 验证结果判断
if (user == null) {
return R.error("手机号或密码错误,登录失败!");
}
// 6️⃣ 生成JWT Token(设置过期时间1小时,避免永久有效)
long expireTime = System.currentTimeMillis() + 3600000; // 1小时=3600000毫秒
String token = Jwts.builder()
.setSubject(user.getId().toString()) // 存储用户ID(核心信息,后续可解析获取)
.setExpiration(new Date(expireTime)) // 设置过期时间
.signWith(SignatureAlgorithm.HS512, "mypet-secret-2024") // 密钥(前后端需一致,建议自定义复杂值)
.compact();
// 7️⃣ 返回成功结果(携带Token)
return R.ok("登录成功")
.put("token", token) // Token传给前端
.put("userId", user.getId()); // 可选:返回用户ID,方便前端后续使用
}
}
📌 关键讲解:
- @IgnoreAuth:必须添加!否则拦截器会拦截登录接口,导致无法登录;
- 密码加密:与 Day9 注册时的SecureUtil.md5()完全一致,确保数据库中加密后的密码能匹配;
- Token 过期时间:避免 Token 永久有效,降低泄露风险,1 小时是常见合理值;
- 密钥(mypet-secret-2024):需记牢,后续拦截器验证、前端存储都需用到,建议项目中用配置文件管理(而非硬编码)。
4.2 后端:拦截器配置(2 步:编写拦截器 + 注册拦截器)
4.2.1 编写 AuthorizationInterceptor 拦截器
路径:src/main/java/com/interceptor/AuthorizationInterceptor.java
核心功能:拦截需登录的接口→验证 Token 有效性
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import com.annotation.IgnoreAuth;
import com.utils.R;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
public class AuthorizationInterceptor implements HandlerInterceptor {
// 拦截时机:接口调用前(preHandle)
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1️⃣ 判断是否为控制器方法(避免拦截静态资源)
if (!(handler instanceof HandlerMethod)) {
return true; // 非控制器方法,直接放行
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
// 2️⃣ 检查接口是否加了@IgnoreAuth(如登录/注册接口,直接放行)
if (handlerMethod.hasMethodAnnotation(IgnoreAuth.class)) {
return true;
}
// 3️⃣ 从请求头获取Token(前端需将Token放在header中)
String token = request.getHeader("token");
if (token == null || token.trim().isEmpty()) {
// Token为空:返回错误信息(JSON格式,方便前端解析)
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
out.write(new R.error("请先登录!").toString());
out.flush();
out.close();
return false; // 拦截请求,不允许访问接口
}
// 4️⃣ 验证Token有效性(密钥需与登录接口一致)
try {
Claims claims = Jwts.parser()
.setSigningKey("mypet-secret-2024") // 同登录接口的密钥
.parseClaimsJws(token) // 解析Token
.getBody();
// 5️⃣ Token有效:将用户ID存入request(后续接口可直接获取)
String userId = claims.getSubject();
request.setAttribute("userId", userId);
return true; // 验证通过,放行请求
} catch (Exception e) {
// Token无效(如过期、篡改):返回错误
response.setContentType("application/json;charset=UTF-8");
PrintWriter out = response.getWriter();
out.write(new R.error("Token无效或已过期,请重新登录!").toString());
out.flush();
out.close();
return false;
}
}
}
4.2.2 注册拦截器(关键!否则拦截器不生效)
路径:src/main/java/com/config/WebMvcConfig.java(若无此文件,需新建)
核心功能:指定拦截哪些接口、放行哪些接口
import com.interceptor.AuthorizationInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册拦截器
registry.addInterceptor(new AuthorizationInterceptor())
.addPathPatterns("/**") // 拦截所有接口
.excludePathPatterns( // 放行不需要登录的接口(必配!)
"/yonghu/login", // 登录接口
"/yonghu/register",// 注册接口
"/static/**", // 静态资源(如CSS/JS)
"/front/**" // 前端页面(如login.vue)
);
}
}
4.3 前端:Uni-App 登录表单与 Token 存储
路径:src/main/webapp/front/pages/login/login.vue
核心功能:表单输入→前端验证→调用登录接口→存储 Token→跳转首页
<template>
<view class="login-page">
<!-- 手机号输入框 -->
<view class="input-item">
<input
v-model="form.shoujihao"
placeholder="请输入手机号"
type="number" <!-- 限制输入数字,避免非数字字符 -->
@input="handlePhoneInput" <!-- 手机号格式实时校验 -->
/>
<view class="error-tip" v-if="phoneError">{{ phoneError }}</view>
</view>
<!-- 密码输入框 -->
<view class="input-item">
<input
v-model="form.mima"
placeholder="请输入密码(6-16位)"
type="password"
@input="handlePwdInput" <!-- 密码长度校验 -->
/>
<view class="error-tip" v-if="pwdError">{{ pwdError }}</view>
</view>
<!-- 登录按钮 -->
<button
type="primary"
class="login-btn"
@click="handleLogin"
:disabled="isBtnDisabled" <!-- 表单不合法时禁用按钮 -->
>
登录
</button>
</view>
</template>
<script>
// 引入封装的请求工具(同Day9)
import request from '@/api/request.js';
export default {
data() {
return {
form: {
shoujihao: '', // 手机号
mima: '' // 密码
},
phoneError: '', // 手机号错误提示
pwdError: '', // 密码错误提示
isBtnDisabled: true // 登录按钮是否禁用
};
},
watch: {
// 实时监听表单变化,判断按钮是否可点击
form: {
deep: true, // 监听对象内部属性变化
handler() {
this.checkFormValid();
}
}
},
methods: {
// 手机号输入校验(格式+非空)
handlePhoneInput() {
const phone = this.form.shoujihao.trim();
if (phone === '') {
this.phoneError = '手机号不能为空';
} else if (!/^1[3-9]\d{9}$/.test(phone)) { // 中国大陆手机号正则
this.phoneError = '请输入正确的11位手机号';
} else {
this.phoneError = '';
}
},
// 密码输入校验(长度)
handlePwdInput() {
const pwd = this.form.mima.trim();
if (pwd === '') {
this.pwdError = '密码不能为空';
} else if (pwd.length < 6 || pwd.length > 16) {
this.pwdError = '密码需为6-16位';
} else {
this.pwdError = '';
}
},
// 检查表单是否合法(决定按钮是否启用)
checkFormValid() {
this.isBtnDisabled = !(
this.form.shoujihao.trim() !== '' &&
this.form.mima.trim() !== '' &&
this.phoneError === '' &&
this.pwdError === ''
);
},
// 核心:登录逻辑
handleLogin() {
// 1️⃣ 再次校验表单(前端兜底)
this.handlePhoneInput();
this.handlePwdInput();
if (this.phoneError || this.pwdError) {
return; // 表单不合法,不发起请求
}
// 2️⃣ 调用后端登录接口
request.post('/yonghu/login', this.form)
.then(res => {
if (res.data.code === 0) { // 假设R.ok()返回code=0,R.error()返回code=1
// 3️⃣ 存储Token(uni.setStorageSync:跨端存储,永久有效直到手动删除)
uni.setStorageSync('mypet_token', res.data.token);
uni.setStorageSync('mypet_userId', res.data.userId); // 存储用户ID
// 4️⃣ 提示登录成功并跳转首页(关闭当前登录页,避免返回)
uni.showToast({
title: res.data.msg,
icon: 'success',
duration: 1500,
success: () => {
uni.redirectTo({ url: '/pages/index/index' }); // redirectTo:关闭当前页跳转
}
});
} else {
// 登录失败:提示错误信息
uni.showToast({
title: res.data.msg,
icon: 'none',
duration: 1500
});
}
})
.catch(err => {
// 网络错误:提示用户
uni.showToast({
title: '网络异常,请稍后再试',
icon: 'none',
duration: 1500
});
console.error('登录请求失败:', err);
});
}
},
// 页面显示时触发:检查是否已登录(登录态保持)
onShow() {
const token = uni.getStorageSync('mypet_token');
if (token) {
// 已登录:直接跳转首页(避免重复登录)
uni.redirectTo({ url: '/pages/index/index' });
}
}
};
</script>
<style scoped>
.login-page {
padding: 40rpx 30rpx;
background-color: #f5f5f5;
min-height: 100vh;
}
.input-item {
margin-bottom: 30rpx;
}
input {
width: 100%;
padding: 20rpx;
border: 1px solid #e5e5e5;
border-radius: 10rpx;
font-size: 28rpx;
background-color: #fff;
}
.error-tip {
margin-top: 10rpx;
font-size: 24rpx;
color: #ff4d4f;
}
.login-btn {
margin-top: 50rpx;
padding: 20rpx 0;
font-size: 30rpx;
border-radius: 10rpx;
}
</style>
📌 前端关键讲解:
- 表单验证:前端添加手机号正则校验、密码长度校验,提升用户体验(但后端仍需校验,安全兜底);
- Token 存储:用uni.setStorageSync(同步存储),跨端兼容(小程序、H5、APP 均支持),键名加前缀(如mypet_token)避免与其他项目冲突;
- 登录态保持:onShow生命周期中检查 Token,若已存在则直接跳转首页,避免用户重复登录;
- 跳转方式:用uni.redirectTo而非uni.navigateTo,关闭登录页,防止用户点击返回键回到登录页。
五、效果验证
✅ 1. 后端测试(Postman)
- 请求方式:POST
- 请求体(JSON) :
{
"shoujihao": "13812345678", // Day9注册的手机号
"mima": "123456" // 原始密码(后端会自动MD5加密)
}
- 成功返回:
{
"code": 0,
"msg": "登录成功",
"token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzE2NzUxNjY0fQ.xxxx", // 生成的Token
"userId": "1"
}
- 失败返回(如密码错误):
{
"code": 1,
"msg": "手机号或密码错误,登录失败!"
}
✅ 2. 前端测试(Uni-App)
- 运行 HBuilder X → 选择模拟器 / 真机调试 → 打开登录页;
- 输入正确手机号(13812345678)和密码(123456)→ 点击 “登录”;
- 观察效果:
-
- 弹出 “登录成功” 提示;
-
- 自动跳转首页;
-
- 打开 Uni-App 调试器 → 进入 “Storage” 面板,可看到mypet_token和mypet_userId已存储;
- 刷新页面 / 重启模拟器:
-
- 页面会直接跳转首页(onShow触发登录态检查),无需重新登录。
六、常见问题与排查
| 问题现象 | 可能原因 | 解决方式 |
|---|---|---|
| Token 无效或已过期 | 1. 前后端密钥不一致;2. Token 过期;3. Token 被篡改 | 1. 确认UserController和AuthorizationInterceptor的密钥(如mypet-secret-2024)完全一致;2. 延长 Token 过期时间(如改为 2 小时);3. 检查前端是否正确传递 Token |
| 登录接口被拦截(返回 “请先登录”) | 未给/login接口加@IgnoreAuth注解 | 在login方法上添加@IgnoreAuth注解,同 Day9 的注册接口 |
| 前端存储 Token 后,刷新页面丢失 | 1. 用了uni.setStorage(异步存储);2. 键名错误 | 1. 改用uni.setStorageSync(同步存储);2. 检查存储和获取的键名一致(如均为mypet_token) |
| 拦截器不生效 | 未在WebMvcConfig中注册拦截器 | 新建WebMvcConfig类,按 4.2.2 步骤注册拦截器,确保类上有@Configuration注解 |
| JWT 依赖报错(ClassNotFound) | pom.xml中缺少 JWT 依赖或版本不兼容 | 添加 jjwt 依赖(参考 2. 前置准备中的依赖代码),版本建议用 0.9.1,高版本需调整Jwts语法 |
七、扩展与提升
7.1 JWT 进阶用法
- 携带更多用户信息:生成 Token 时可添加用户昵称、头像等非敏感信息,减少数据库查询:
Jwts.builder()
.setSubject(user.getId().toString())
.claim("nickname", user.getNickname()) // 新增:携带昵称
.claim("avatar", user.getAvatar()) // 新增:携带头像地址
.setExpiration(new Date(expireTime))
.signWith(SignatureAlgorithm.HS512, "mypet-secret-2024")
.compact();
解析时通过claims.get("nickname")获取。
- Token 刷新机制:当 Token 快过期时(如剩余 30 分钟),前端发起 “刷新 Token” 请求,后端生成新 Token,避免用户重新登录。
7.2 前端 Token 安全优化
- HTTPS 传输:生产环境必须用 HTTPS,防止 Token 在传输过程中被窃取;
- Token 存储选择:
-
- 小程序:用uni.setStorageSync(安全,除非手机被 root);
-
- H5:若需更高安全,可将 Token 存在HttpOnly Cookie中(需后端配合),避免 XSS 攻击;
- 退出登录:需手动删除 Token:uni.removeStorageSync('mypet_token')。
7.3 后端安全优化
- 密钥管理:不要硬编码密钥,放在application.properties中:
jwt.secret=mypet-secret-2024
jwt.expire=3600000
后端通过@Value("${jwt.secret}")获取,方便后续修改。
- 接口限流:给登录接口添加限流(如 1 分钟内最多请求 5 次),防止暴力破解密码。
八、课堂互动
🙋♂️ 思考题:
- 如果用户登录后,Token 还没过期就退出登录,如何让已退出的 Token 立即失效?(提示:可维护 “无效 Token 黑名单”)
- 前端每次请求需携带 Token,除了放在请求头(header)中,还能放在哪里?哪种方式更安全?
💡 提示:Token 放在请求头中是行业主流,避免放在 URL 中(易被日志记录),也避免放在请求体中(部分 GET 请求无请求体)。
九、下节预告
👉 明天 Day11:个人信息修改!我们将学习:
- 用登录时存储的 Token 获取当前用户信息;
- 实现个人资料(昵称、头像、手机号)修改功能;
- 处理修改密码时的旧密码验证逻辑。
记得提前复习 Token 的传递和解析哦!