【Uni-App+SSM 宠物项目实战】Day10:用户登录

114 阅读6分钟

一、前言

欢迎回来!🔑 今天我们聚焦 用户登录 模块 —— 这是mypet项目中用户进入系统的 “大门”。用户完成 Day9 的注册后,需通过登录获取Token(访问令牌) ,才能访问宠物用品购买、美容预约等核心功能。

本次登录实现的核心逻辑:

JWT(JSON Web Token) 生成加密令牌,搭配拦截器验证身份,同时加入登录态保持(刷新页面 / 重启 APP 不丢失登录状态),即使是新手也能轻松跟上。

📌 学习目标

  1. 实现后端登录接口,用 JWT 生成 Token;
  1. 配置AuthorizationInterceptor拦截器,保护私有接口;
  1. 掌握uni.setStorageSync()实现跨端 Token 存储;
  1. 理解 “前端验证辅助 + 后端验证兜底” 的安全逻辑。

二、前置准备

开始编码前,请确认以下内容已完成,避免后续报错:

项目检查内容注意事项
数据基础Day9 注册功能正常,yonghu表已有测试用户(如手机号 13812345678,密码 123456)若无测试数据,需先通过 Day9 的注册接口新增用户
后端依赖pom.xml需包含 JWT 依赖(以 jjwt 为例):xmlio.jsonwebtokenjjwt0.9.1JWT 版本建议与 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)

  1. 请求地址http://localhost:8080/yonghu/login
  1. 请求方式:POST
  1. 请求体(JSON)
{
  "shoujihao": "13812345678",  // Day9注册的手机号
  "mima": "123456"             // 原始密码(后端会自动MD5加密)
}
  1. 成功返回
{
  "code": 0,
  "msg": "登录成功",
  "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiZXhwIjoxNzE2NzUxNjY0fQ.xxxx", // 生成的Token
  "userId": "1"
}
  1. 失败返回(如密码错误):
{
  "code": 1,
  "msg": "手机号或密码错误,登录失败!"
}

✅ 2. 前端测试(Uni-App)

  1. 运行 HBuilder X → 选择模拟器 / 真机调试 → 打开登录页;
  1. 输入正确手机号(13812345678)和密码(123456)→ 点击 “登录”;
  1. 观察效果:
    • 弹出 “登录成功” 提示;
    • 自动跳转首页;
    • 打开 Uni-App 调试器 → 进入 “Storage” 面板,可看到mypet_token和mypet_userId已存储;
  1. 刷新页面 / 重启模拟器:
    • 页面会直接跳转首页(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 次),防止暴力破解密码。

八、课堂互动

🙋‍♂️ 思考题:

  1. 如果用户登录后,Token 还没过期就退出登录,如何让已退出的 Token 立即失效?(提示:可维护 “无效 Token 黑名单”)
  1. 前端每次请求需携带 Token,除了放在请求头(header)中,还能放在哪里?哪种方式更安全?

💡 提示:Token 放在请求头中是行业主流,避免放在 URL 中(易被日志记录),也避免放在请求体中(部分 GET 请求无请求体)。

九、下节预告

👉 明天 Day11:个人信息修改!我们将学习:

  1. 用登录时存储的 Token 获取当前用户信息;
  1. 实现个人资料(昵称、头像、手机号)修改功能;
  1. 处理修改密码时的旧密码验证逻辑。

记得提前复习 Token 的传递和解析哦!