Spring Boot 3 + Vue 3 全栈开发实战

前言

💡 痛点: 前后端分离后接口对接混乱?跨域问题反复出现?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)。

相关推荐
阿猫的故乡1 小时前
Vue组合式函数(Composables)从入门到实战:鼠标跟踪、请求封装、本地存储……全案例拆解
前端·vue.js·计算机外设
码农飞哥1 小时前
Spring Boot 多角色权限隔离实战:接口层+路由层+UI层三层防御,杜绝生产数据泄露
spring boot·状态模式·架构设计·系统设计·权限控制
SuperArc19991 小时前
SpringBoot+Slf4j+Log4j2+mybatis 日志整合
spring boot·mybatis·log4j2·slf4j·日志整合
一壶纱1 小时前
一个用于 UniApp 项目的 Pinia 持久化插件
前端·javascript·vue.js
仿生joe会梦见漫天的大雪吗1 小时前
CTF学习笔记03:密码口令 —— 从弱口令到字典爆破
后端
自进化Agent智能体1 小时前
从零到一玩转Hermes Agent:VPS部署 × 模型配置 × 记忆架构 × 多Agent协作
后端
用户4682557459131 小时前
Testcontainers 在 Windows Docker Desktop 上跑不通:协议层不兼容 + 4 种可行环境
java·后端
Tenaryo1 小时前
「底层系统基石 · 缓存篇」V —— 写策略、Store Buffer 与内存屏障
后端·面试