第一阶段:奠定基石(后台管理与数据库)

1.7 JWT 身份认证 (Req 2, 10)

任务书要求: 不同级别用户在登陆后的操作权限不同,解决安全设计问题。

对你们的要求: 大概理解逻辑,照着代码实现。

给零基础同学的话:

JWT (JSON Web Token) 就是系统的“电子身份证”或“令牌”。

以前大家去游乐园,进门买票。现在我们的系统也一样:

  1. 登录 (Login): 用户输入账号密码,如果正确,系统发给他一张 JWT 令牌 (一串很长的乱码字符串)。

  2. 访问 (Access): 用户在系统里做的任何操作(查数据、删人),都必须在请求头里带上这张令牌。

  3. 校验 (Verify): 后端有一个“保安”(拦截器),会检查每一张令牌。如果是假的或者过期的,直接踢出。

1. JWT 介绍

  • 全称: JSON Web Token (官网:https://jwt.io/)

  • 特点:

    • 简洁: 就是一个字符串,方便在网络传输。

    • 自包含: 令牌里可以存自定义数据(比如你的 ID、名字、角色),后端拿到令牌解密后就能知道你是谁,不用每次都去查数据库。

  • 组成: Header (头) . Payload (有效载荷/数据) . Signature (签名,防篡改)

2. JWT 的生成 (后端实现)

Step 1: 引入依赖

在 pom.xml 中添加 JWT 的工具包:

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

Step 2: 引入 JWT 工具类

在 com.xhu.headline_server.utils 包下创建 JwtUtils.java,负责生成和解析令牌。

package com.xhu.headline_server.utils;  
  
import io.jsonwebtoken.Claims;  
import io.jsonwebtoken.Jwts;  
import io.jsonwebtoken.SignatureAlgorithm;  
  
import java.util.Date;  
import java.util.Map;  
  
public class JwtUtils {  
  
    private static String signKey = "SVRIRUlNQQ=="; // 签名密钥,绝密!
    private static Long expire = 43200000L; // 过期时间 12小时
  
    /** * 生成JWT令牌  
     * @return  
     */  
    public static String generateJwt(Map<String,Object> claims){  
        String jwt = Jwts.builder()  
                .addClaims(claims)  
                .signWith(SignatureAlgorithm.HS256, signKey)  
                .setExpiration(new Date(System.currentTimeMillis() + expire))  
                .compact();  
        return jwt;  
    }  
  
    /** * 解析JWT令牌  
     * @param jwt JWT令牌  
     * @return JWT第二部分负载 payload 中存储的内容  
     */  
    public static Claims parseJWT(String jwt){  
        Claims claims = Jwts.parser()  
                .setSigningKey(signKey)  
                .parseClaimsJws(jwt)  
                .getBody();  
        return claims;  
    }  
}

Step 3: 完善 LoginController

在 controller 包下(或 controller.admin)创建或修改 LoginController。

(注:你需要先创建 UserDTO 和 LoginInfo 类,用来接收前端参数和返回结果)


@PostMapping("/admin/login")  
public Map<String, Object> login(@RequestBody  UserDTO userDTO) {  
    log.info("登录请求: {}", userDTO);  
    String username = userDTO.getUserName();  
    String password = userDTO.getPassword();  
    // 简单验证用户名和密码  
    LoginInfo flag = userService.login(username, password);  
      
    Map<String, Object> res = new HashMap<>();  
    if (flag == null) {  
        res.put("code", 0);  
        res.put("message", "用户名或密码错误");  
        return res;  
    }  
  
    res.put("code", 1);  
    res.put("data", flag);  
    return res;  
}

Step 4: 完善 Service 登录逻辑

在 UserServiceImpl 中实现 login 方法。登录成功后,生成令牌并返回。

@Override  
public LoginInfo login(String username, String password) {  
    // 1. 调用 mapper 查询用户  
    User user = userMapper.selectNameAndPassword(username, password);  
    if (user == null) {  
        // 登录失败:账号或密码错误  
        return null;  
    }  
  
    int grade = user.getRole();  
    if (grade != 0 && grade != 1) {  
        // 仅允许管理员和员工登录后台管理系统  
        return null;  
    }  
  
    // 2. 组装 JWT 负载数据  
    Map<String, Object> claims = new HashMap<>();  
    claims.put("userId", user.getId());  
    claims.put("userName", user.getUserName());  
    claims.put("role", user.getRole());  
  
    // 3. 使用 JwtUtils 生成 JWT 令牌  
    String token = JwtUtils.generateJwt(claims);  
  
    // 4. 封装 LoginInfo 返回  
    LoginInfo loginInfo = new LoginInfo();  
    loginInfo.setUserId(user.getId());  
    loginInfo.setUserName(user.getUserName());  
    loginInfo.setPassword(user.getPassword());  
    loginInfo.setRole(user.getRole());  
    loginInfo.setToken(token);  
    System.out.println("生成的 token: " + token);  
  
    return loginInfo;  
}

3. JWT 校验 (拦截器)

原理: 我们需要一个“保安”(拦截器 Interceptor)。除了登录接口,其他所有请求都要检查是否带着合法的 Token。

Step 1: 定义拦截器实现类

在 Interceptor 包下新建 TokenInterceptor.java。

package com.xhu.headline_server.Interceptor;  
  
  
import com.xhu.headline_server.utils.JwtUtils;  
import jakarta.servlet.http.HttpServletRequest;  
import jakarta.servlet.http.HttpServletResponse;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.stereotype.Component;  
import org.springframework.util.StringUtils;  
import org.springframework.web.servlet.HandlerInterceptor;  
  
@Slf4j  
@Component  
public class TokenInterceptor implements HandlerInterceptor {  
  
    @Override  
    public boolean preHandle(HttpServletRequest request,  
                             HttpServletResponse response,  
                             Object handler) throws Exception {  
  
        // 1. 获取请求 url        String url = request.getRequestURL().toString();  
  
        // 2. 登录接口直接放行  
        if (url.contains("/admin/login")) {  
            log.info("登录请求,直接放行: {}", url);  
            return true;  
        }  
  
        // 3. 从请求头获取 token        String jwt = request.getHeader("token");  
  
        // 4. 判断 token 是否为空  
        if (!StringUtils.hasLength(jwt)) {  
            log.info("请求未携带 token,拒绝访问");  
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  
            return false;  
        }  
  
        // 5. 解析 token        try {  
            JwtUtils.parseJWT(jwt);  
        } catch (Exception e) {  
            log.info("token 解析失败,拒绝访问", e);  
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  
            return false;  
        }  
  
        // 6. 放行  
        return true;  
    }  
}

Step 2: 注册拦截器1

在 config 包下新建 WebConfig.java,告诉 SpringBoot 启用这个拦截器。2

Java
@Configuration  
public class WebConfig implements WebMvcConfigurer {  
  
    @Autowired  
    private TokenInterceptor tokenInterceptor;  
  
    @Override  
    public void addInterceptors(InterceptorRegistry registry) {  
        registry.addInterceptor(tokenInterceptor)  
                .addPathPatterns("/**")            // 拦截所有  
                .excludePathPatterns(  
                        "/admin/login",          // 登录接口放行  
                        "/",                     // 首页  
                        "/index.html",           // 前端入口  
                        "/static/**",            // 静态资源  
                        "/assets/**",            // Vite `/assets` 等  
                        "/favicon.ico"  
                );  
    }  
}

1.8 前端完善:登录与权限

目标: 后端接口有了,前端需要实现登录页面,并在每次请求时自动带上 Token。

1. 配置路由逻辑 (Router)

修改 src/router/index.ts。增加“路由守卫”,没登录的人强制踢回登录页。

import { createRouter, createWebHistory } from 'vue-router'
import LayoutView from '@/views/layout/index.vue'
import UserView from '@/views/user/index.vue'
import LoginView from '@/views/Login/index.vue' // 确保你创建了这个文件
  
const routes = [
  // 登录页:单独显示,不在 Layout 下
  {
    path: '/login',
    name: 'login',
    component: LoginView,
  },
  // 需要登录的业务页面放在 Layout 下
  {
    path: '/',
    component: LayoutView,
    redirect: '/user',
    children: [{ path: 'user', name: 'user', component: UserView }],
  },
]
  
const router = createRouter({
  history: createWebHistory(),
  routes,
})
  
// 未登录时只允许访问 /login
router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('auth_token')
  console.log('[router.beforeEach] to:', to.path, 'token:', token)
  
  // 未登录:只放行 /login,其余全部重定向到 /login
  if (!token) {
    if (to.path !== '/login') {
      return next({ path: '/login', replace: true })
    }
    return next()
  }
  
  // 已登录:访问 /login 时重定向到用户管理页,其余正常放行
  if (to.path === '/login') {
    return next({ path: '/user', replace: true })
  }
  
  return next()
})
  
export default router

2. 拦截登录请求 (Axios)

修改 src/api/http.ts (或者你定义的 axios 配置文件)。让每次点击按钮发请求时,自动把 Token 塞进请求头。

import axios from 'axios'
  
// 创建一个 axios 实例,统一管理所有接口请求
const http = axios.create({
  // 所有请求都会以 /api 开头,通过 Vite 代理转发到后端 http://localhost:7111
  baseURL: '/api',
  timeout: 10000,
})
  
// 请求拦截器:在每次请求前,自动从 localStorage 读取 token,放到请求头中
http.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('auth_token')
  
    if (token) {
      config.headers = config.headers || {}
      config.headers.token = token
    }
  
    return config
  },
  (error) => Promise.reject(error),
)
  
http.interceptors.response.use(
  (resp) => resp.data,
  (error) => Promise.reject(error),
)
  
export default http

3. 编写前端登录页

src/views/Login/index.vue (新建目录和文件) 中粘贴以下代码。使用了 Inspira UI 的炫酷组件。

<template>
  <AuroraBackground class="bg-wrapper">
    <div class="login-overlay">
      <div class="login-panel">
        <el-card class="login-card" shadow="always">
          <h3 class="title">系统登录</h3>
  
          <el-form :model="form" :rules="rules" ref="formRef" label-position="left" label-width="0">
            <el-form-item prop="userName" class="form-item-vanish">
              <VanishingInput
                v-model="form.userName"
                :placeholders="namePlaceholders"
                @submit="onVanishingSubmitName"
              />
            </el-form-item>
  
            <el-form-item prop="password" class="form-item-vanish">
              <VanishingInput
                v-model="form.password"
                :placeholders="passwordPlaceholders"
                @submit="onVanishingSubmitPassword"
              />
            </el-form-item>
  
            <el-form-item>
              <el-checkbox v-model="form.remember">记住我</el-checkbox>
            </el-form-item>
  
            <el-form-item>
              <div class="btn-wrap">
                <RainbowButton @click="onSubmit">登录</RainbowButton>
              </div>
            </el-form-item>
          </el-form>
        </el-card>
      </div>
    </div>
  </AuroraBackground>
</template>
  
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
// 确保这些组件已经正确安装在 components 目录下
import AuroraBackground from '@/components/AuroraBackground.vue'
import VanishingInput from '@/components/inspira/VanishingInput.vue'
import RainbowButton from '@/components/inspira/RainbowButton.vue'
  
const router = useRouter()
const route = useRoute()
const formRef = ref()
  
// 登录表单数据
const form = reactive({
  userName: '',
  password: '',
  remember: false,
})
  
// 基本校验规则
const rules = {
  userName: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
  
// VanishingInput 占位内容
const namePlaceholders = ['请输入用户名', '示例:alice']
const passwordPlaceholders = ['请输入密码', '长度建议 8+ 位', '区分大小写']
  
// VanishingInput 提交时同步到 form
function onVanishingSubmitName(val: string) {
  form.userName = val
}
function onVanishingSubmitPassword(val: string) {
  form.password = val
}
  
// 点击登录按钮
const onSubmit = async () => {
  try {
    const resp = await fetch('/api/admin/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        userName: form.userName,
        password: form.password,
      }),
    })
    const json = await resp.json()
    console.log('login response:', json)
  
    if (json.code != 1 || !json.data?.token) {
      ElMessage.error(json.message || '用户名或密码错误')
      return
    }
  
    const loginInfo = json.data
  
    const roleNumber = typeof loginInfo.role === 'string' ? Number(loginInfo.role) : loginInfo.role
  
    localStorage.setItem('auth_token', loginInfo.token)
    localStorage.setItem(
      'login_user',
      JSON.stringify({
        userId: loginInfo.userId,
        userName: loginInfo.userName,
        role: roleNumber,
        avatarUrl: loginInfo.avatarUrl || '',
      }),
    )
    console.log('after setItem auth_token:', localStorage.getItem('auth_token'))
  
    ElMessage.success('登录成功')
    const redirect = (route.query.redirect as string) || '/user'
    router.replace(redirect)
  } catch (err) {
    console.error(err)
    ElMessage.error('登录失败,请检查网络或服务器')
  }
}
</script>
  
<style scoped>
/* 背景容器,保持背景组件充满视口 */
.bg-wrapper {
  position: relative;
  min-height: 100vh;
  display: block;
  overflow: hidden;
}
  
/* 覆盖层:使登录面板在背景正中央 */
.login-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
  
/* 登录面板允许交互 */
.login-panel {
  width: 460px;
  z-index: 10;
  pointer-events: auto;
}
  
/* 卡片:圆角矩形 */
.login-card {
  padding: 28px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.96);
  -webkit-backdrop-filter: blur(4px);
  backdrop-filter: blur(4px);
  border: 1px solid rgba(0, 0, 0, 0.06);
  color: #222;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
  display: flex;
  flex-direction: column;
  align-items: stretch;
}
  
/* 标题:黑色且居中 */
.title {
  margin: 0 0 16px 0;
  font-size: 22px;
  text-align: center;
  color: #000000;
  font-weight: 600;
}
  
/* VanishingInput 容器让两个输入外观一致 */
.form-item-vanish {
  margin-bottom: 14px;
}
/* 若 VanishingInput 内部需要填充样式,可在这里调整 wrapper */
.form-item-vanish > * {
  width: 100%;
  display: block;
}
  
/* 保证按钮有合理宽度 */
.btn-wrap ::v-deep button,
.btn-wrap ::v-deep .rainbow-button {
  min-width: 220px;
  border-radius: 8px;
  display: block;
}
/* 窗口窄时折叠布局:隐藏背景交互,表单仍居中 */
@media (max-width: 1000px) {
  .bg-wrapper {
    min-height: auto;
  }
  .login-overlay {
    position: static;
    padding: 24px 0;
  }
  .login-panel {
    width: 92%;
    max-width: 460px;
    margin: 0 auto;
  }
  .login-card {
    background: rgba(255, 255, 255, 0.98);
  }
}
</style>

**4. 优化 User 类逻辑

任务: 管理员可以修改数据,员工仅能进行查询。

打开 src/views/user/index.vue,在 <script><template> 中分别添加/修改以下代码:

修改 Script 部分:

(添加 props 定义)

<script setup lang="ts">
// ... 其他 import ...

const props = defineProps<{
  isAdmin: boolean
}>()

// ... 其他代码 ...
</script>

修改 Template 部分 (操作列):

(使用 v-if=“props.isAdmin” 来控制按钮的显示)

<el-table-column label="操作" width="120">
  <template #default="{ row }">
    <div class="action-buttons">
      <el-button v-if="props.isAdmin" size="small" type="primary" @click="updateUser(row)">
        <el-icon><EditPen /></el-icon>
      </el-button>
      <el-button v-if="props.isAdmin" size="small" type="danger" @click="delUser(row)">
        <el-icon><Delete /></el-icon>
      </el-button>
    </div>
  </template>
</el-table-column>

想温柔的对待这个世界