前言
💡 痛点: 前后端分离后接口对接混乱?跨域问题反复出现?JWT 鉴权每次都要重新实现?部署时前端路由 404?
🎯 解决方案: Spring Boot 3 + Vue 3 标准化全栈方案,覆盖开发→测试→部署全流程。
#mermaid-svg-HmhsXMG9kdKCeAXQ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HmhsXMG9kdKCeAXQ .error-icon{fill:#552222;}#mermaid-svg-HmhsXMG9kdKCeAXQ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HmhsXMG9kdKCeAXQ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .marker.cross{stroke:#333333;}#mermaid-svg-HmhsXMG9kdKCeAXQ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HmhsXMG9kdKCeAXQ p{margin:0;}#mermaid-svg-HmhsXMG9kdKCeAXQ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .cluster-label text{fill:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .cluster-label span{color:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .cluster-label span p{background-color:transparent;}#mermaid-svg-HmhsXMG9kdKCeAXQ .label text,#mermaid-svg-HmhsXMG9kdKCeAXQ span{fill:#333;color:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .node rect,#mermaid-svg-HmhsXMG9kdKCeAXQ .node circle,#mermaid-svg-HmhsXMG9kdKCeAXQ .node ellipse,#mermaid-svg-HmhsXMG9kdKCeAXQ .node polygon,#mermaid-svg-HmhsXMG9kdKCeAXQ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .rough-node .label text,#mermaid-svg-HmhsXMG9kdKCeAXQ .node .label text,#mermaid-svg-HmhsXMG9kdKCeAXQ .image-shape .label,#mermaid-svg-HmhsXMG9kdKCeAXQ .icon-shape .label{text-anchor:middle;}#mermaid-svg-HmhsXMG9kdKCeAXQ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .rough-node .label,#mermaid-svg-HmhsXMG9kdKCeAXQ .node .label,#mermaid-svg-HmhsXMG9kdKCeAXQ .image-shape .label,#mermaid-svg-HmhsXMG9kdKCeAXQ .icon-shape .label{text-align:center;}#mermaid-svg-HmhsXMG9kdKCeAXQ .node.clickable{cursor:pointer;}#mermaid-svg-HmhsXMG9kdKCeAXQ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .arrowheadPath{fill:#333333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HmhsXMG9kdKCeAXQ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HmhsXMG9kdKCeAXQ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HmhsXMG9kdKCeAXQ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HmhsXMG9kdKCeAXQ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .cluster text{fill:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ .cluster span{color:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HmhsXMG9kdKCeAXQ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HmhsXMG9kdKCeAXQ rect.text{fill:none;stroke-width:0;}#mermaid-svg-HmhsXMG9kdKCeAXQ .icon-shape,#mermaid-svg-HmhsXMG9kdKCeAXQ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HmhsXMG9kdKCeAXQ .icon-shape p,#mermaid-svg-HmhsXMG9kdKCeAXQ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HmhsXMG9kdKCeAXQ .icon-shape .label rect,#mermaid-svg-HmhsXMG9kdKCeAXQ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HmhsXMG9kdKCeAXQ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HmhsXMG9kdKCeAXQ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HmhsXMG9kdKCeAXQ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 后端 Spring Boot 3
网关层
前端 Vue 3
HTTPS API
Vue 3 SPA
Vue Router
Pinia Store
Axios 封装
统一拦截器
CORS 配置
JWT 过滤器
Controller
Service
Repository
MySQL
Redis 缓存
技术栈版本锁定:
| 技术 | 版本 | 说明 |
|---|---|---|
| Spring Boot | 3.2.x | Java 17+,Jakarta EE 9+ |
| Vue | 3.4.x | Composition API + <script setup> |
| JDK | 17 LTS | 最低要求 |
| Node.js | 20 LTS | 推荐 |
| MySQL | 8.0+ | InnoDB |
| Redis | 7.x | 缓存 + Session |
一、Spring Boot 3 后端架构
1.1 项目结构
backend/
├── src/main/java/com/example/demo/
│ ├── DemoApplication.java
│ ├── config/ # 配置类(CORS/Swagger/Security)
│ ├── controller/ # REST 控制器
│ ├── service/ # 业务逻辑
│ │ └── impl/
│ ├── repository/ # JPA / MyBatis Mapper
│ ├── domain/ # 实体类(JPA Entity / MyBatis POJO)
│ ├── dto/ # 数据传输对象
│ ├── vo/ # 视图对象(响应)
│ ├── exception/ # 全局异常处理
│ ├── security/ # Spring Security + JWT
│ ├── filter/ # 过滤器(JWT/XSS/CORS)
│ └── util/ # 工具类
├── src/main/resources/
│ ├── application.yml
│ ├── application-dev.yml
│ └── application-prod.yml
└── pom.xml
1.2 核心依赖(pom.xml)
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-backend</artifactId>
<version>1.0.0</version>
<name>demo-backend</name>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<jwt.version>0.12.3</jwt.version>
<hutool.version>5.8.25</hutool.version>
</properties>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 安全 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- 数据访问 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jwt.version}</version>
</dependency>
<!-- 工具 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<!-- 文档 -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
1.3 全局异常处理
java
// ===== 全局异常处理 =====
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e) {
return Result.error(e.getCode(), e.getMessage());
}
/**
* 参数校验异常(JSR-380)
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(
MethodArgumentNotValidException e) {
String message = e.getBindingResult()
.getFieldError()
.getDefaultMessage();
return Result.error(400, message);
}
/**
* 请求体缺失
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public Result<Void> handleHttpMessageNotReadable(
HttpMessageNotReadableException e) {
return Result.error(400, "请求体格式错误");
}
/**
* 未认证
*/
@ExceptionHandler(AuthenticationException.class)
public Result<Void> handleAuthentication(AuthenticationException e) {
return Result.error(401, "未登录或 Token 已过期");
}
/**
* 无权限
*/
@ExceptionHandler(AccessDeniedException.class)
public Result<Void> handleAccessDenied(AccessDeniedException e) {
return Result.error(403, "无权限访问");
}
/**
* 兜底异常
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("系统异常", e);
return Result.error(500, "系统异常,请联系管理员");
}
}
// ===== 统一响应体 =====
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
private int code;
private String message;
private T data;
private long timestamp;
public static <T> Result<T> success(T data) {
return new Result<>(200, "success", data, System.currentTimeMillis());
}
public static <T> Result<T> error(int code, String message) {
return new Result<>(code, message, null, System.currentTimeMillis());
}
}
// ===== 业务异常 =====
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}
}
二、JWT 认证与 Spring Security
2.1 JWT 工具类
java
// ===== JWT 工具类 =====
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration:86400}")
private long expiration; // 默认 24 小时
/**
* 生成 Token
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();
Collection<? extends GrantedAuthority> authorities =
userDetails.getAuthorities();
claims.put("auth", authorities.stream()
.map(GrantedAuthority::getAuthority)
.toList());
return Jwts.builder()
.claims(claims)
.subject(userDetails.getUsername())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration * 1000))
.signWith(getSigningKey(), Jwts.SIG.HS256)
.compact();
}
/**
* 解析 Token
*/
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
/**
* 验证 Token
*/
public boolean validateToken(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isExpired(token);
}
public String extractUsername(String token) {
return parseToken(token).getSubject();
}
private boolean isExpired(String token) {
return parseToken(token).getExpiration().before(new Date());
}
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
}
2.2 JWT 过滤器
java
// ===== JWT 认证过滤器 =====
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtUtils jwtUtils;
@Autowired
private UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = extractToken(request);
if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) {
try {
String username = jwtUtils.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtils.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (JwtException e) {
// Token 无效,不设置认证(匿名访问)
logger.warn("JWT 解析失败: " + e.getMessage());
}
}
filterChain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
}
2.3 Spring Security 配置
java
// ===== Security 配置(Spring Boot 3 / Spring Security 6)=====
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter
) throws Exception {
http
// CSRF 禁用(JWT 无状态)
.csrf(csrf -> csrf.disable())
// CORS 配置
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 会话管理:无状态
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 请求授权
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/actuator/health"
).permitAll()
.anyRequest().authenticated()
)
// JWT 过滤器在用户名密码过滤器之前
.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*")); // 生产环境改成具体域名
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
三、Vue 3 前端架构
3.1 项目结构
frontend/
├── src/
│ ├── api/ # 接口封装(按模块)
│ │ ├── user.js
│ │ └── article.js
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ ├── composables/ # 组合式函数(hooks)
│ │ ├── useFetch.js
│ │ └── usePagination.js
│ ├── layouts/ # 布局组件
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── stores/ # Pinia 状态管理
│ │ ├── user.js
│ │ └── app.js
│ ├── utils/ # 工具函数
│ │ ├── request.js # Axios 封装
│ │ └── auth.js # Token 管理
│ ├── views/ # 页面组件
│ │ ├── login/
│ │ ├── dashboard/
│ │ └── article/
│ ├── App.vue
│ └── main.js
├── public/
├── package.json
├── vite.config.js
└── env.d.ts
3.2 Axios 封装(统一拦截器)
javascript
// ===== utils/request.js =====
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/stores/user'
// 创建实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器
let loadingInstance = null
request.interceptors.response.use(
(response) => {
const res = response.data
// 二进制数据(文件下载)直接返回
if (response.config.responseType === 'blob') {
return response
}
// 业务状态码判断
if (res.code === 200) {
return res.data // 直接返回 data,简化调用
} else if (res.code === 401) {
// Token 过期
ElMessage.error('登录已过期,请重新登录')
const userStore = useUserStore()
userStore.logout()
router.push('/login')
return Promise.reject(new Error(res.message))
} else {
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message))
}
},
(error) => {
// HTTP 状态码错误
const message = error.response?.data?.message || error.message
ElMessage.error(message)
return Promise.reject(error)
}
)
export default request
3.3 API 模块化封装
javascript
// ===== api/user.js =====
import request from '@/utils/request'
export function login(data) {
return request({
url: '/auth/login',
method: 'post',
data
})
}
export function getUserInfo() {
return request({
url: '/user/info',
method: 'get'
})
}
export function updateUser(data) {
return request({
url: '/user/update',
method: 'put',
data
})
}
// ===== api/article.js =====
import request from '@/utils/request'
export function getArticleList(params) {
return request({
url: '/article/list',
method: 'get',
params
})
}
export function getArticleDetail(id) {
return request({
url: `/article/${id}`,
method: 'get'
})
}
export function createArticle(data) {
return request({
url: '/article/create',
method: 'post',
data
})
}
四、Pinia 状态管理
4.1 用户 Store
javascript
// ===== stores/user.js =====
import { defineStore } from 'pinia'
import { login, getUserInfo } from '@/api/user'
import { getToken, setToken, removeToken } from '@/utils/auth'
export const useUserStore = defineStore('user', {
state: () => ({
token: getToken() || '',
userInfo: null,
roles: []
}),
getters: {
isLoggedIn: (state) => !!state.token,
username: (state) => state.userInfo?.username || ''
},
actions: {
async handleLogin(loginForm) {
const data = await login(loginForm)
this.token = data.token
setToken(data.token)
},
async fetchUserInfo() {
const data = await getUserInfo()
this.userInfo = data.user
this.roles = data.roles
},
logout() {
this.token = ''
this.userInfo = null
this.roles = []
removeToken()
}
},
// 持久化(配合 pinia-plugin-persistedstate)
persist: {
key: 'user',
storage: localStorage,
paths: ['token']
}
})
五、Vue Router 路由与权限
5.1 路由配置 + 动态权限
javascript
// ===== router/index.js =====
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录', requiresAuth: false }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', requiresAuth: true, roles: ['admin', 'user'] }
},
{
path: 'article/list',
name: 'ArticleList',
component: () => import('@/views/article/list.vue'),
meta: { title: '文章列表', requiresAuth: true }
},
{
path: 'article/create',
name: 'ArticleCreate',
component: () => import('@/views/article/create.vue'),
meta: { title: '写文章', requiresAuth: true, roles: ['admin'] }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard'
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 全局路由守卫
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
document.title = to.meta.title
? `${to.meta.title} - 后台管理系统`
: '后台管理系统'
// 不需要认证的页面直接放行
if (!to.meta.requiresAuth) {
next()
return
}
// 未登录 → 跳转登录页
if (!userStore.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 已登录但没有用户信息 → 获取用户信息
if (!userStore.userInfo) {
await userStore.fetchUserInfo()
}
// 角色权限校验
if (to.meta.roles) {
const hasRole = userStore.roles.some(role => to.meta.roles.includes(role))
if (!hasRole) {
next({ path: '/403' })
return
}
}
next()
})
export default router
六、组合式函数(Composables)
6.1 usePagination(分页逻辑复用)
javascript
// ===== composables/usePagination.js =====
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
export function usePagination(fetchApi) {
const loading = ref(false)
const dataList = ref([])
const total = ref(0)
const pagination = reactive({
pageNum: 1,
pageSize: 10
})
const fetchData = async () => {
loading.value = true
try {
const res = await fetchApi({
pageNum: pagination.pageNum,
pageSize: pagination.pageSize
})
dataList.value = res.list || res.records || []
total.value = res.total || 0
} catch (error) {
ElMessage.error('获取数据失败')
} finally {
loading.value = false
}
}
const handleSizeChange = (size) => {
pagination.pageSize = size
fetchData()
}
const handleCurrentChange = (page) => {
pagination.pageNum = page
fetchData()
}
onMounted(fetchData)
return {
loading,
dataList,
total,
pagination,
fetchData,
handleSizeChange,
handleCurrentChange
}
}
6.2 useFetch(通用请求封装)
javascript
// ===== composables/useFetch.js =====
import { ref } from 'vue'
import request from '@/utils/request'
export function useFetch() {
const loading = ref(false)
const error = ref(null)
const execute = async (apiCall, ...args) => {
loading.value = true
error.value = null
try {
const data = await apiCall(...args)
return data
} catch (err) {
error.value = err
throw err
} finally {
loading.value = false
}
}
return { loading, error, execute }
}
七、生产案例:用户管理模块
7.1 后端 Controller
java
// ===== UserController =====
@RestController
@RequestMapping("/api/user")
@Validated
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/info")
public Result<UserVO> getUserInfo(Authentication authentication) {
String username = authentication.getName();
UserVO userVO = userService.getUserInfo(username);
return Result.success(userVO);
}
@PutMapping("/update")
@PreAuthorize("hasRole('admin')")
public Result<Void> updateUser(
@RequestBody @Valid UserUpdateDTO dto,
Authentication authentication) {
userService.updateUser(dto, authentication.getName());
return Result.success(null);
}
@GetMapping("/list")
@PreAuthorize("hasRole('admin')")
public Result<PageResult<UserVO>> listUsers(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(required = false) String keyword) {
PageResult<UserVO> result = userService.listUsers(pageNum, pageSize, keyword);
return Result.success(result);
}
}
7.2 前端页面(Vue 3 + Element Plus)
vue
<!-- ===== views/user/list.vue ===== -->
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getUserList, deleteUser } from '@/api/user'
import { usePagination } from '@/composables/usePagination'
const {
loading,
dataList,
total,
pagination,
fetchData,
handleSizeChange,
handleCurrentChange
} = usePagination(getUserList)
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定删除该用户?', '提示', { type: 'warning' })
await deleteUser(id)
ElMessage.success('删除成功')
fetchData()
}
onMounted(fetchData)
</script>
<template>
<div class="user-list">
<el-table :data="dataList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色" />
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button type="danger" size="small" @click="handleDelete(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.pageNum"
v-model:page-size="pagination.pageSize"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
layout="total, sizes, prev, pager, next"
/>
</div>
</template>
八、部署实战
8.1 后端部署(Docker + Docker Compose)
yaml
# ===== docker-compose.yml =====
version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: app-mysql
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: app_db
ports:
- "3306:3306"
volumes:
- mysql-data:/var/lib/mysql
networks:
- app-network
redis:
image: redis:7-alpine
container_name: app-redis
ports:
- "6379:6379"
networks:
- app-network
backend:
build: ./backend
container_name: app-backend
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
MYSQL_HOST: mysql
REDIS_HOST: redis
depends_on:
- mysql
- redis
networks:
- app-network
frontend:
build: ./frontend
container_name: app-frontend
ports:
- "80:80"
depends_on:
- backend
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
mysql-data:
8.2 前端 Nginx 配置(解决 History 路由 404)
nginx
# ===== nginx.conf =====
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Vue Router History 模式配置
location / {
try_files $uri $uri/ /index.html;
}
# 代理后端 API
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1024;
}
8.3 前端 Dockerfile
dockerfile
# ===== frontend/Dockerfile =====
# 构建阶段
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --registry=https://registry.npmmirror.com
COPY . .
RUN npm run build
# 生产阶段
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
九、总结
技术全景
| 层 | 技术选型 | 说明 |
|---|---|---|
| 前端框架 | Vue 3.4 + Vite | Composition API + <script setup> |
| UI 组件 | Element Plus | 企业级中后台首选 |
| 状态管理 | Pinia | Vue 3 官方推荐,替代 Vuex |
| 路由 | Vue Router 4 | History 模式 + 权限守卫 |
| HTTP | Axios | 统一拦截器封装 |
| 后端框架 | Spring Boot 3.2 | Java 17+,Jakarta EE 9+ |
| 安全 | Spring Security 6 + JWT | 无状态认证 |
| ORM | MyBatis-Plus 3.5 | 增强 MyBatis |
| 数据库 | MySQL 8.0 | InnoDB |
| 缓存 | Redis 7 | Session + 业务缓存 |
| 文档 | SpringDoc OpenAPI | 替代 Swagger 2 |
| 部署 | Docker Compose | 一键编排 |
最佳实践
| 实践 | 说明 |
|---|---|
| 统一响应体 | Result<T> 封装所有接口返回 |
| 全局异常 | @RestControllerAdvice 统一处理 |
| JWT 无状态 | 禁用 Session,适合微服务扩展 |
| Axios 拦截器 | 统一注入 Token + 错误处理 |
| 组合式函数 | usePagination / useFetch 逻辑复用 |
| 路由权限 | 角色 + 路由守卫双重控制 |
| Nginx 路由 | try_files 解决 History 模式 404 |
| CORS 配置 | Spring Security 统一管理,避免前端代理混乱 |
本文涵盖 Spring Boot 3 + Vue 3 全栈开发完整技术栈:后端架构、JWT 认证、Vue 3 组合式 API、Pinia 状态管理、路由权限、生产部署(Docker Compose + Nginx)。