二十二、“仿小红书”全栈项目实现前后端分离(一)

0 阅读21分钟

1.1 前后端分离架构设计,构建现代化全栈开发体系

核心差异总结

特性原生 JSVue 3 组合式 API
DOM 操作方式直接选择和修改通过 ref 和响应式系统
状态管理全局变量局部响应式 ref/reactive
位置更新手动调用函数自动响应状态变化
代码可维护性分散在多个函数中集中在组件或自定义指令中
动画过渡需要额外的 CSS 或 JS可通过绑定样式自动处理

通过这些方式,你可以在 Vue 3 中实现更简洁、更易维护的功能,同时保持与原生 JS 相同的视觉效果。

2.1 Vue.js初始化前端项目,快速实现标准化开发准备

初始化仿“小红书”前端项目

在工作目录下,执行

npm create vue@latest

这一指令将会安装并执行 create-vue,它是 Vue 官方的项目脚手架工具。创建过程中选择 TypeScript、Router、Pinia等功能:

3>npm create vue@latest

> npx
> create-vue

T  Vue.js - The Progressive JavaScript Framework
|
o  请输入项目名称:
|  rednote-ui
|
o  请选择要包含的功能: (↑/↓ 切换,空格选择,a 全选,回车确认)
|  TypeScript, Router(单页面应用开发), Pinia(状态管理)
|
o  选择要包含的试验特性: (↑/↓ 切换,空格选择,a 全选,回车确认)
|  none

正在初始化项目 D:\workspace\gitee\java-full-stack-engineer-system-course-video\samples\course18\ch3\rednote-ui...
|
—  项目初始化完成,可执行以下命令:

   cd rednote-ui
   npm install
   npm run dev

| 可选:使用以下命令在项目目录中初始化 Git:

   git init && git add -A && git commit -m "initial commit"

上面命令创建了一个名为“rednote-ui”的Vue.js项目。

清理项目回归纯净

清理资源文件

清理src\assets目录下的所有资源文件。

清理组件文件

清理src\components目录下的所有组件文件。

清理全局状态文件

清理src\stores目录下的所有全局状态文件。

修改路由文件

修改src\router\index.ts,内容如下:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    }
  ],
})

export default router

修改视图

修改src\views\HomeView.vue,内容如下:

<script setup lang="ts">
</script>

<template>
  <main>
    <h1>rednote-ui</h1>
  </main>
</template>

删除视图

删除视图src\views\AboutView.vue

修改App.vue

修改src\App.vue,内容如下:

<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <header>
    <div class="wrapper">
      <nav>
        <RouterLink to="/">Home</RouterLink>
      </nav>
    </div>
  </header>

  <RouterView />
</template>

修改main.ts

修改src\main.ts,内容如下:

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

修改index.html

修改index.html引入静态资源,内容如下:

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="UTF-8">
    <link rel="icon" href="/favicon.ico">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RN</title>
    <!-- 引入 Bootstrap CSS -->
    <link href="/css/bootstrap.min.css" rel="stylesheet">
    <!-- 引入 Font Awesome -->
    <link href="/css/font-awesome.min.css" rel="stylesheet">
    
  </head>
  <body>
    <div id="app"></div>
    <!-- Bootstrap JS -->
    <script src="/js/bootstrap.bundle.min.js"></script>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

使用静态资源

将原rednote项目中的src/main/resources/static静态资源复制到rednote-ui项目public目录下。

安装 Axios

Axios 是一个基于 Promise 的 HTTP 客户端,专为浏览器和 Node.js 设计,具有以下特性:

  • 功能全面:支持 GET、POST、PUT、DELETE 等所有 HTTP 方法,提供请求/响应拦截器、取消请求、自动转换 JSON 数据等高级功能。
  • Promise API:与 Vue 3 的 Composition API(如 async/await)完美兼容,代码简洁易读。
  • 浏览器兼容性:支持 IE10+ 及现代浏览器,自动处理跨域请求和错误码。
  • 社区支持:拥有庞大的社区和丰富的插件(如 axios-retryaxios-mock-adapter),问题解决效率高。

通过以下命令在项目中安装 Axios

npm install axios

启动开发服务器

在项目被创建后,通过以下步骤安装依赖并启动开发服务器:

cd rednote-ui
npm install
npm run dev

看到如下输出,则说明已经运行起来了你的第一个Vue.js项目了!

VITE v7.0.2  ready in 16458 ms

➜  Local:   http://localhost:5173/
➜  Network: use --host to expose
➜  Vue DevTools: Open http://localhost:5173/__devtools__/ as a separate window
➜  Vue DevTools: Press Alt(⌥)+Shift(⇧)+D in App to toggle the Vue DevTools
➜  press h + enter to show help

项目默认运行在 http://localhost:5173,可以在浏览器中打开。

图3-1 RN项目首页

2.2 AI辅助编程工具成为Vue.js应用开发导师

本节介绍通AI辅助编程工具通义灵码在Visual Studio Code中的安装。让AI辅助编程工具成为Vue.js应用开发的导师。

手动安装步骤如下。

步骤1:已安装 Visual Studio Code 的情况下,在侧边导航上点击扩展。

图3-2 点击扩展

步骤2:搜索通义灵码(TONGYI Lingma),找到通义灵码后点击安装。

图3-3 搜索通义灵码

步骤3:登录阿里云账号,即刻开启智能编码之旅。通义灵码界面如下。

图3-4 通义灵码界面

3.1 全栈视角下的用户模块前后端划分与功能全流程剖析

将仿"小红书"项目的用户模块从 Thymeleaf 后端渲染迁移到 Vue 3 前端渲染,需要对架构、交互模式和数据流向进行全面调整。以下是核心改造点和实施建议。

架构层面的核心变化

1. 渲染模式转变

  • Thymeleaf 模式: 浏览器 → HTTP请求 → 后端控制器 → Thymeleaf模板 → HTML响应
  • Vue 3 模式: 浏览器 → 加载HTML骨架 → Vue初始化 → API请求 → 动态渲染

2. 数据交互方式

  • 传统方式:表单提交/页面跳转
  • Vue 方式
    // 用户登录示例
    async login() {
      try {
        const { data } = await this.$axios.post('/api/users/login', {
          username: this.username,
          password: this.password
        });
        this.$store.commit('setUser', data.user);
        this.$router.push('/home');
      } catch (error) {
        this.$message.error(error.response.data.message);
      }
    }
    

渐进式迁移策略

  1. 先构建 API 层

    • 为现有用户模块开发 REST API 接口
    • 确保新旧系统可以共存
  2. 组件级迁移

    • 先迁移独立组件(如登录表单)
    • 再迁移完整页面(如个人主页、设置页面)
  3. 路由过渡

    • 逐步将 Thymeleaf 路由替换为 Vue Router
    • 使用代理服务器处理新旧路由
  4. 状态管理整合

    • 在迁移期间保持 Session 和 Token 并存
    • 确保用户在迁移过程中不会丢失会话

通过以上改造,用户模块将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的状态管理、API 设计和渐进式迁移策略。

在 Vue 3 中实现分页序号生成

在 Vue 3 中,我们可以使用计算属性或方法来替代 Thymeleaf 的 ${#numbers.sequence(1, totalPage)} 功能。以下是几种实现方式:

方法一:使用计算属性生成页码数组

<template>
  <div class="pagination">
    <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
    
    <button 
      v-for="page in pageNumbers" 
      :key="page" 
      :class="{ active: page === currentPage }"
      @click="goToPage(page)"
    >
      {{ page }}
    </button>
    
    <button @click="nextPage" :disabled="currentPage === totalPage">下一页</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const currentPage = ref(1);
const totalPage = ref(10); // 从API获取或计算得到

// 计算属性生成页码数组
const pageNumbers = computed(() => {
  return Array.from({ length: totalPage.value }, (_, i) => i + 1);
});

const prevPage = () => {
  if (currentPage.value > 1) {
    currentPage.value--;
  }
};

const nextPage = () => {
  if (currentPage.value < totalPage.value) {
    currentPage.value++;
  }
};

const goToPage = (page) => {
  currentPage.value = page;
};
</script>

<style scoped>
.active {
  background-color: #007bff;
  color: white;
}
</style>

方法二:使用方法生成页码(带省略号)

<template>
  <div class="pagination">
    <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
    
    <button 
      v-for="page in getDisplayedPages" 
      :key="page" 
      :class="{ active: page === currentPage }"
      @click="goToPage(page)"
    >
      {{ page === '...' ? page : page }}
    </button>
    
    <button @click="nextPage" :disabled="currentPage === totalPage">下一页</button>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';

const currentPage = ref(1);
const totalPage = ref(15); // 从API获取或计算得到
const displayRange = ref(2); // 当前页面前后显示的页数

// 生成带省略号的页码数组
const getDisplayedPages = computed(() => {
  const pages = [];
  const start = Math.max(1, currentPage.value - displayRange.value);
  const end = Math.min(totalPage.value, currentPage.value + displayRange.value);
  
  // 添加第一页
  pages.push(1);
  
  // 添加左侧省略号
  if (start > 2) {
    pages.push('...');
  }
  
  // 添加中间页码
  for (let i = start; i <= end; i++) {
    pages.push(i);
  }
  
  // 添加右侧省略号
  if (end < totalPage.value - 1) {
    pages.push('...');
  }
  
  // 添加最后一页
  if (totalPage.value > 1) {
    pages.push(totalPage.value);
  }
  
  return pages;
});

// 其他方法保持不变
</script>

方法三:封装为可复用组件

<!-- Pagination.vue -->
<template>
  <div class="pagination">
    <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
    
    <button 
      v-for="page in getDisplayedPages" 
      :key="page" 
      :class="{ active: page === currentPage }"
      @click="goToPage(page)"
    >
      {{ page === '...' ? page : page }}
    </button>
    
    <button @click="nextPage" :disabled="currentPage === totalPage">下一页</button>
  </div>
</template>

<script setup>
import { ref, computed, defineProps, defineEmits } from 'vue';

const props = defineProps({
  currentPage: {
    type: Number,
    required: true
  },
  totalPage: {
    type: Number,
    required: true
  },
  displayRange: {
    type: Number,
    default: 2
  }
});

const emits = defineEmits(['page-change']);

// 生成带省略号的页码数组
const getDisplayedPages = computed(() => {
  // 实现同上一个示例
});

const prevPage = () => {
  if (props.currentPage > 1) {
    emits('page-change', props.currentPage - 1);
  }
};

const nextPage = () => {
  if (props.currentPage < props.totalPage) {
    emits('page-change', props.currentPage + 1);
  }
};

const goToPage = (page) => {
  if (page !== '...') {
    emits('page-change', page);
  }
};
</script>

在父组件中使用:

<template>
  <div>
    <Pagination 
      :currentPage="currentPage" 
      :totalPage="totalPage" 
      @page-change="handlePageChange" 
    />
  </div>
</template>

<script setup>
import { ref } from 'vue';
import Pagination from './components/Pagination.vue';

const currentPage = ref(1);
const totalPage = ref(10);

const handlePageChange = (page) => {
  currentPage.value = page;
  // 加载新页面的数据
  fetchData(page);
};
</script>

Vue 3 与 Thymeleaf 的对比

功能ThymeleafVue 3
生成页码数组${#numbers.sequence(1, totalPage)}Array.from({length: totalPage}, (_, i) => i + 1)
条件渲染th:ifv-if
循环渲染th:eachv-for
事件处理th:onclick@click
样式绑定th:classappend:class
文本绑定th:textv-text

3.2 全栈实战注册功能:从需求分析到接口联调的全栈开发流程

将用户注册功能从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对架构、交互流程和安全机制进行全面调整。以下是核心改造点和实施建议:

架构与交互模式的核心变化

1. 数据流转方式

  • Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 验证 → 重定向/错误页面
  • Vue 3 模式: 浏览器 → Vue表单组件 → API请求 → 后端验证 → JSON响应 → 前端处理结果

2. 接口实现方式

  • Thymeleaf 模式

    • 表单提交场景(application/x-www-form-urlencoded)
    • 适用场景:传统表单提交,非 JSON 数据
    • 使用方式:@ModelAttribute + @Valid
  • Vue 3 模式

    • JSON 请求场景(application/json)
    • 适用场景:前后端分离的 REST API
    • 使用方式:@RequestBody + @Valid

后端接口改造

修改 AuthController.java:

/**
 * 处理注册表单提交
 */
@PostMapping("/register")
/*public String processRegistrationForm(@Valid @ModelAttribute("user") UserRegistrationDto registrationDto,
                                        BindingResult bindingResult,
                                        Model model) {
    // 检查用户名是否已存在
    if (userService.existsByUsername(registrationDto.getUsername())) {
        bindingResult.rejectValue("username", null, "该用户名已被使用");
    }

    // 检查手机号是否已注册
    if (userService.existsByPhone(registrationDto.getPhone())) {
        bindingResult.rejectValue("phone", null, "该手机号已被注册");
    }

    // 检查手机验证码是否校验通过
    if (!userService.verifyCode(registrationDto.getPhone(), registrationDto.getVerificationCode())) {
        bindingResult.rejectValue("verificationCode", null, "验证码不正确");
    }

    // 检查用户名、手机号、验证码是否通过
    // 如果有错误,则返回注册页面
    if (bindingResult.hasErrors()) {
        model.addAttribute("user", registrationDto);
        return "registration-form";
    }

    // 注册用户
    userService.registerUser(registrationDto);

    // 注册成功,跳转到登录页面
    return "redirect:/auth/login";
}*/
public ResponseEntity<?> processRegistrationForm(@Valid @RequestBody UserRegistrationDto registrationDto,
                                                    BindingResult bindingResult) {
    // 检查用户名是否已存在
    if (userService.existsByUsername(registrationDto.getUsername())) {
        bindingResult.rejectValue("username", null, "该用户名已被使用");
    }

    // 检查手机号是否已注册
    if (userService.existsByPhone(registrationDto.getPhone())) {
        bindingResult.rejectValue("phone", null, "该手机号已被注册");
    }

    // 检查手机验证码是否校验通过
    if (!userService.verifyCode(registrationDto.getPhone(), registrationDto.getVerificationCode())) {
        bindingResult.rejectValue("verificationCode", null, "验证码不正确");
    }

    // 检查用户名、手机号、验证码是否通过
    // 如果有错误,则返回错误列表
    if (bindingResult.hasErrors()) {
        // 自定义错误响应
        Map<String, String> errors = new HashMap<>();
        bindingResult.getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );

        return ResponseEntity.badRequest().body(errors);
    }

    // 注册用户
    userService.registerUser(registrationDto);

    // 注册成功
    return ResponseEntity.ok("用户注册成功");
}

后端安全配置调整

  1. 禁用CSRF防护
  2. 会话管理使用无状态会话

修改WebSecurityConfig如下:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // 启用CSRF防护
            // .csrf(Customizer.withDefaults())
            // 禁用CSRF防护
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(authorize -> authorize
                    // 允许指定资源的请求不需要认证
                    .requestMatchers("/auth/register", "/auth/login", "/css/**", "/js/**", "/fonts/**", "/images/**", "/favicon.ico").permitAll()
                    .requestMatchers("/error/**").permitAll()
                    // 允许ADMIN角色的用户访问 /admin/** 的资源
                    .requestMatchers("/admin/**").hasRole("ADMIN")
                    // 允许ADMIN、USER角色的用户访问 /user/** 的资源
                    .requestMatchers("/user/**").hasAnyRole("ADMIN", "USER")
                    // 允许USER角色的用户访问 /note/** 的资源
                    .requestMatchers("/note/**").hasRole("USER")
                    // 允许USER角色的用户访问 /explore/** 的资源
                    .requestMatchers("/explore/**").hasRole("USER")
                    // 允许USER角色的用户访问 /like/** 的资源
                    .requestMatchers("/like/**").hasRole("USER")
                    // 允许USER角色的用户访问 /comment/** 的资源
                    .requestMatchers("/comment/**").hasRole("USER")
                    // 允许USER角色的用户访问 /log/** 的资源
                    .requestMatchers("/log/**").hasRole("USER")
                    // 允许ADMIN、USER角色的用户访问 /file/** 的资源
                    .requestMatchers("/file/**").hasAnyRole("ADMIN", "USER")
                    // 其他请求需求认证
                    .anyRequest().authenticated()
            )
            .formLogin(form -> form
                    // 指定登录页面
                    .loginPage("/auth/login")
                    // 指定执行登录的地址
                    .loginProcessingUrl("/auth/login")
                    // 自定义失败处理器
                    .failureHandler(authenticationFailureHandler())
                    // 指定登录成功后跳转的页面
                    .defaultSuccessUrl("/")
                    .permitAll()
            )
            // 异常处理
            .exceptionHandling(exception -> exception
                    // 指定403错误页面
                    .accessDeniedPage("/error/403")
            )
            // 会话管理
            /*.sessionManagement(session -> session
                    // 会话创建策略
                    .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
                    // 访问无效会话时,重定向到指定URL
                    .invalidSessionUrl("/auth/login?error=" + SESSION_INVALID)
                    // 同一用户最大会话数
                    .maximumSessions(1)
                    // 访问过期会话时,重定向到指定URL
                    .expiredUrl("/auth/login?error=" + SESSION_EXPIRED)
                    // false表示允许新登录,踢掉旧会话,旧会话会过期
                    .maxSessionsPreventsLogin(false)
                    // 会话注册表
                    .sessionRegistry(sessionRegistry())
            )*/
            // 无状态会话
            .sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            // 注销
            .logout(logout -> logout
                    // 清理会话
                    .invalidateHttpSession( true)
                    // 清理认证信息
                    .clearAuthentication(true)
                    // 用户访问此URL时,交由Spring Security处理注销逻辑
                    .logoutUrl("/logout")
                    // 注销成功后,重定向到指定URL
                    .logoutSuccessUrl("/auth/login?error=" + LOGOUT)
                    // 删除会话Cookie
                    .deleteCookies("JSESSIONID")

            )
            // 记住我
            .rememberMe(rememberMe -> rememberMe
                    // 设置记住我令牌的有效期(秒),默认是2周。以下设置1周
                    .tokenValiditySeconds(60 * 60 * 24 * 7)
                    // 设置用于签名令牌的密钥
                    .key("rnRememberMeKey")
            )
    ;

    return http.build();
}

// ...为节约篇幅,此处省略非核心内容

/*@Bean
public SessionRegistry sessionRegistry() {
    return new SessionRegistryImpl();
}*/

3.3 AI辅助编程快速实现注册页面及与后端API联调

前端新增错误验证接口类似

新建src\errors\api-validation-error.ts,代码如下:

export interface ApiValidationError {
  [field: string]: string;
}

前端新增注册表单组件

新建 src\views\RegistrationForm.vue,相关代码可以从后端应用的src/main/resources/templates/registration-form.html拷贝过来进行微调即可。调整后代码如下:

<script setup lang="ts">
import type { ApiValidationError } from '@/errors/api-validation-error'
import { ref, onUnmounted } from 'vue'
import axios, { AxiosError } from 'axios'
import { useRouter } from 'vue-router'

const form = ref({
  username: '',
  phone: '',
  verificationCode: '',
  password: ''
})

// 错误信息使用ApiValidationError类型
const errors = ref<ApiValidationError>({})

// 获取router实例
const router = useRouter()

// 注册逻辑
const handleRegister = async () => {
  // 重置错误信息
  errors.value = {}

  try {
    // 发送注册请求
    await axios.post('/api/auth/register', form.value)

    // 提示注册成功
    alert('注册成功,请登录')

    // 重置错误信息
    errors.value = {}

    // 跳转到登录页面
    router.push({ name: 'login' })
  } catch (error) {
    // 验证码发送失败
    if (error instanceof AxiosError) {
      // 获取错误信息
      const axiosError = error as AxiosError<ApiValidationError>
      if (axiosError.response?.status === 400 && axiosError.response.data) {
        // 绑定后端返回的错误信息到errors上
        errors.value = axiosError.response.data
      }
    }
  }
}

// 验证码倒计时相关状态
const countdown = ref(60)
const timer = ref<number | null>(null)
const isCounting = ref(false)

// 获取验证码倒计时函数
const startCountdown = () => {
  if (countdown.value === 60 && !isCounting.value) {
    isCounting.value = true
    timer.value = window.setInterval(() => {
      countdown.value--
      if (countdown.value === 0) {
        clearInterval(timer.value!)
        countdown.value = 60
        isCounting.value = false
      }
    }, 1000)
  }
}

// 组件卸载时清理定时器
onUnmounted(() => {
  if (timer.value) {
    clearInterval(timer.value)
  }
})

</script>
<template>
  <div class="container align-items-center min-vh-100 py-4">
    <div class="form-container">
      <!-- Logo -->
      <div class="logo">
        <img src="/images/rn_avatar.png" alt="Logo" class="rounded-circle">
      </div>

      <!-- 表单标题 -->
      <h2 class="form-title">欢迎注册RN</h2>

      <!-- 注册表单 -->
      <form id="registrationForm" method="post" @submit.prevent="handleRegister">
        <!-- 用户名输入框 -->
        <div class="mb-3">
          <input type="text" class="form-control" id="username" name="username" v-model="form.username"
            placeholder="请设置用户名" required>
          <div class="error-message" id="usernameError" v-if="errors.username">{{ errors.username }}</div>
        </div>

        <!-- 手机号输入框 -->
        <div class="mb-3">
          <input type="text" class="form-control" id="phone" name="phone" v-model="form.phone" placeholder="请输入手机号"
            required>
          <div class="error-message" id="phoneError" v-if="errors.phone">{{ errors.phone }}</div>
        </div>

        <!-- 验证码输入框 -->
        <div class="mb-3">
          <div class="input-group">
            <input type="text" class="form-control" id="verificationCode" name="verificationCode"
              v-model="form.verificationCode" placeholder="请输入验证码" required>
            <button type="button" class="btn btn-outline-secondary" id="getCodeBtn" @click="startCountdown"
              :disabled="isCounting">
              {{ isCounting ? countdown + '秒后重新获取' : '获取验证码' }}
            </button>
          </div>
          <div class="error-message" id="verificationCodeError" v-if="errors.verificationCode">{{
            errors.verificationCode }}</div>
        </div>

        <!-- 密码输入框 -->
        <div class="mb-3">
          <input type="password" class="form-control" id="password" name="password" v-model="form.password"
            placeholder="请设置密码" required>
          <div class="error-message" id="passwordError" v-if="errors.password">{{ errors.password }}</div>
        </div>

        <!--注册按钮 -->
        <button class="btn btn-primary w-100" type="submit">立即注册</button>
      </form>

      <!-- 已有账号 -->
      <div class="form-footer">
        已有账号? <a href="/auth/login">立即登录</a>
      </div>

      <!-- 其他登录方式 -->
      <div class=" divider">
          <span>其他登录方式</span>
      </div>

      <!-- 社交登录 -->
      <div class="social-login">
        <a href="#" class="social-btn">
          <i class="fa fa-weixin"></i>
        </a>
        <a href="#" class="social-btn">
          <i class="fa fa-weibo"></i>
        </a>
        <a href="#" class="social-btn">
          <i class="fa fa-qq"></i>
        </a>
      </div>

      <!-- 用户协议、隐藏政策 -->
      <div class="policy">
        注册即表示同意<a href="#">用户协议</a>和<a href="#">隐藏政策</a>
      </div>
    </div>
  </div>
</template>
<style setup>
body {
  background-color: #fef6f6;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

.form-container {
  background-color: white;
  border-radius: 16px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
  padding: 32px;
  max-width: 400px;
  margin: 0 auto;
}

.logo {
  text-align: center;
  margin-bottom: 32px;
}

.logo img {
  width: 64px;
  height: 64px;
}

.form-title {
  font-size: 24px;
  font-weight: 700;
  color: #333;
  margin-bottom: 24px;
  text-align: center;
}

.form-control {
  border-radius: 12px;
  border: 1px solid #e8e8e8;
  padding: 12px 16px;
  height: auto;
  font-size: 14px;
}

.form-control:focus {
  border-color: #ff2442;
  box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}

.btn-primary {
  background-color: #ff2442;
  border-color: #ff2442;
  border-radius: 12px;
  padding: 12px;
  font-size: 16px;
  font-weight: 600;
  transition: all 0.3s ease;
}

.btn-primary:hover,
.btn-primary:focus {
  background-color: #e61e3a;
  border-color: #e61e3a;
  box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}

.btn-outline-secondary {
  border-radius: 12px;
  padding: 12px;
  font-size: 14px;
  color: #666;
  border-color: #e8e8e8;
}

.btn-outline-secondary:hover {
  background-color: #f8f8f8;
  border-color: #ddd;
}

.form-footer {
  text-align: center;
  margin-top: 24px;
  font-size: 14px;
  color: #666;
}

.form-footer a {
  color: #ff2442;
  text-decoration: none;
}

.form-footer a:hover {
  text-decoration: underline;
}

.divider {
  display: flex;
  align-items: center;
  margin: 24px 0;
  color: #999;
  font-size: 14px;
}

.divider::before,
.divider::after {
  content: '';
  flex: 1;
  border-bottom: 1px solid #e8e8e8;
}

.divider::before {
  margin-right: 16px;
}

.divider::after {
  margin-left: 16px;
}

.social-login {
  display: flex;
  justify-content: center;
  gap: 24px;
  margin-top: 24px;
}

.social-btn {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid #e8e8e8;
  transition: all 0.3s ease;
}

.social-btn:hover {
  background-color: #f8f8f8;
  transform: translateY(-2px);
}

.social-btn i {
  font-size: 20px;
  color: #666;
}

.policy {
  font-size: 12px;
  color: #999;
  text-align: center;
  margin-top: 16px;
}

.policy a {
  color: #999;
  text-decoration: underline;
}

.error-message {
  color: #ff2442;
  font-size: 12px;
  margin-top: 4px;
  /* display: none; */
}
</style>

设置反向代理

// 设置反向代理
server: {
  host: 'localhost',
  port: 5173, // Vue开发端口
  proxy: {
    '/api': {
      // 指向Spring Boot后端地址(假设后端运行在8080端口)
      target: 'http://localhost:8080',
      changeOrigin: true,
      rewrite: (path) => path.replace(/^\/api/, '')
    }
  }
}

配置说明

  • /api:代理路径前缀,前端请求以/api开头的 URL 会被转发
  • target:Spring Boot 后端的基础地址
  • changeOrigin:设置为 true 以支持跨域请求
  • rewrite:去除 URL 中的/api前缀,确保后端正确接收路径

修改路由

修改路由文件src\router\index.ts,内容如下:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
    },
    {
      path: '/auth/register',
      name: 'register',
      // 当访问该路径时,它被延迟加载
      component: () => import('../views/RegistrationForm.vue'),
    },
  ],
})

export default router

修改App.vue

修改src\App.vue文件,删除<header>,内容如下:

<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <!--
  <header>
    <div class="wrapper">
      <nav>
        <RouterLink to="/">Home</RouterLink>
      </nav>
    </div>
  </header>
  -->
  <RouterView />
</template>

运行调测

运行应用执行注册,注册失败界面效果如下图4-1所示。

图4-1 注册失败界面效果

注册成功界面效果如下图4-2所示。

图4-2 注册成功界面效果

通过以上改造,用户注册功能将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的安全机制、表单验证和用户体验优化。

3.4 全栈实战登录功能:密码加密与 Token 认证的攻防实战

将用户登录功能从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对架构、交互流程和安全机制进行全面调整。以下是核心改造点和实施建议。

一、架构与交互模式的核心变化

1. 数据流转方式

  • Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 验证 → Session 存储 → 重定向到主页
  • Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端验证 → JWT/会话令牌 → 前端存储 → 路由跳转

2. 安全机制调整

  • CSRF 防护
    • Thymeleaf:自动注入 CSRF 令牌到表单
    • Vue 3:禁用 CSRF 令牌
  • 认证方式
    • Thymeleaf:基于 Session/Cookie
    • Vue 3:推荐 JWT(JSON Web Token)或增强的 Session 机制

后端接口改造

修改 AuthController.java:

import com.waylau.rednote.config.JwtTokenProvider;
import org.springframework.security.authentication.AuthenticationManager;

// ...为节约篇幅,此处省略非核心内容

@Autowired
private JwtTokenProvider jwtTokenProvider;

@Autowired
private AuthenticationManager authenticationManager;

// ...为节约篇幅,此处省略非核心内容

/**
 * 处理登录表单的提交
 */
@PostMapping("/login")
/*public String processLoginForm(@Valid @ModelAttribute("user") UserLoginDto loginDto,
                                BindingResult bindingResult,
                                Model model) {
    // 检查用户名是否存在
    if (!userService.existsByUsername(loginDto.getUsername())) {
        bindingResult.rejectValue("username", null, "该用户名未注册");

        model.addAttribute("user", loginDto);
        return "login-form";
    }

    // 检查密码是否正确
    if (!userService.verifyPassword(loginDto.getUsername(), loginDto.getPassword())) {
        bindingResult.rejectValue("password", null, "密码错误");

        model.addAttribute("user", loginDto);
        return "login-form";
    }

    // 登录成功,重定向到首页或
    return "redirect:/";
}*/
public ResponseEntity<?> processLoginForm(@Valid @RequestBody UserLoginDto loginDto,
                                BindingResult bindingResult) {
    // 检查用户名是否存在
    if (!userService.existsByUsername(loginDto.getUsername())) {
        bindingResult.rejectValue("username", null, "该用户名未注册");

        // 自定义错误响应
        Map<String, String> errors = new HashMap<>();
        bindingResult.getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.badRequest().body(errors);
    }

    // 检查密码是否正确
    if (!userService.verifyPassword(loginDto.getUsername(), loginDto.getPassword())) {
        bindingResult.rejectValue("password", null, "密码错误");

        // 自定义错误响应
        Map<String, String> errors = new HashMap<>();
        bindingResult.getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return ResponseEntity.badRequest().body(errors);
    }

    // 获取认证用户
    Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(
                    loginDto.getUsername(),
                    loginDto.getPassword()
            )
    );

    // 校验成功,生成JWT
    String jwt = jwtTokenProvider.generateToken(authentication);

    // 返回响应
    return ResponseEntity.ok(jwt);
}

后端安全配置调整

  1. 取消.formLogin()
  2. 取消.rememberMe()
  3. 取消.logout()
  4. 启用 JWT 认证过滤器
  5. 配置 AuthenticationManager Bean

修改WebSecurityConfig如下:

import org.springframework.security.authentication.AuthenticationManager;

// ...为节约篇幅,此处省略非核心内容

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // ...为节约篇幅,此处省略非核心内容

            /*.formLogin(form -> form
                    // 指定登录页面
                    .loginPage("/auth/login")
                    // 指定执行登录的地址
                    .loginProcessingUrl("/auth/login")
                    // 自定义失败处理器
                    .failureHandler(authenticationFailureHandler())
                    // 指定登录成功后跳转的页面
                    .defaultSuccessUrl("/")
                    .permitAll()
            )*/

            // ...为节约篇幅,此处省略非核心内容

            // 注销
            /*.logout(logout -> logout
                    // 清理会话
                    .invalidateHttpSession( true)
                    // 清理认证信息
                    .clearAuthentication(true)
                    // 用户访问此URL时,交由Spring Security处理注销逻辑
                    .logoutUrl("/logout")
                    // 注销成功后,重定向到指定URL
                    .logoutSuccessUrl("/auth/login?error=" + LOGOUT)
                    // 删除会话Cookie
                    .deleteCookies("JSESSIONID")

            )*/
            // 记住我
            /*.rememberMe(rememberMe -> rememberMe
                    // 设置记住我令牌的有效期(秒),默认是2周。以下设置1周
                    .tokenValiditySeconds(60 * 60 * 24 * 7)
                    // 设置用于签名令牌的密钥
                    .key("rnRememberMeKey")
            )*/
            // 启用JWT认证过滤器
            .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
    ;

    return http.build();
}

// ...为节约篇幅,此处省略非核心内容

@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
    return new JwtAuthenticationFilter();
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
    return authenticationConfiguration.getAuthenticationManager();
}

JWT 认证实现

添加 JJWT (Java JWT) 库的依赖。Jwts 类是 JJWT 库的核心类,用于创建、解析和验证 JWT 令牌。以下是解决方案。

1. Maven 项目

pom.xml 中添加:

<properties>
    <java.version>24</java.version>
    <jsonwebtoken.version>0.13.0</jsonwebtoken.version>
</properties>

<!-- ...为节约篇幅,此处省略非核心内容 -->

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>${jsonwebtoken.version}</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>${jsonwebtoken.version}</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>${jsonwebtoken.version}</version>
    <scope>runtime</scope>
</dependency>

2. JWT 工具类

新建src/main/java/com/waylau/rednote/config/JwtTokenProvider.java

package com.waylau.rednote.config;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;

/**
 * JwtTokenProvider JWT工具类
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/09/08
 **/
@Component
public class JwtTokenProvider {
    private static final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);

    @Value("${app.jwtSecret}")
    private String jwtSecret;

    @Value("${app.jwtExpirationMs}")
    private long jwtExpirationMs;

    /**
     * 生成JWT
     *
     * @param authentication
     * @return
     */
    public String generateToken(Authentication authentication) {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();

        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(new Date().getTime() + jwtExpirationMs))
                .signWith(SignatureAlgorithm.HS256, jwtSecret)
                .compact();
    }

    /**
     * 从JWT中获取用户名
     *
     * @param token
     * @return
     */
    public String getUsernameFromJwtToken(String token) {
        return Jwts.parser()
                .setSigningKey(jwtSecret)
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    /**
     * 验证JWT
     *
     * @param authToken
     * @return
     */
    public boolean validateJwtToken(String authToken) {
        try {
            Jwts.parser()
                    .setSigningKey(jwtSecret)
                    .build()
                    .parseClaimsJws(authToken);

            return true;
        } catch (Exception e) {
            log.error("Invalid JWT token: {}", e.getMessage());
        }

        return false;
    }
}

3. JWT 认证过滤器

新建src/main/java/com/waylau/rednote/config/JwtAuthenticationFilter.java

package com.waylau.rednote.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * JwtAuthenticationFilter JWT认证过滤器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/09/08
 **/
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 从请求头中获取JWT
        String jwt = getJwtFromRequest(request);
        if (jwt != null && jwtTokenProvider.validateJwtToken(jwt)) {
            // 从JWT中获取用户名
            String username = jwtTokenProvider.getUsernameFromJwtToken(jwt);

            // 从用户名中获取用户详情
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            // 创建一个已认证的Authentication对象
            UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                    userDetails, null, userDetails.getAuthorities()
            );
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

            // 设置已认证的Authentication对象到SecurityContextHolder中
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

    /**
     * 从请求头中获取JWT
     */
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

4. 配置文件

application.properties 中添加 JWT 配置:

# 配置 JWT
# 你的Base64编码密钥(至少256位)
app.jwtSecret=bQUBj9U7io0VXuhlaC9XmeaSGSwkqOlG4itHzIgUvOk=
# 24小时
app.jwtExpirationMs=86400000

生成 Base64 密钥的方法:

package com.waylau.rednote;

import io.jsonwebtoken.security.Keys;

import java.util.Base64;

/**
 * JwtSecretGenerator 生成Base64密钥
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/09/08
 **/
public class JwtSecretGenerator {
    public static void main(String[] args) {
        String secret = Base64.getEncoder().encodeToString(
                Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256).getEncoded()
        );
        System.out.println(secret);
    }
}

3.5 AI辅助编程快速实现登录页面及与后端API联调

前端新增登录表单组件

新建 src\views\LoginForm.vue,相关代码可以从后端应用的src/main/resources/templates/login-form.html拷贝过来进行微调即可调整后代码如下:

<script setup lang="ts">
import type { ApiValidationError } from '@/errors/api-validation-error'
import { ref } from 'vue'
import axios, { AxiosError } from 'axios'
import { useRouter } from 'vue-router'

const form = ref({
  username: '',
  password: ''
})

// 错误信息使用ApiValidationError类型
const errors = ref<ApiValidationError>({})

// 获取router实例
const router = useRouter()

// 登录逻辑
const handleLogin = async () => {
  // 重置错误信息
  errors.value = {}

  try {
    // 发送登录请求
    const response = await axios.post('/api/auth/login', form.value)

    // 存储JWT到localStorage中
    localStorage.setItem('token', response.data)

    // 重置错误信息
    errors.value = {}

    // 跳转到主页页面
    router.push({ name: 'home' })
  } catch (error) {
    // 登录失败
    if (error instanceof AxiosError) {
      // 获取错误信息
      const axiosError = error as AxiosError<ApiValidationError>
      if (axiosError.response?.status === 400 && axiosError.response.data) {
        // 绑定后端返回的错误信息到errors上
        errors.value = axiosError.response.data
      }
    }
  }
}

const showPassword = ref(false)

</script>
<template>
  <div class="container align-items-center min-vh-100 py-4">
    <div class="form-container">
      <!-- Logo -->
      <div class="logo">
        <img src="/images/rn_avatar.png" alt="Logo" class="rounded-circle">
      </div>

      <!-- 表单标题 -->
      <h2 class="form-title">欢迎登录RN</h2>

      <!-- 注册表单 -->
      <form id="loginForm" method="post" @submit.prevent="handleLogin">
        <!-- 用户名输入框 -->
        <div class="mb-3">
          <input type="text" class="form-control" id="username" name="username" v-model="form.username"
            placeholder="请输入用户名" required>
          <div class="error-message" id="usernameError" v-if="errors.username">{{ errors.username }}</div>
        </div>

        <!-- 密码输入框 -->
        <div class="mb-3">
          <div class="input-group">
            <input :type="showPassword ? 'text' : 'password'" class="form-control" id="password" name="password"
              v-model="form.password" placeholder="请设置密码" required>
            <!-- 切换密码显示模式 -->
            <button type="button" class="btn btn-outline-secondary" id="togglePassword"
              @click="showPassword = !showPassword">
              <i :class="showPassword ? 'fa fa-eye' : 'fa fa-eye-slash'"></i>
            </button>
          </div>

          <div class="error-message" id="passwordError" v-if="errors.password">{{ errors.password }}</div>
        </div>

        <!-- 记住我 -->
        <div class="form-check mb-3">
          <input type="checkbox" class="form-check-input" id="rememberMe" name="remember-me">
          <label class="form-check-label" for="rememberMe">记住我</label>
        </div>

        <!--登录按钮 -->
        <button class="btn btn-primary w-100" type="submit">登录</button>
      </form>


    </div>
    <!-- 忘记密码 -->
    <div class="form-footer">
      <a href="#">忘记密码</a>
    </div>

    <!-- 其他登录方式 -->
    <div class="divider">
      <span>其他登录方式</span>
    </div>

    <!-- 社交登录 -->
    <div class="social-login">
      <a href="#" class="social-btn">
        <i class="fa fa-weixin"></i>
      </a>
      <a href="#" class="social-btn">
        <i class="fa fa-weibo"></i>
      </a>
      <a href="#" class="social-btn">
        <i class="fa fa-qq"></i>
      </a>
    </div>

    <!-- 注册链接 -->
    <div class="form-footer">
      还没有账号? <a href="/auth/register">立即注册</a>
    </div>

    <!-- 用户协议、隐藏政策 -->
    <div class="policy">
      注册即表示同意<a href="#">用户协议</a>和<a href="#">隐藏政策</a>
    </div>
  </div>
</template>
<style setup>
body {
  background-color: #fef6f6;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

.form-container {
  background-color: white;
  border-radius: 16px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
  padding: 32px;
  max-width: 400px;
  margin: 0 auto;
}

.logo {
  text-align: center;
  margin-bottom: 32px;
}

.logo img {
  width: 64px;
  height: 64px;
}

.form-title {
  font-size: 24px;
  font-weight: 700;
  color: #333;
  margin-bottom: 24px;
  text-align: center;
}

.form-control {
  border-radius: 12px;
  border: 1px solid #e8e8e8;
  padding: 12px 16px;
  height: auto;
  font-size: 14px;
}

.form-control:focus {
  border-color: #ff2442;
  box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}

.btn-primary {
  background-color: #ff2442;
  border-color: #ff2442;
  border-radius: 12px;
  padding: 12px;
  font-size: 16px;
  font-weight: 600;
  transition: all 0.3s ease;
}

.btn-primary:hover,
.btn-primary:focus {
  background-color: #e61e3a;
  border-color: #e61e3a;
  box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}

.btn-outline-secondary {
  border-radius: 12px;
  padding: 12px;
  font-size: 14px;
  color: #666;
  border-color: #e8e8e8;
}

.btn-outline-secondary:hover {
  background-color: #f8f8f8;
  border-color: #ddd;
}

.form-footer {
  text-align: center;
  margin-top: 24px;
  font-size: 14px;
  color: #666;
}

.form-footer a {
  color: #ff2442;
  text-decoration: none;
}

.form-footer a:hover {
  text-decoration: underline;
}

.divider {
  display: flex;
  align-items: center;
  margin: 24px 0;
  color: #999;
  font-size: 14px;
}

.divider::before,
.divider::after {
  content: '';
  flex: 1;
  border-bottom: 1px solid #e8e8e8;
}

.divider::before {
  margin-right: 16px;
}

.divider::after {
  margin-left: 16px;
}

.social-login {
  display: flex;
  justify-content: center;
  gap: 24px;
  margin-top: 24px;
}

.social-btn {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid #e8e8e8;
  transition: all 0.3s ease;
}

.social-btn:hover {
  background-color: #f8f8f8;
  transform: translateY(-2px);
}

.social-btn i {
  font-size: 20px;
  color: #666;
}

.policy {
  font-size: 12px;
  color: #999;
  text-align: center;
  margin-top: 16px;
}

.policy a {
  color: #999;
  text-decoration: underline;
}

.error-message {
  color: #ff2442;
  font-size: 12px;
  margin-top: 4px;
  /* display: none; */
}
</style>

修改路由

修改路由文件src\router\index.ts,内容如下:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...为节约篇幅,此处省略非核心内容
    ,
    {
      path: '/auth/login',
      name: 'login',
      component: () => import('../views/LoginForm.vue'),
    },
  ],
})

export default router

运行调测

运行应用执行登录,登录失败界面效果如下图4-3所示。

图4-3 登录失败界面效果

登录成功界面效果如下图4-4所示。

图4-4 登录成功界面效果

通过以上改造,用户登录功能将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的安全机制、状态管理和用户体验优化。

3.6 前端全局认证状态管理与路由守卫

后端接口改造

UserController 接口改造如下:

@GetMapping("/profile")
/*public String profile(Model model) {
    // 获取当前用户信息
    User user = userService.getCurrentUser();

    *//*model.addAttribute("user", user);

    return "user-profile";*//*

    // 重定向
    return "redirect:/user/profile/" + user.getUserId();
}*/
public ResponseEntity<User> profile() {
    // 获取当前用户信息
    User user = userService.getCurrentUser();

    return ResponseEntity.ok(user);
}

使用 Pinia 管理认证状态

新建认证状态管理文件src\stores\auth.ts

import { defineStore } from "pinia"
import { useRouter } from "vue-router"
import axios from "axios"

export const useAuthStore = defineStore("auth", {
  state: () => ({
    user: null,
    token: localStorage.getItem("token") || null,
    isAuthenticated: false,
  }),
  getters: {
    getUser: (state) => state.user,
    getToken: (state) => state.token,
    getIsAuthenticated: (state) => state.isAuthenticated,
  },
  actions: {
    // 登录
    async login(username: string, password: string) {
      try {
        const response = await axios.post("/api/auth/login", {
          username,
          password,
        })
        this.token = response.data

        if (this.token) {
          localStorage.setItem("token", this.token)
          this.isAuthenticated = true
          axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`

          // 获取用户信息
          await this.fetchUser()

          return true;
        } else {
          localStorage.removeItem("token")
          this.isAuthenticated = false

          return false;
        }
      } catch (error) {
        this.logout()
        throw error
      }
    },
    // 获取用户信息
    async fetchUser() {
      try {
        const response = await axios.get("/api/user/profile")
        this.user = response.data
      } catch (error) {
        this.logout()
        throw error
      }
    },
    // 注销
    logout() {
      this.user = null
      this.token = null
      this.isAuthenticated = false;
      localStorage.removeItem('token')
      axios.defaults.headers.common['Authorization'] = null

      // 跳转到登录页面
      const router = useRouter()
      router.push({ name: 'login' })
    },
    // 检查认证状态(比如页面刷新后恢复)
    async checkAuth() {
      const storedToken = localStorage.getItem('token')
      if (storedToken) {
        this.token = storedToken
        this.isAuthenticated = true
        await this.fetchUser()
      }
    }
  }
})

在组件中使用认证状态

修改 src\views\LoginForm.vue

import { useAuthStore } from '@/stores/auth'

// 获取useAuthStore实例
const authStore = useAuthStore()

// ...为节约篇幅,此处省略非核心内容

// 登录逻辑
const handleLogin = async () => {
  // 重置错误信息
  errors.value = {}

  try {
    // 发送登录请求
    /*
    const response = await axios.post('/api/auth/login', form.value)

    // 存储JWT到localStorage中
    localStorage.setItem('token', response.data)
    */
    await authStore.login(form.value.username, form.value.password)

    // 重置错误信息
    errors.value = {}

    // 跳转到主页页面
    router.push({ name: 'home' })
  } catch (error) {
    // ...为节约篇幅,此处省略非核心内容
  }
}

路由守卫配置

修改路由文件src\router\index.ts,内容如下:

import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'home',
      component: HomeView,
      // 需要认证的路由
      meta: {
        requiresAuth: true
      }
    },
    {
      path: '/auth/register',
      name: 'register',
      // 当访问该路径时,它被延迟加载
      component: () => import('../views/RegistrationForm.vue'),
    },
    {
      path: '/auth/login',
      name: 'login',
      component: () => import('../views/LoginForm.vue'),
    },
  ],
})

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 获取useAuthStore实例
  const authStore = useAuthStore()

  // 检查是否需要认证
  if (to.meta.requiresAuth && !authStore.getIsAuthenticated) {
    // 跳转到登录页面
    return next({ name: 'login' })
  } 

  console.log('authStore.getUser', authStore.getUser)
  console.log('authStore.getIsAuthenticated', authStore.getIsAuthenticated)
  // 如果用户已登录,但没有加载用户信息,则先加载用户信息
  if (authStore.getIsAuthenticated && !authStore.getUser) {
    try {
      await authStore.fetchUser()
      next()
    } catch (error) {
      authStore.logout()
      next({ name: 'login' })
    }
  } else {
    next()
  }
})

export default router

自动刷新令牌

修改认证状态管理文件src\stores\auth.ts,增加如下内容:

// ...为节约篇幅,此处省略非核心内容

// axios拦截器,自动刷新JWT
axios.interceptors.request.use((config) => { 
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
})

应用启动时恢复认证状态

修改src\App.vue

<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

// 应用启动时检查用户是否已登录
const authStore = useAuthStore()
authStore.checkAuth()
</script>

// ...为节约篇幅,此处省略非核心内容

运行调测

运行应用在未执行登录的情况下访问首页,则会直接重定向到登录界面,效果如下图4-5所示。

图4-5 重定向到登录界面

3.7 全栈实战信息管理功能:实现用户信息展示

将用户信息管理功能从 Thymeleaf 后端渲染模式迁移到 Vue 3 前端渲染模式,需要对数据流转、组件设计、API 接口和安全机制进行全面调整。以下是核心改造点和实施建议。

架构与交互模式的核心变化

  • Thymeleaf 模式: 浏览器 → 表单提交 → 后端控制器 → 数据库操作 → 重定向到详情页

  • Vue 3 模式: 浏览器 → Vue 组件 → API 请求 → 后端服务 → JSON 响应 → 前端更新视图

后端接口改造

修改UserController:

// 获取用户笔记列表数据在界面上展示
@GetMapping("/profile/{userId}")
/*public String profileWithNotes(Model model,
                                @PathVariable Long userId,
                                @RequestParam(defaultValue = "1") int page,
                                @RequestParam(defaultValue = "12") int size) {
    // 获取当前用户信息
    Optional<User> optionalUser = userService.findByUserId(userId);

    // 判断用户是否存在
    if (!optionalUser.isPresent()) {
        throw new UserNotFoundException("");
    }

    User user = optionalUser.get();
    model.addAttribute("user", user);

    // 获取用户笔记列表数据
    Page<Note> notePage = noteService.getNotesByUser(userId, page - 1, size);

    // 添加笔记列表数据到模型中
    model.addAttribute("notePage", notePage);
    model.addAttribute("currentPage", page);
    model.addAttribute("totalPages", notePage.getTotalPages());

    return "user-profile";
}*/
public ResponseEntity<?> profileWithNotes(
                                @PathVariable Long userId,
                                @RequestParam(defaultValue = "1") int page,
                                @RequestParam(defaultValue = "12") int size) {
    // 获取当前用户信息
    Optional<User> optionalUser = userService.findByUserId(userId);

    // 判断用户是否存在
    if (!optionalUser.isPresent()) {
        throw new UserNotFoundException("");
    }

    User user = optionalUser.get();

    // 获取用户笔记列表数据
    Page<Note> notePage = noteService.getNotesByUser(userId, page - 1, size);

    // 转为DTO
    List<NoteExploreDto> noteExploreDtoList =
            notePage.map(note ->  NoteExploreDto.toExploreDto(note, user)).getContent();

    // 添加笔记列表数据到模型中
    Map<String, Object> map = new HashMap<>();
    map.put("user", user);
    map.put("noteList", noteExploreDtoList);
    map.put("currentPage", page);
    map.put("totalPages", notePage.getTotalPages());

    return ResponseEntity.ok(map);
}

为了避免序列化问题,将Page<Note>转为了List<NoteExploreDto>

静态资源的访问

1. 后端安全配置调整

允许匿名访问静态图片资源,修改WebSecurityConfig如下:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            // 禁用CSRF防护
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(authorize -> authorize
                    // ...为节约篇幅,此处省略非核心内容

                    /*// 允许ADMIN、USER角色的用户访问 /file/** 的资源
                    .requestMatchers("/file/**").hasAnyRole("ADMIN", "USER")*/
                    // 允许匿名访问静态图片资源
                    .requestMatchers("/uploads/**").permitAll()
                    .requestMatchers("/file/**").permitAll()
                    // 其他请求需求认证
                    .anyRequest().authenticated()
            )
             
            // ...为节约篇幅,此处省略非核心内容 
    ;

    return http.build();
}

2. 前端设置反向代理

修改vite.config.ts,设置针对静态图片资源的反向代理:

// 设置反向代理
server: {
  host: 'localhost',
  port: 5173, // Vue开发端口
  proxy: {
    // ...为节约篇幅,此处省略非核心内容

    '/uploads': {
      target: 'http://localhost:8080',
      changeOrigin: true
    },
    '/file': {
      target: 'http://localhost:8080',
      changeOrigin: true
    }
  }
}

3.8 AI辅助编程快速实现用户信息展示页面

前端组件设计

UserProfile.vue

新增src\views\UserProfile.vue

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import type { NoteExploreDto } from '@/dto/note-explore-dto'
import { User } from '@/dto/user'
import { useRoute } from 'vue-router'
import axios from 'axios'
import { useRouter } from "vue-router"

const user = ref<User>(new User())
const authStore = useAuthStore()
const me = ref<User>(new User())
const noteList = ref<Array<NoteExploreDto>>([])
const totalPages = ref<number>(0)
const currentPage = ref<number>(0)
const route = useRoute()
const router = useRouter()

// 注销
function logout() {
  authStore.logout()

  // 跳转到登录页面
  router.push({ name: 'login' })
}

// 从路由里面获取用户ID
const userId = ref(route.params.userId)

// 构造查询参数pageIndex,默认从1开始
const pageIndex = ref(route.query.page || 1)

onMounted(() => {
  // 获取用户信息
  fetchUserProfile(userId.value, pageIndex.value)

  // 获取当前用户信息
  me.value = authStore.getUser ? authStore.getUser : new User()
})

const fetchUserProfile = async (userId: any, pageIndex: any) => {
  // 调用API获取用户信息
  try {
    const response = await axios.get(`/api/user/profile/${userId}?page=${pageIndex}`)
    user.value = response.data['user']
    currentPage.value = response.data['currentPage']
    totalPages.value = response.data['totalPages']
    noteList.value = response.data['noteList']
  } catch (error) {
    console.error('获取用户信息失败:' + error)
  }
}
</script>

<template>
  <!-- 导航栏 -->
  <nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
      <a class="navbar-brand" href="/">
        <img src="/images/rn_logo.png" alt="RN" height="24">
      </a>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
        aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarNav">
        <ul class="navbar-nav ms-auto">
          <li class="nav-item">
            <a class="nav-link" href="#">
              {{ user.username }}
            </a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="/user/profile">个人资料</a>
          </li>
          <li class="nav-item">
            <!-- 注销 -->
            <a class="nav-link" href="#" @click="logout">退出登录</a>
          </li>
        </ul>
      </div>
    </div>
  </nav>

  <!-- 主体部分 -->
  <div class="container mt-5">
    <div class="row justify-content-center">
      <!-- 用户个人信息 -->
      <div class="row col-md-8">
        <div class="col-md-4 text-center">
          <img :src="user.avatar ? user.avatar : '/images/rn_avatar.png'" class="rounded-circle" alt="用户头像" height="88"
            width="88">
          <p class="mt-3">{{ user.username }}</p>

          <!-- 仅作者自己可见 -->
          <div v-if="me.username === user.username">
            <a href="/user/edit" class="btn btn-primary btn-sm">编辑资料</a>
          </div>

        </div>

        <div class="col-md-8">
          <dive class="mb-3">
            <label class="form-label">RN号:{{ user.userId }}</label>
          </dive>
          <dive class="mb-3">
            <p class="form-control-plaintext">{{ user.bio ? user.bio : '这家伙很懒,什么都没写' }}</p>
          </dive>

          <!-- 仅作者自己可见 -->
          <div v-if="me.username === user.username">
            <a href="/user/change-password" class="btn btn-outline-secondary">修改密码</a>
          </div>
        </div>
      </div>

      <!-- 笔记列表 -->
      <div class="col-md-8">
        <!-- 空状态提示 -->
        <div class="empty-state" v-if="noteList.length === 0">
          <div class="empty-icon">
            <i class="fa fa-file-o"></i>
          </div>
          <div class="empty-text">
            还没有发布任何笔记
          </div>
          <!-- 仅作者自己可见 -->
          <div v-if="me.username == user.username">
            <a href="/note/publish" class="create-note-btn">
              <i class="fa fa-plus"></i>
              发布第一篇笔记
            </a>
          </div>
        </div>

        <!-- 非空状态提示 -->
        <div class="note-grid" v-if="noteList.length > 0">
          <!-- 循环遍历笔记列表生成笔记卡片 -->
          <div class="note-card" v-for="note in noteList">
            <a :href="'/note/' + note.noteId">
              <img :src="note.cover" class="note-image" alt="note.title">
            </a>
            <div class="note-content">
              <dive class="note-title">
                {{ note.title }}
              </dive>
            </div>
          </div>
        </div>
      </div>

      <!-- 分页导航 -->
      <div class="col-md-8">
        <div class="pagination" v-if="totalPages > 0">
          <a class="page-btn" v-if="currentPage > 1"
            :href="'/user/profile/' + user.userId + '?page=' + (currentPage - 1)">«</a>

          <a class="page-btn" v-for="pageNum in Array.from({ length: totalPages }, (_, i) => i + 1)"
            :href="'/user/profile/' + user.userId + '?page=' + pageNum" :class="{ active: pageNum === currentPage }">{{
              pageNum }}</a>

          <a class="page-btn" v-if="currentPage < totalPages"
            :href="'/user/profile/' + user.userId + '?page=' + (currentPage + 1)">»</a>
        </div>
      </div>
    </div>
  </div>
</template>
<style setup>
/* 小红书风格 */
* {
  font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  background-color: #f5f5f5;
}

/* 顶部导航栏 */
.header {
  position: sticky;
  top: 0;
  background-color: white;
  padding: 16px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

.user-info {
  display: flex;
  align-items: center;
  gap: 12px;
}

.user-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background-color: #f0f0f0;
}

.user-name {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}

.user-meta {
  font-size: 12px;
  color: #666;
}

.action-btn {
  margin-left: auto;
  font-size: 14px;
  color: #ff2442;
}

/* 笔记列表 */
.note-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 16px;
  padding: 16px;
}

.note-card {
  background-color: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
  transition: transform 0.2s;
}

.note-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05);
}

.note-image {
  width: 100%;
  height: 180px;
  object-fit: cover;
}

.note-content {
  padding: 12px;
}

.note-title {
  font-size: 14px;
  font-weight: 500;
  line-height: 1.4;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  color: #333;
  margin-bottom: 8px;
}

.note-meta {
  display: flex;
  justify-content: space-between;
  font-size: 12px;
  color: #999;
}

/* 空状态提示 */
.empty-state {
  padding: 48px;
  text-align: center;
}

.empty-icon {
  font-size: 48px;
  color: #e0e0e0;
  margin-bottom: 16px;
}

.empty-text {
  color: #999;
  margin-bottom: 24px;
}

.create-note-btn {
  background-color: #ff2442;
  color: white;
  padding: 10px 24px;
  border-radius: 24px;
  font-weight: 500;
}

/* 分页组件 */
.pagination {
  padding: 24px;
  display: flex;
  justify-content: center;
  gap: 8px;
  font-size: 14px;
}

.page-btn {
  padding: 6px 12px;
  border-radius: 4px;
  color: #666;
  text-decoration: none;
}

.page-btn.active {
  background-color: #ff2442;
  color: white;
  font-weight: 500;
}
</style>

useRouter()useRoute() 的区别

在 Vue 3 中,router 对象(通过 useRouter() 获取)没有 params 属性,而是需要通过 useRoute() 来获取当前路由的参数。

区分 useRoute()useRouter()如下表所示。

API用途主要属性/方法
useRoute()获取当前路由信息params, query, path, fullPath
useRouter()执行路由导航操作push, replace, go, back

user.ts

新增src\dto\user.ts

export class User {
  userId: number = 0;
  username: string = '';
  password: string = '';
  phone: string = '';
  avatar: string = '';
  bio: string = '';
  role: string = '';
}

note-explore-dto.ts

新增src\dto\note-explore-dto.ts

export interface NoteExploreDto {
  noteId: number;
  title: string;
  cover: string;
  username: string;
  avatar: string;
  userId: string;
  liked: boolean;
  likeCount: number;
}

3.9 路由配置和全局前置守卫实现页面重定向

路由配置和全局前置守卫实现重定向

在 Vue 3 中实现从 /user/profile/user/profile/:userId 的重定向,需要结合路由配置和全局前置守卫。以下是具体实现方法:

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...为节约篇幅,此处省略非核心内容

    {
      path: '/user/profile',
      // 重定向到指定用户ID的页面
      redirect: to => {
        // 获取用户ID需要到全局守卫中处理
        return { name: 'profile-placeholder'}
      },
      meta: {
        requiresAuth: true
      }
    },
    // 临时占位路由,用于在全局守卫中处理重定向
    {
      path: '/user/profile-placeholder',
      name: 'profile-placeholder',
      component: { template: '<div>Loading...</div>' },
      meta: {
        requiresAuth: true
      }
    },
    {
      path: '/user/profile/:userId',
      name: 'user-profile',
      component: () => import('../views/UserProfile.vue'),
      meta: {
        requiresAuth: true
      }
    },
  ],
})

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // ...为节约篇幅,此处省略非核心内容

  // 如果用户已登录,但没有加载用户信息,则先加载用户信息
  if (authStore.getIsAuthenticated && !authStore.getUser) {
    try {
      await authStore.fetchUser()
    } catch (error) {
      authStore.logout()
      next({ name: 'login' })
    }
  }

  // 获取用户ID
  if (to.name === 'profile-placeholder' && authStore.getUser) {
    next({ name: 'user-profile', params: { userId: (authStore.getUser as any).userId } })
  } else {
    next()
  }

})

export default router

运行调测

运行应用在登录账号的情况下访问自己的用户信息首页,效果如下图4-6所示。

图4-6 在执行登录的情况下访问用户信息首页

访问其他人的用户信息首页,效果如下图4-7所示。与上述界面的差异点在于少了“编辑资料”“修改密码”。

图4-7 访问其他人的用户信息首页

如果某个用户未发表过笔记,则效果如下图4-8所示。

图4-8 未发表过笔记的用户信息首页

如果是自己未发表过笔记,则效果如下图4-9所示。与上述界面的差异点在于少了“发布第一篇笔记”。

图4-9 未发表过笔记的自己信息首页

通过以上改造,用户信息管理功能将从后端渲染转变为前端渲染,实现更流畅的交互体验和更好的可维护性。关键是要处理好前后端分离后的API设计、状态管理和用户体验优化。

3.10 前后端分离架构下的全局错误异常处理

在前后端分离的架构中,传统的 Spring Boot @ControllerAdvice 全局异常处理需要结合前端错误拦截机制进行重构。以下是完整的解决方案。

改造前的效果

当试图访问一个不存在的用户ID的时候,比如ID为111,则界面效果如下图4-10所示。

图4-10 访问一个不存在的用户ID

该界面没有提示任何错误信息,用户也很难察觉后台实际上已经抛出了UserNotFoundException,只不过该异常并未i能反馈给前端应用。

后端GlobalExceptionHandler优化

1. 统一 API 错误响应格式

新建 ErrorResponseDto:

package com.waylau.rednote.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;

/**
 * ErrorResponseDto 错误响应对象
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/07/08
 **/
@Getter
@Setter
@AllArgsConstructor
public class ErrorResponseDto {
    /**
     * HTTP状态码
     */
    private int code;
    /**
     * 信息
     */
    private String message;
}

2. 重构 GlobalExceptionHandler

package com.waylau.rednote.exception;

import com.waylau.rednote.dto.ErrorResponseDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
/*import org.springframework.ui.Model;*/
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MaxUploadSizeExceededException;


/**
 * GlobalExceptionHandler 全局异常处理
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/18
 **/
@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(MaxUploadSizeExceededException.class)
    /*public String handleMaxSizeException(MaxUploadSizeExceededException exc, Model model) {
        log.error("服务器异常:{}", exc.getMessage(), exc);

        model.addAttribute("errorCode", 400);
        model.addAttribute("errorMessage", "服务器异常:" + exc.getMessage());

        return "400-error";
    }*/
    public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
        log.error("服务器异常:{}", exc.getMessage(), exc);

        ErrorResponseDto errorResponseDto = new ErrorResponseDto(400, "服务器异常:" + exc.getMessage());

        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(errorResponseDto);
    }

    // 用户不存在异常
    @ExceptionHandler(UserNotFoundException.class)
    /*public String handleUserNotFoundException(UserNotFoundException exc, Model model) {
        log.error("用户不存在异常:{}", exc.getMessage(), exc);

        model.addAttribute("errorCode", 404);
        model.addAttribute("errorMessage", "异常信息:" + exc.getMessage());

        return "400-error";
    }*/
    public ResponseEntity<?> handleUserNotFoundException(UserNotFoundException exc) {
        log.error("用户不存在异常:{}", exc.getMessage(), exc);

        ErrorResponseDto errorResponseDto = new ErrorResponseDto(404, "异常信息:" + exc.getMessage());

        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(errorResponseDto);
    }

    // 笔记不存在异常
    @ExceptionHandler(NoteNotFoundException.class)
    /*public String handleNoteNotFoundException(NoteNotFoundException exc, Model model) {
        log.error("笔记不存在异常:{}", exc.getMessage(), exc);

        model.addAttribute("errorCode", 404);
        model.addAttribute("errorMessage", "异常信息:" + exc.getMessage());

        return "400-error";
    }*/
    public ResponseEntity<?> handleNoteNotFoundException(NoteNotFoundException exc) {
        log.error("笔记不存在异常:{}", exc.getMessage(), exc);

        ErrorResponseDto errorResponseDto = new ErrorResponseDto(404, "异常信息:" + exc.getMessage());

        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(errorResponseDto);
    }

    // 评论不存在异常
    @ExceptionHandler(CommentNotFoundException.class)
    /*public String handleCommentNotFoundException(CommentNotFoundException exc, Model model) {
        log.error("评论不存在异常:{}", exc.getMessage(), exc);

        model.addAttribute("errorCode", 404);
        model.addAttribute("errorMessage", "异常信息:" + exc.getMessage());

        return "400-error";
    }*/
    public ResponseEntity<?> handleCommentNotFoundException(CommentNotFoundException exc) {
        log.error("评论不存在异常:{}", exc.getMessage(), exc);

        ErrorResponseDto errorResponseDto = new ErrorResponseDto(404, "异常信息:" + exc.getMessage());

        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(errorResponseDto);
    }
}

前端 axios 拦截器配置

1. 创建 axios 实例并添加拦截器

新建src\services\axios.ts

import axios from "axios"
import { useAuthStore } from "@/stores/auth"
import router from "@/router"

const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 5000,
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    const authStore = useAuthStore()
    if (authStore.getToken) {
      config.headers.Authorization = `Bearer ${authStore.getToken}`
    }
    return config
  },
  (error) => {
    console.error('请求错误:' + error)
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  (response) => {
    return response
  },
  (error) => {
    console.error('响应错误:' + error)
    const { status, data } = error.response || {}

    // 根据状态码的不同处理不同的错误
    switch (status) {
      case 401:
        // 认证失败,跳转到登录页
        const authStore = useAuthStore()
        authStore.logout()
        router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } })
        break;
      case 403:
        // 权限不足,显示提示
        alert(data.message || '权限不足')
        break;
      case 404:
        // 资源不存在,显示提示
        alert(data.message || '资源不存在')
        break;
      case 500:
        // 服务器内部错误,显示提示
        alert(data.message || '服务器内部错误,请稍后再试')
        break;
      default:
        // 其他错误,显示提示
        alert(data.message || '未知错误,请稍后再试')
    }

    return Promise.reject(error)
  }
)

export default service

2. 删除老的axios拦截器

原有的在src\stores\auth.ts的axios拦截器代码可以删除。

/*
// axios拦截器,自动刷新JWT
axios.interceptors.request.use((config) => { 
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
})
*/

3. 使用 axios 实例

在其他组件中原有的使用axios的地方,改为使用@/services/axios中的 axios 实例。

/*import axios from "axios"*/
import axios from "@/services/axios"

以下三个地方:

  • src\views\UserProfile.vue
  • src\views\RegistrationForm.vue
  • src\stores\auth.ts

后续如果有需要发起HTTP请求,都统一使用@/services/axios中的 axios 实例。

运行调测

当试图访问一个不存在的用户ID的时候,比如ID为111,则界面效果如下图4-11所示。

图4-11 访问一个不存在的用户ID

总结

通过以上重构,你可以实现:

  1. 统一的错误响应格式:后端返回标准化的错误结构
  2. 全局错误拦截:前端通过 axios 拦截器统一处理 HTTP 错误
  3. 友好的用户提示:根据不同错误类型显示适当的用户提示

这种架构既能保持后端的健壮性,又能提供良好的前端用户体验,是前后端分离架构下理想的异常处理方案。

3.11 实现用户基本信息的编辑功能

后端接口改造

修改UserController:

@GetMapping("/edit")
/*public String editProfile(Model model) {
    User user = userService.getCurrentUser();

    model.addAttribute("user", user);

    return "user-profile-edit";
}*/
public ResponseEntity<User> editProfile() {
    User user = userService.getCurrentUser();
    return ResponseEntity.ok(user);
}

@Transactional
@PostMapping("/edit")
/*
public String updateProfile(@ModelAttribute User user, RedirectAttributes redirectAttributes,
                            @RequestParam("avatarFile") MultipartFile avatarFile) {
    User currentUser = userService.getCurrentUser();
    String oldAvatar = currentUser.getAvatar();

    // 验证文件类型和大小
    if (avatarFile != null && !avatarFile.isEmpty()) {
        // 验证文件类型
        String contentType = avatarFile.getContentType();
        if (!contentType.startsWith("image/")) {
            redirectAttributes.addFlashAttribute("error", "请上传图片文件");

            return "redirect:/user/edit";
        }

        String fileId = gridFSStorageService.uploadImage(avatarFile);
        String fileUrl = MongoConfig.STATIC_PATH_PREFIX + fileId;

        currentUser.setAvatar(fileUrl);

        // 删除旧头像文件
        if (oldAvatar != null && !oldAvatar.isEmpty()) {
            String oldFileId = oldAvatar.substring(oldAvatar.lastIndexOf("/") + 1);
            gridFSStorageService.deleteImage(oldFileId);
        }
    }

    // 更新用户信息
    currentUser.setPhone(user.getPhone());
    currentUser.setBio(user.getBio());

    // 修改内容保存到数据库
    userService.updateUser(currentUser);

    // 重定向到指定页面,并传递参数
    redirectAttributes.addFlashAttribute("success", "个人信息更新成功");

    return "redirect:/user/profile";
}
*/
public ResponseEntity<?> updateProfile(@RequestParam(required = true) String phone,
                                        @RequestParam(required = false) String bio,
                                        @RequestParam(required = false, value = "avatarFile") MultipartFile avatarFile) {
    User currentUser = userService.getCurrentUser();
    String oldAvatar = currentUser.getAvatar();
    Map<String, String> map = new HashMap<>();

    // 验证文件类型和大小
    if (avatarFile != null && !avatarFile.isEmpty()) {
        // 验证文件类型
        String contentType = avatarFile.getContentType();
        if (!contentType.startsWith("image/")) {
            map.put("error", "请上传图片文件");
            return ResponseEntity.ok(map);
        }

        // 处理文件上传
        String fileId = gridFSStorageService.uploadImage(avatarFile);
        String fileUrl = MongoConfig.STATIC_PATH_PREFIX + fileId;

        currentUser.setAvatar(fileUrl);

        // 删除旧头像文件
        if (oldAvatar != null && !oldAvatar.isEmpty()) {
            String oldFileId = oldAvatar.substring(oldAvatar.lastIndexOf("/") + 1);
            gridFSStorageService.deleteImage(oldFileId);
        }
    }

    // 更新用户信息
    currentUser.setPhone(phone);
    currentUser.setBio(bio);

    // 修改内容保存到数据库
    userService.updateUser(currentUser);

    // 重定向到指定页面,并传递参数
    map.put("success", "个人信息更新成功");
    return ResponseEntity.ok(map);
}

前端组件设计

UserProfileEdit.vue

新增src\views\UserProfileEdit.vue

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { User } from '@/dto/user'
import axios from "@/services/axios"
import { useRouter } from "vue-router"

const user = ref<User>(new User())
const authStore = useAuthStore()
const router = useRouter()
const success = ref('')
const error = ref('')
const selectedFile = ref(null)

onMounted(() => {
  // 获取用户信息
  fetchUserProfile()
})

const fetchUserProfile = async () => {
  try {
    const response = await axios.get(`/api/user/edit`)
    user.value = response.data
  } catch (error) {
    console.error('获取用户信息失败:' + error)
  }
}

// 注销
function logout() {
  authStore.logout()

  // 跳转到登录页面
  router.push({ name: 'login' })
}

const handleUserEdit = async () => {
  const formData = new FormData()

  if (selectedFile.value) {
    formData.append('avatarFile', selectedFile.value as File)
  }
  formData.append('phone', user.value.phone)
  formData.append('bio', user.value.bio)

  // 调用API编辑用户信息
  try {
    const response = await axios.post(`/api/user/edit`, formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      }
    })

    if (response.data['success']) {
      success.value = response.data['success']

      // 获取用户信息
      fetchUserProfile()
    } else if (response.data['error']) {
      error.value = response.data['error']
    }
  } catch (err) {
    console.error('获取用户信息失败:' + err)
    error.value = err + ''
  }
}

// 选中头像的处理
const handleFileUpload = (e: any) => {
  selectedFile.value = e.target.files[0]
}
</script>

<template>
  <!-- 导航栏 -->
  <nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
      <a class="navbar-brand" href="/">
        <img src="/images/rn_logo.png" alt="RN" height="24">
      </a>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
        aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarNav">
        <ul class="navbar-nav ms-auto">
          <li class="nav-item">
            <a class="nav-link" href="#">
              {{ user.username }}
            </a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="/user/profile">个人资料</a>
          </li>
          <li class="nav-item">
            <!-- 注销 -->
            <a class="nav-link" href="#" @click="logout">退出登录</a>
          </li>
        </ul>
      </div>
    </div>
  </nav>

  <!-- 主体部分 -->
  <div class="profile-container">
    <!-- 编辑标题 -->
    <div class="profile-header">
      <h2 class="text-center">编辑个人资料</h2>
      <p>请填写或者更新你的个人信息</p>
    </div>

    <!-- 编辑表单 -->
    <form action="/user/edit" method="post" enctype="multipart/form-data" @submit.prevent="handleUserEdit">
      <!-- 头像 -->
      <div class="form-group position-relative">
        <div class="profile-avatar">
          <img :src="user.avatar ? user.avatar : '/images/rn_avatar.png'" alt="用户头像" height="88" width="88">
          <div class="avatar-upload">
            <!-- 文件上传 --->
            <input type="file" id="avatarFile" name="avatarFile" accept="image/*" class="d-none"
              @change="handleFileUpload"></input>
            <label for="avatarFile">更换头像</label>
          </div>
        </div>
      </div>

      <!-- 用户名(不可编辑)-->
      <div class="form-group">
        <label for="username" class="form-label">用户名</label>
        <input type="text" class="form-control" id="username" name="username" :value="user.username" disabled />
      </div>

      <!-- 手机号-->
      <div class="form-group">
        <label for="phone" class="form-label">手机号</label>
        <input type="text" class="form-control" id="phone" name="phone" v-model="user.phone" placeholder="请输入手机号" />
      </div>

      <!-- 个人简介 -->
      <div class="form-group">
        <label for="bio" class="form-label">个人简介</label>
        <textarea class="form-control" id="bio" name="bio" rows="3" v-model="user.bio"
          placeholder="请输入个人简介(最多255字)"></textarea>
      </div>

      <!-- 提交按钮 -->
      <button type="submit" class="btn btn-primary">保存修改</button>
    </form>

    <!-- 操作反馈 -->
    <div v-if="success" class="alert alert-success mt-3" role="alert">
      {{ success }}
    </div>
    <div v-if="error" class="alert alert-danger mt-3" role="alert">
      {{ error }}
    </div>
  </div>
</template>

<style setup>
.profile-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 32px;
}

.profile-header {
  text-align: center;
  margin-bottom: 32px;
}

.profile-avatar {
  width: 120px;
  height: 120px;
  border-radius: 50%;
  margin: 0 auto 20px;
  position: relative;
}

.profile-avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border: 4px solid white;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.avatar-upload {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  background-color: #ff2442;
  color: white;
  padding: 4px 12px;
  border-radius: 20px;
  cursor: pointer;
  font-size: 12px;
  transition: background-color 0.3s;
}

.avatar-upload:hover {
  background-color: #e61e3a;
}

.form-group {
  margin-bottom: 24px;
}

.form-label {
  font-weight: 600;
  color: #333;
}

.form-control {
  border-radius: 12px;
  border: 1px solid #e8e8e8;
  padding: 12px 16px;
}

.form-control:focus {
  border-color: #ff2442;
  box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}

.btn-primary {
  background-color: #ff2442;
  border-color: #ff2442;
  border-radius: 24px;
  padding: 12px 48px;
  font-weight: 600;
  width: 100%;
}

.btn-primary:hover {
  background-color: #e61e3a;
  box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}

.error-message {
  color: #ff2442;
  font-size: 12px;
  margin-top: 4px;
}
</style>

路由配置

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...为节约篇幅,此处省略非核心内容
    ,
    {
      path: '/user/edit',
      name: 'user-profile-edit',
      component: () => import('../views/UserProfileEdit.vue'),
      meta: {
        requiresAuth: true
      }
    },
  ],
})

运行调测

运行应用访问用户信息编辑页面,效果如下图4-12所示。

图4-12 访问用户信息编辑页面

对用户信息进行编辑,效果如下图4-13所示。

图4-13 对用户信息进行编辑

用户信息编辑成功后刷新页面,效果如下图4-14所示。

图4-14 用户信息编辑成功后刷新页面

3.12 实现用户密码修改

后端接口改造

修改UserController:

@GetMapping("/change-password")
/*public String changePasswordForm() {
    return "user-change-password";
}*/
public ResponseEntity<User> changePasswordForm() {
    User user = userService.getCurrentUser();
    return ResponseEntity.ok(user);
}

@PostMapping("/change-password")
/*public String changePassword(@RequestParam String oldPassword, @RequestParam String newPassword, @RequestParam String confirmPassword, RedirectAttributes redirectAttributes) {
    // 密码验证,验证两次输入的密码是否一致
    if (!newPassword.equals(confirmPassword)) {
        redirectAttributes.addFlashAttribute("error", "两次输入的密码不一致");
        return "redirect:/user/change-password";
    }

    // 密码旧密码是否正确
    if (!userService.verifyPassword(userService.getCurrentUser().getUsername(), oldPassword)) {
        redirectAttributes.addFlashAttribute("error", "旧密码错误");
        return "redirect:/user/change-password";
    }

    // 新密码强度验证
    if (!newPassword.matches("^[a-zA-Z0-9_]{8,20}$")) {
        redirectAttributes.addFlashAttribute("error", "新密码强度不够");
        return "redirect:/user/change-password";
    }

    // 更新密码到数据库
    userService.changePassword(userService.getCurrentUser().getUsername(), newPassword);
    redirectAttributes.addFlashAttribute("success", "密码修改成功");

    return "redirect:/user/change-password";
}*/
public ResponseEntity<?> changePassword(@RequestParam String oldPassword,
                                        @RequestParam String newPassword,
                                        @RequestParam String confirmPassword) {
    Map<String, String> map = new HashMap<>();

    // 密码验证,验证两次输入的密码是否一致
    if (!newPassword.equals(confirmPassword)) {
        map.put("error", "两次输入的密码不一致");
        return ResponseEntity.ok(map);
    }

    // 密码旧密码是否正确
    if (!userService.verifyPassword(userService.getCurrentUser().getUsername(), oldPassword)) {
        map.put("error", "旧密码错误");
        return ResponseEntity.ok(map);
    }

    // 新密码强度验证
    if (!newPassword.matches("^[a-zA-Z0-9_]{8,20}$")) {
        map.put("error", "新密码强度不够");
        return ResponseEntity.ok(map);
    }

    // 更新密码到数据库
    userService.changePassword(userService.getCurrentUser().getUsername(), newPassword);
    map.put("success", "密码修改成功");

    return ResponseEntity.ok(map);
}

前端组件设计

UserChangePassword.vue

新增src\views\UserChangePassword.vue

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { User } from '@/dto/user'
import axios from "@/services/axios"
import { useRouter } from "vue-router"
import { UserChangePassword } from '@/dto/user-change-password'

const user = ref<User>(new User())
const authStore = useAuthStore()
const router = useRouter()
const success = ref('')
const error = ref('')
const userChangePassword = ref<UserChangePassword>(new UserChangePassword())

onMounted(() => {
  // 获取用户信息
  fetchUserProfile()
})

const fetchUserProfile = async () => {
  try {
    const response = await axios.get(`/api/user/change-password`)
    user.value = response.data
  } catch (error) {
    console.error('获取用户信息失败:' + error)
  }
}

// 注销
function logout() {
  authStore.logout()

  // 跳转到登录页面
  router.push({ name: 'login' })
}

const handleUserChangePassword = async () => {
  const formData = new FormData()

  formData.append('oldPassword', userChangePassword.value.oldPassword)
  formData.append('newPassword', userChangePassword.value.newPassword)
  formData.append('confirmPassword', userChangePassword.value.confirmPassword)

  // 调用API编辑用户信息
  try {
    const response = await axios.post(`/api/user/change-password`, formData)

    if (response.data['success']) {
      success.value = response.data['success']
    } else if (response.data['error']) {
      error.value = response.data['error']
    }
  } catch (err) {
    console.error('获取用户信息失败:' + err)
    error.value = err + ''
  }
}
</script>

<template>
  <!-- 导航栏 -->
  <nav class="navbar navbar-expand-lg navbar-light bg-light">
    <div class="container">
      <a class="navbar-brand" href="/">
        <img src="/images/rn_logo.png" alt="RN" height="24">
      </a>
      <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
        aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse" id="navbarNav">
        <ul class="navbar-nav ms-auto">
          <li class="nav-item">
            <a class="nav-link" href="#">
              {{ user.username }}
            </a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="/user/profile">个人资料</a>
          </li>
          <li class="nav-item">
            <!-- 注销 -->
            <a class="nav-link" href="#" @click="logout">退出登录</a>
          </li>
        </ul>
      </div>
    </div>
  </nav>

  <!-- 主体部分 -->
  <div class="password-container">
    <div class="card password-card">
      <!-- 编辑标题 -->
      <div class="card-header">
        <h2 class="text-center">修改密码</h2>
        <p>请输入当前密码和新密码</p>
      </div>

      <div class="card-body">
        <form action="/user/change-password" method="post" @submit.prevent="handleUserChangePassword">
          <!-- 当前密码 -->
          <div class="form-group">
            <label for="oldPassword" class="form-label">当前密码</label>
            <input type="password" class="form-control" id="oldPassword" name="oldPassword"
              v-model="userChangePassword.oldPassword" />
          </div>

          <!-- 新密码 -->
          <div class="form-group">
            <label for="newPassword" class="form-label">新密码</label>
            <input type="password" class="form-control" id="newPassword" name="newPassword"
              v-model="userChangePassword.newPassword" required />
          </div>

          <!-- 确认密码 -->
          <div class="form-group">
            <label for="confirmPassword" class="form-label">确认密码</label>
            <input type="password" class="form-control" id="confirmPassword" name="confirmPassword"
              v-model="userChangePassword.confirmPassword" required />
          </div>

          <!-- 提交按钮 -->
          <button type="submit" class="btn btn-primary">确认修改</button>
        </form>

        <!-- 返回个人资料 -->
        <a href="/user/profile" class="back-link">返回个人资料</a>
      </div>
    </div>

    <!-- 操作反馈 -->
    <div v-if="success" class="alert alert-success mt-3" role="alert">
      {{ success }}
    </div>
    <div v-if="error" class="alert alert-danger mt-3" role="alert">
      {{ error }}
    </div>
  </div>
</template>

<style setup>
.password-container {
  max-width: 500px;
  margin: 0 auto;
  padding: 32px;
}

.password-card {
  border-radius: 16px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
  border: none;
}

.card-header {
  background-color: white;
  border-bottom: none;
  padding: 32px 32px 0;
}

.card-body {
  padding: 32px;
}

.form-group {
  margin-bottom: 24px;
}

.form-label {
  font-weight: 600;
  color: #333;
  margin-bottom: 8px;
}

.form-control {
  border-radius: 12px;
  border: 1px solid #e8e8e8;
  padding: 12px 16px;
  height: 48px;
}

.form-control:focus {
  border-color: #ff2442;
  box-shadow: 0 0 0 2px rgba(255, 36, 66, 0.1);
}

.btn-primary {
  background-color: #ff2442;
  border-color: #ff2442;
  border-radius: 24px;
  padding: 12px 48px;
  font-weight: 600;
  height: 48px;
  width: 100%;
  transition: all 0.3s ease;
}

.btn-primary:hover {
  background-color: #e61e3a;
  box-shadow: 0 4px 12px rgba(255, 36, 66, 0.2);
}

.error-message {
  color: #ff2442;
  font-size: 12px;
  margin-top: 4px;
}

.back-link {
  display: block;
  text-align: center;
  margin-top: 24px;
  color: #999;
  font-size: 14px;
}

.back-link:hover {
  color: #ff2442;
}
</style>

user-change-password.ts

新增src\dto\user-change-password.ts

export class UserChangePassword {
  oldPassword: string = '';
  newPassword: string = '';
  confirmPassword: string = '';
}

路由配置

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...为节约篇幅,此处省略非核心内容

    ,
    {
      path: '/user/change-password',
      name: 'user-change-password',
      component: () => import('../views/UserChangePassword.vue'),
      meta: {
        requiresAuth: true
      }
    },
  ],
})

运行调测

运行应用对用户密码进行修改。修改失败效果如下图4-15所示。

图4-15 对用户信息进行编辑

运行应用对用户密码进行修改。修改成功效果如下图4-16所示。

图4-16 用户信息编辑成功后刷新页面