1. 架构概述

在现代 Web 应用开发中,前后端分离架构已成为主流。本文将详细介绍如何在 Vue 3 前端与 Spring Boot 后端之间实现高效、安全的数据交互。

1.1 技术栈

层级 技术 说明
前端 Vue 3 + Axios 响应式框架 + HTTP 客户端
后端 Spring Boot 3.x Java 企业级开发框架
数据层 JPA + MySQL 对象关系映射 + 关系型数据库
安全 JWT JSON Web Token 认证

1.2 数据流向

┌─────────────────────────────────────────────────────────────────┐
│                          前端 (Vue 3)                            │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │  组件/页面   │  │  Service 层  │  │   Axios 请求封装     │  │
│  │  用户交互    │──▶│  业务逻辑    │──▶│   API 路径管理       │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              │ HTTP/REST + JWT
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                      后端 (Spring Boot)                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │  Controller  │  │   Service    │  │     Repository       │  │
│  │  接口定义    │──▶│  业务逻辑    │──▶│    数据访问层        │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         数据库 (MySQL)                           │
└─────────────────────────────────────────────────────────────────┘

2. 前端数据层设计

2.1 Axios 实例配置

前端使用 Axios 进行 HTTP 请求,通过拦截器实现统一的认证和错误处理。

import axios from 'axios'

// 创建 axios 实例
const api = axios.create({
  baseURL: 'http://localhost:8080', // 后端 API 基础路径
  timeout: 10000, // 请求超时时间
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器 - 自动添加认证 Token
api.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => Promise.reject(error)
)

// 响应拦截器 - 统一错误处理
api.interceptors.response.use(
  response => response.data,
  error => {
    if (error.response?.status === 401) {
      // Token 过期,清除登录状态并跳转
      localStorage.clear()
      window.location.href = '/'
    }
    return Promise.reject(error)
  }
)

export default api

关键点说明

  • baseURL 统一配置后端地址,便于环境切换
  • 请求拦截器自动从 localStorage 获取 JWT Token 并添加到请求头
  • 响应拦截器处理 401 未授权状态,自动跳转登录页

2.2 API 路径集中管理

将所有 API 路径统一管理,便于维护和修改。

const API_BASE = '/api/v1';

const apiPaths = {
  // 用户相关
  user: {
    login: `${API_BASE}/users/login`,
    register: `${API_BASE}/users/register`,
    info: `${API_BASE}/users/info`,
    update: `${API_BASE}/users/update`,
  },
  
  // 饮食记录
  food: {
    records: `${API_BASE}/food-records`,
    summary: `${API_BASE}/food-record/summary`,
    favorite: (id) => `${API_BASE}/food-records/${id}/favorite`
  },
  
  // 运动记录
  exercise: {
    records: `${API_BASE}/workouts`,
    summary: `${API_BASE}/workout/summary`,
  },
  
  // 睡眠记录
  sleep: {
    records: `${API_BASE}/sleep-records`,
    summary: `${API_BASE}/sleep-record/summary`,
  },
}

export default apiPaths

2.3 Service 层封装

按业务模块封装 API 调用方法,实现代码复用。

import api from './api'
import apiPaths from '../utils/apiPaths'

const healthService = {
  // 饮食记录模块
  food: {
    // 获取列表
    getRecords: async (username, params) => {
      return api.get(`${apiPaths.food.records}?username=${username}`, { params })
    },

    // 创建记录
    createRecord: async (record) => {
      return api.post(apiPaths.food.records, record)
    },

    // 更新记录
    updateRecord: async (recordId, record) => {
      return api.put(`${apiPaths.food.records}/${recordId}`, record)
    },

    // 删除记录
    deleteRecord: async (recordId) => {
      return api.delete(`${apiPaths.food.records}/${recordId}`)
    },

    // 切换收藏状态
    toggleFavorite: async (recordId, favorited) => {
      return api.post(apiPaths.food.favorite(recordId), { favorited })
    },

    // 获取统计数据
    getSummary: async (username, startDate, endDate) => {
      return api.get(apiPaths.food.summary, { 
        params: { username, startDate, endDate } 
      })
    }
  },

  // 运动记录模块
  exercise: {
    getRecords: async (username, params) => {
      return api.get(`${apiPaths.exercise.records}?username=${username}`, { params })
    },
    createRecord: async (record) => {
      return api.post(apiPaths.exercise.records, record)
    },
    // ... 其他方法
  }
}

export default healthService

3. 后端数据层设计

3.1 实体类定义 (Entity)

使用 JPA 注解定义数据库实体,建立对象关系映射。

@Entity
@Table(indexes = {
    @Index(name = "idx_user_username", columnList = "username")
})
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String username;
    private String password;
    private String email;
    
    // 一对一关系:用户头像
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    @JsonIgnore
    private UserAvatar avatar;
    
    // 一对多关系:用户相册
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    @JsonIgnore
    private List<UserPhoto> photos;
    
    // 用户基本信息字段
    private String title;
    private String gender;
    private Double height;
    private Double weight;
    private String birthDate;
    private String city;
    private String bio;
    
    // Getters and Setters
}

关键点说明

  • @Entity 标记为 JPA 实体类
  • @Table(indexes = ...) 创建数据库索引优化查询
  • @OneToOne / @OneToMany 定义实体关联关系
  • @JsonIgnore 防止 JSON 序列化时的循环引用

3.2 数据访问层 (Repository)

继承 JpaRepository,获得基础的 CRUD 能力,同时定义自定义查询方法。

public interface FoodRecordRepository extends JpaRepository<FoodRecord, Long> {
    
    // 根据用户名查询记录
    List<FoodRecord> findByUsername(String username);
    
    // 自定义 JPQL 查询:按日期范围查询
    @Query("SELECT f FROM FoodRecord f WHERE f.username = :username " +
           "AND f.recordTime >= :startDate AND f.recordTime < :endDate")
    List<FoodRecord> findByUsernameAndRecordDatePrefix(
        @Param("username") String username, 
        @Param("startDate") LocalDateTime startDate, 
        @Param("endDate") LocalDateTime endDate
    );
    
    // 按时间范围查询
    List<FoodRecord> findByUsernameAndRecordTimeBetween(
        String username, 
        LocalDateTime startDate, 
        LocalDateTime endDate
    );

    // 级联删除
    @Transactional
    void deleteByUsername(String username);
}

3.3 控制器层 (Controller)

定义 RESTful API 接口,处理 HTTP 请求和响应。

@RestController
@RequestMapping("/api/v1/food-records")
public class FoodRecordController {

    @Autowired
    private FoodRecordService foodRecordService;

    // 创建记录(支持文件上传)
    @PostMapping
    public Map<String, Object> addFoodRecord(
            @RequestParam("mealType") String mealType,
            @RequestParam("recordTime") String recordTime,
            @RequestParam("calories") String calories,
            @RequestParam("notes") String notes,
            @RequestParam("username") String username,
            @RequestParam("foodItems") String foodItemsJson,
            @RequestParam(value = "photos", required = false) MultipartFile[] photos) {
        
        FoodRecord foodRecord = new FoodRecord();
        foodRecord.setMealType(mealType);
        foodRecord.setRecordTime(LocalDateTime.parse(recordTime));
        foodRecord.setCalories(calories);
        foodRecord.setNotes(notes);
        foodRecord.setUsername(username);

        // 处理 JSON 格式的食物项数据
        if (foodItemsJson != null && !foodItemsJson.isEmpty()) {
            ObjectMapper objectMapper = new ObjectMapper();
            List<FoodItem> foodItems = objectMapper.readValue(
                foodItemsJson, 
                new TypeReference<List<FoodItem>>() {}
            );
            foodRecord.setFoodItems(foodItems);
        }

        // 处理图片上传
        if (photos != null && photos.length > 0) {
            List<FoodPhoto> foodPhotos = new ArrayList<>();
            for (MultipartFile photo : photos) {
                if (!photo.isEmpty()) {
                    FoodPhoto foodPhoto = new FoodPhoto();
                    foodPhoto.setFoodRecord(foodRecord);
                    foodPhoto.setPhotoData(photo.getBytes());
                    foodPhoto.setFilename(photo.getOriginalFilename());
                    foodPhoto.setContentType(photo.getContentType());
                    foodPhotos.add(foodPhoto);
                }
            }
            foodRecord.setPhotos(foodPhotos);
        }

        FoodRecord savedRecord = foodRecordService.addFoodRecord(foodRecord);
        return convertFoodRecord(savedRecord);
    }

    // 查询记录列表
    @GetMapping
    public List<Map<String, Object>> getFoodRecords(
            @RequestParam("username") String username,
            @RequestParam(value = "date", required = false) String date) {
        
        List<FoodRecord> records;
        if (date != null) {
            records = foodRecordService.getFoodRecordsByUsernameAndDate(username, date);
        } else {
            records = foodRecordService.getFoodRecordsByUsername(username);
        }
        
        return records.stream()
            .map(this::convertFoodRecord)
            .collect(Collectors.toList());
    }

    // 更新记录
    @PutMapping
    public Map<String, Object> updateFoodRecord(
            @RequestParam("id") Long id,
            @RequestParam("mealType") String mealType,
            // ... 其他参数
    ) {
        FoodRecord existingRecord = foodRecordService.getFoodRecordById(id);
        // 更新逻辑...
        FoodRecord updatedRecord = foodRecordService.updateFoodRecord(foodRecord);
        return convertFoodRecord(updatedRecord);
    }

    // 删除记录
    @DeleteMapping("/{id}")
    public void deleteFoodRecord(@PathVariable("id") Long id) {
        foodRecordService.deleteFoodRecord(id);
    }

    // 切换收藏状态
    @PostMapping("/{id}/favorite")
    public Map<String, Object> toggleFavorite(
            @PathVariable("id") Long id, 
            @RequestBody FavoriteRequest request) {
        FoodRecord foodRecord = foodRecordService.getFoodRecordById(id);
        foodRecord.setFavorited(request.isFavorited());
        FoodRecord updatedRecord = foodRecordService.updateFoodRecord(foodRecord);
        return convertFoodRecord(updatedRecord);
    }

    // 批量删除
    @DeleteMapping("/batch-delete")
    public void batchDeleteFoodRecords(@RequestBody List<Long> ids) {
        foodRecordService.batchDeleteFoodRecords(ids);
    }
}

4. 数据交互流程

4.1 用户注册流程

1. 前端收集表单数据
   {
     "username": "zhangsan",
     "password": "password123",
     "email": "zhangsan@example.com",
     "captcha": "a3f8"
   }

2. 调用 healthService.user.register(data)

3. Axios 发送 POST 请求到 /api/v1/users/register

4. 后端 UserController 接收请求
   - 验证验证码
   - 调用 userService.register(user)
   - 生成 JWT Token
   - 返回用户信息和 Token

5. 前端存储 Token
   localStorage.setItem('token', response.token)

4.2 数据查询流程

1. 前端组件加载时调用
   healthService.food.getRecords('zhangsan', { date: '2024-01-15' })

2. Axios 发送 GET 请求
   GET /api/v1/food-records?username=zhangsan&date=2024-01-15
   Headers: Authorization: Bearer {token}

3. JwtFilter 拦截请求,验证 Token 有效性

4. FoodRecordController 处理请求
   - 调用 foodRecordService.getFoodRecordsByUsernameAndDate()
   - Repository 执行数据库查询
   - 返回记录列表

5. 前端接收数据并渲染到页面

4.3 文件上传流程

1. 前端选择文件后创建 FormData
   const formData = new FormData()
   formData.append('mealType', '早餐')
   formData.append('recordTime', '2024-01-15T08:30')
   formData.append('photos', file)

2. Axios 发送 POST 请求(Content-Type: multipart/form-data)

3. 后端使用 @RequestParam 接收参数
   - @RequestParam("photos") MultipartFile[] photos

4. 处理文件数据并保存到数据库

5. 数据格式转换

5.1 后端实体转前端 JSON

private Map<String, Object> convertFoodRecord(FoodRecord record) {
    Map<String, Object> result = new HashMap<>();
    result.put("id", record.getId());
    result.put("username", record.getUsername());
    result.put("mealType", record.getMealType());
    result.put("recordTime", record.getRecordTime());
    result.put("calories", record.getCalories());
    result.put("notes", record.getNotes());
    result.put("favorited", record.isFavorited());
    result.put("foodItems", record.getFoodItems());

    // 图片数据 Base64 编码
    if (record.getPhotos() != null && !record.getPhotos().isEmpty()) {
        List<String> photoUrls = record.getPhotos().stream()
            .map(photo -> "data:image/jpeg;base64," + 
                Base64.getEncoder().encodeToString(photo.getPhotoData()))
            .collect(Collectors.toList());
        result.put("photos", photoUrls);
    }

    return result;
}

5.2 前端使用示例

<template>
  <div class="food-records">
    <div v-for="record in records" :key="record.id" class="record-card">
      <h3>{{ record.mealType }} - {{ formatDate(record.recordTime) }}</h3>
      <p>卡路里: {{ record.calories }}</p>
      <p>备注: {{ record.notes }}</p>
      
      <!-- 显示图片 -->
      <div v-if="record.photos" class="photos">
        <img v-for="(photo, index) in record.photos" 
             :key="index" 
             :src="photo" 
             alt="食物照片" />
      </div>
      
      <button @click="toggleFavorite(record)">
        {{ record.favorited ? '取消收藏' : '收藏' }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import healthService from '@/services/healthService'

const records = ref([])

const loadRecords = async () => {
  const username = localStorage.getItem('username')
  records.value = await healthService.food.getRecords(username)
}

const toggleFavorite = async (record) => {
  await healthService.food.toggleFavorite(record.id, !record.favorited)
  record.favorited = !record.favorited
}

onMounted(loadRecords)
</script>

6. 最佳实践

6.1 错误处理

// 统一错误处理
api.interceptors.response.use(
  response => response.data,
  error => {
    const { response } = error
    
    switch (response?.status) {
      case 400:
        // 请求参数错误
        console.error('请求参数错误:', response.data.message)
        break
      case 401:
        // 未授权,清除登录状态
        localStorage.clear()
        window.location.href = '/'
        break
      case 403:
        // 禁止访问
        console.error('没有权限执行此操作')
        break
      case 404:
        // 资源不存在
        console.error('请求的资源不存在')
        break
      case 500:
        // 服务器错误
        console.error('服务器内部错误')
        break
      default:
        console.error('网络请求失败:', error.message)
    }
    
    return Promise.reject(error)
  }
)

6.2 请求取消

// 用于长时间运行的请求(如 AI 分析)
import axios from 'axios'

const CancelToken = axios.CancelToken
let cancel

const generateAnalysis = async (params) => {
  try {
    const response = await api.post('/api/v1/ai-analysis/generate', null, {
      params,
      timeout: 120000, // 120秒超时
      cancelToken: new CancelToken(c => { cancel = c })
    })
    return response
  } catch (error) {
    if (axios.isCancel(error)) {
      console.log('请求已取消')
    }
    throw error
  }
}

// 取消请求
cancel && cancel('用户取消操作')

6.3 数据校验

// 后端使用 Bean Validation
public class UserRegisterRequest {
    @NotBlank(message = "用户名不能为空")
    @Size(min = 3, max = 20, message = "用户名长度必须在3-20之间")
    private String username;
    
    @NotBlank(message = "密码不能为空")
    @Pattern(regexp = "^[a-zA-Z0-9@_]+$", message = "密码只能包含字母、数字、@和_")
    private String password;
    
    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;
}

// Controller 中使用 @Valid 注解
@PostMapping("/register")
public UserResponse register(@Valid @RequestBody UserRegisterRequest request) {
    // 如果校验失败,自动返回 400 错误
}

更多推荐