Spring Boot+Vue心理咨询平台全栈开发实战:从零构建毕业设计项目

AI助手已提取文章相关产品:

在实际的毕业设计、课程设计或练手项目中,一个功能完整、技术栈主流的“大学生心理咨询平台”是一个典型且实用的选题。它融合了用户管理、内容发布、在线交流、数据统计等核心业务,非常适合用来串联 Spring Boot 后端与 Vue 前端,并实践数据库设计、接口开发、权限控制等全栈技能。很多同学在初期会感到无从下手,或者代码跑通后却对业务逻辑、异常处理和项目结构理解不深。

本文将带你从零开始,构建一个具备基础咨询功能的平台。我们将聚焦于如何将 Spring Boot 与 Vue 进行工程化整合,实现用户端与管理端的核心功能模块,并解释每一步背后的设计考量与常见陷阱。完成后,你将获得一个结构清晰、可运行、可扩展的项目原型,不仅能用于课程作业,更能作为理解前后端分离开发模式的实战案例。

1. 项目核心功能与架构设计

在动手编码之前,明确项目要做什么以及如何组织代码至关重要。一个心理咨询平台的核心是建立咨询师与来访者(学生)之间的连接,并提供安全、私密的交流环境。

1.1 核心业务模块分析

一个最小可行产品(MVP)应包含以下模块:

  1. 用户系统 :区分学生、咨询师、管理员三种角色。学生可预约咨询、查看文章、发起匿名倾诉;咨询师可管理自己的日程、回复咨询;管理员管理用户、文章和全局设置。
  2. 咨询管理 :这是核心业务。学生可以选择咨询师、预约时间段(需考虑咨询师排班),形成咨询订单。咨询支持图文交流(模拟聊天室)。
  3. 内容管理 :发布心理健康相关的文章、科普视频(如 m3u8 格式流媒体),供学生浏览学习。
  4. 交流社区 :提供一个匿名的树洞或论坛板块,让学生可以安全地倾诉。
  5. 后台管理 :管理员需要对用户、咨询记录、文章、社区内容进行审核与管理。

1.2 技术栈选型与职责划分

基于输入材料中的高频热词,我们采用主流且成熟的技术栈:

  • 后端 (Spring Boot 2.x) : 提供 RESTful API,处理业务逻辑、数据持久化和安全控制。
    • Spring Security : 处理用户认证(登录)和授权(权限检查)。
    • Spring Data JPA / MyBatis-Plus : 简化数据库操作。本文示例使用 JPA。
    • Spring Boot Validation : 进行接口参数校验。
    • JJWT : 用于生成和解析 JWT (JSON Web Token),实现无状态登录。
    • Spring Boot Starter Web : 提供 Web MVC 支持。
  • 前端 (Vue 3 + Element Plus) : 构建用户界面,通过 Axios 调用后端 API。
    • Vue Router : 管理前端路由,实现单页面应用(SPA)跳转。
    • Pinia / Vuex : 进行状态管理。本文示例使用 Pinia。
    • Axios : 处理 HTTP 请求,可配置请求拦截器(添加JWT)和响应拦截器(处理错误)。
    • Element Plus : 提供丰富的 UI 组件,加速开发。
  • 数据库 (MySQL 8.0) : 存储结构化数据。
  • 开发工具 : IDEA(后端)、VSCode(前端)、Postman/Apigfox(接口测试)、Navicat(数据库管理)。

前后端分离架构 :前端项目独立运行在一个端口(如 8081 ),后端运行在另一个端口(如 8080 )。前端通过 Ajax 请求后端 API,数据以 JSON 格式交互。这种架构清晰解耦,便于团队协作和独立部署。

2. 后端工程:Spring Boot 项目搭建与核心配置

我们从后端开始,因为后端定义了数据模型和业务规则,前端依此进行开发。

2.1 初始化项目与依赖配置

使用 Spring Initializr 或 IDEA 直接创建项目。关键依赖选择:

  • Spring Web
  • Spring Data JPA
  • MySQL Driver
  • Lombok (简化实体类代码)
  • Spring Security (可选,初期可简化,后期加入)

创建完成后, pom.xml 文件应包含类似以下依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- JWT 支持 -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <!-- 参数校验 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

2.2 数据库连接与 JPA 配置

application.yml application.properties 中配置数据库连接和 JPA 属性。使用 YAML 格式更清晰:

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/psy_platform?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: yourpassword
  jpa:
    hibernate:
      ddl-auto: update # 学习环境可用 update,生产环境应为 validate 或 none
    show-sql: true # 开发时显示SQL,生产关闭
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQL8Dialect
        format_sql: true
server:
  port: 8080
# 自定义JWT配置
jwt:
  secret: yourSuperSecretKeyForJWT256BitsOrMore # 密钥,务必复杂且保密
  expiration: 86400000 # token有效期,单位毫秒,这里24小时

注意 ddl-auto: update 在开发时很方便,能自动根据实体类创建或更新表结构。但在生产环境,这极其危险,可能导致数据丢失。生产环境应使用 validate 检查映射一致性,并通过 Flyway 或 Liquibase 管理数据库版本。

2.3 核心数据模型设计(实体类)

根据业务模块,我们设计几个核心实体。这里以 User (用户)和 Consultation (咨询订单)为例。

User.java :

package com.example.psyplatform.entity;

import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
import java.util.List;

@Entity
@Data
@Table(name = "sys_user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username; // 登录名

    private String password; // 密码(存储加密后的)

    private String realName; // 真实姓名
    private String studentId; // 学号(学生特有)
    private String phone;
    private String email;

    @Enumerated(EnumType.STRING)
    private UserRole role; // 角色枚举:STUDENT, COUNSELOR, ADMIN

    private String avatar; // 头像URL
    private String profile; // 个人简介(咨询师用)

    private Boolean isActive = true; // 账号是否激活

    @OneToMany(mappedBy = "student")
    private List<Consultation> consultationsAsStudent; // 作为学生的咨询记录

    @OneToMany(mappedBy = "counselor")
    private List<Consultation> consultationsAsCounselor; // 作为咨询师的咨询记录

    private LocalDateTime createTime;
    private LocalDateTime updateTime;

    // 省略 getter/setter (由Lombok @Data 生成)
}

// 角色枚举
enum UserRole {
    STUDENT, COUNSELOR, ADMIN
}

Consultation.java :

package com.example.psyplatform.entity;

import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Data
@Table(name = "consultation")
public class Consultation {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne
    @JoinColumn(name = "student_id", nullable = false)
    private User student; // 来访学生

    @ManyToOne
    @JoinColumn(name = "counselor_id", nullable = false)
    private User counselor; // 咨询师

    private LocalDateTime scheduledTime; // 预约时间
    private Integer durationMinutes; // 咨询时长(分钟)

    @Enumerated(EnumType.STRING)
    private ConsultStatus status; // 状态:PENDING, CONFIRMED, IN_PROGRESS, COMPLETED, CANCELLED

    private String studentNotes; // 学生预约时填写的简要问题
    private String counselorNotes; // 咨询师记录

    private LocalDateTime createTime;
    private LocalDateTime updateTime;

    // 关联的聊天记录,这里简化为一对多文本消息
    @OneToMany(mappedBy = "consultation", cascade = CascadeType.ALL)
    private List<ChatMessage> messages;
}

// 咨询状态枚举
enum ConsultStatus {
    PENDING, CONFIRMED, IN_PROGRESS, COMPLETED, CANCELLED
}

设计要点

  1. 使用 @Enumerated(EnumType.STRING) 存储枚举的字符串值,比存储序号更易读。
  2. 关联关系 @ManyToOne @OneToMany 清晰地表达了用户与咨询订单之间的关系。 mappedBy 属性指定了关系的维护方。
  3. 时间字段 LocalDateTime 类型处理时间, createTime updateTime 用于审计。
  4. 密码字段 :实际存储的必须是加密后的哈希值,绝不能是明文。

2.4 实现用户认证与 JWT 签发

安全是平台的基础。我们采用 JWT 实现无状态认证。

JwtUtil.java (JWT工具类):

package com.example.psyplatform.util;

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import javax.crypto.SecretKey;

@Component
public class JwtUtil {
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private Long expiration;

    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    // 生成Token
    public String generateToken(String username, String role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", role);
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    // 从Token中解析用户名
    public String getUsernameFromToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }

    // 验证Token是否有效
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 日志记录异常,生产环境需细化处理
            return false;
        }
    }
}

AuthController.java (认证控制器):

package com.example.psyplatform.controller;

import com.example.psyplatform.entity.User;
import com.example.psyplatform.service.UserService;
import com.example.psyplatform.util.JwtUtil;
import com.example.psyplatform.vo.LoginVo;
import com.example.psyplatform.vo.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    @Autowired
    private UserService userService;
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private BCryptPasswordEncoder passwordEncoder;

    @PostMapping("/login")
    public Result<String> login(@Valid @RequestBody LoginVo loginVo) {
        User user = userService.findByUsername(loginVo.getUsername());
        if (user == null) {
            return Result.error("用户不存在");
        }
        if (!passwordEncoder.matches(loginVo.getPassword(), user.getPassword())) {
            return Result.error("密码错误");
        }
        if (!user.getIsActive()) {
            return Result.error("账号已被禁用");
        }
        // 生成JWT
        String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
        return Result.success(token);
    }

    @PostMapping("/register")
    public Result<String> register(@Valid @RequestBody RegisterVo registerVo) {
        // 检查用户名是否已存在
        if (userService.existsByUsername(registerVo.getUsername())) {
            return Result.error("用户名已存在");
        }
        // 创建用户,密码加密存储
        User user = new User();
        user.setUsername(registerVo.getUsername());
        user.setPassword(passwordEncoder.encode(registerVo.getPassword()));
        user.setRole(UserRole.STUDENT); // 默认注册为学生
        user.setRealName(registerVo.getRealName());
        // ... 设置其他字段
        userService.save(user);
        return Result.success("注册成功");
    }
}

关键点

  1. 密码加密 :必须使用 BCryptPasswordEncoder 等强哈希算法,切勿使用 MD5 或 SHA-1。
  2. JWT 存储 :Token 应包含用户名和角色等必要信息,但 切勿存放敏感信息如密码
  3. 统一响应 :使用 Result 类包装所有接口响应,包含 code , message , data 字段,便于前端处理。

3. 前端工程:Vue 3 项目搭建与路由配置

后端 API 就绪后,我们构建前端用户界面。

3.1 创建 Vue 项目并安装核心依赖

使用 Vue CLI 或 Vite 创建项目。这里以 Vite 为例:

npm create vue@latest psy-platform-frontend
# 按照提示选择:TypeScript, Vue Router, Pinia, ESLint
cd psy-platform-frontend
npm install

安装额外依赖:

npm install axios element-plus @element-plus/icons-vue
# 或使用 pnpm/yarn

3.2 配置 Axios 与全局状态管理

1. 创建 Axios 实例 ( src/utils/request.ts ) :

import axios from 'axios';
import { ElMessage } from 'element-plus';
import router from '@/router';

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api',
  timeout: 10000,
});

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

// 响应拦截器:统一处理错误
request.interceptors.response.use(
  (response) => {
    // 如果后端返回的Result结构是 {code: 200, data: ..., message: 'ok'}
    const res = response.data;
    if (res.code === 200) {
      return res.data; // 直接返回业务数据
    } else {
      // 业务逻辑错误
      ElMessage.error(res.message || '请求失败');
      return Promise.reject(new Error(res.message || 'Error'));
    }
  },
  (error) => {
    // HTTP状态码错误,如 401, 403, 500
    if (error.response) {
      switch (error.response.status) {
        case 401:
          ElMessage.error('未授权,请重新登录');
          localStorage.removeItem('token');
          router.push('/login');
          break;
        case 403:
          ElMessage.error('拒绝访问');
          break;
        case 500:
          ElMessage.error('服务器内部错误');
          break;
        default:
          ElMessage.error(error.response.data?.message || '请求错误');
      }
    } else {
      ElMessage.error('网络错误或请求超时');
    }
    return Promise.reject(error);
  }
);

export default request;

2. 创建用户状态 Store ( src/stores/user.ts ) :

import { defineStore } from 'pinia';
import { ref } from 'vue';
import request from '@/utils/request';

export const useUserStore = defineStore('user', () => {
  const token = ref(localStorage.getItem('token') || '');
  const userInfo = ref<any>(null);

  const setToken = (newToken: string) => {
    token.value = newToken;
    localStorage.setItem('token', newToken);
  };

  const clearToken = () => {
    token.value = '';
    localStorage.removeItem('token');
    userInfo.value = null;
  };

  const login = async (username: string, password: string) => {
    try {
      const res = await request.post('/auth/login', { username, password });
      // 假设后端返回的就是token字符串
      setToken(res);
      await fetchUserInfo();
      return true;
    } catch (error) {
      return false;
    }
  };

  const fetchUserInfo = async () => {
    if (!token.value) return;
    try {
      userInfo.value = await request.get('/user/info');
    } catch (error) {
      clearToken();
    }
  };

  const logout = () => {
    clearToken();
    // 跳转到登录页
    window.location.href = '/login';
  };

  return {
    token,
    userInfo,
    setToken,
    clearToken,
    login,
    logout,
    fetchUserInfo,
  };
});

3.3 配置路由与权限守卫

路由配置 ( src/router/index.ts ) :

import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from '@/stores/user';

const routes = [
  {
    path: '/',
    redirect: '/home',
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { requiresAuth: false },
  },
  {
    path: '/home',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: true },
  },
  {
    path: '/consultation',
    name: 'Consultation',
    component: () => import('@/views/consultation/Index.vue'),
    meta: { requiresAuth: true, roles: ['STUDENT', 'COUNSELOR'] }, // 需要特定角色
  },
  {
    path: '/admin',
    name: 'Admin',
    component: () => import('@/views/admin/Index.vue'),
    meta: { requiresAuth: true, roles: ['ADMIN'] },
  },
  // ... 其他路由
];

const router = createRouter({
  history: createWebHistory(),
  routes,
});

// 全局前置路由守卫
router.beforeEach((to, from, next) => {
  const userStore = useUserStore();
  const isAuthenticated = !!userStore.token;

  // 检查路由是否需要认证
  if (to.meta.requiresAuth && !isAuthenticated) {
    next('/login');
    return;
  }

  // 检查角色权限
  if (to.meta.roles) {
    const userRole = userStore.userInfo?.role;
    if (!userRole || !to.meta.roles.includes(userRole)) {
      ElMessage.error('权限不足');
      next(from.fullPath); // 停留在原页面
      return;
    }
  }

  next();
});

export default router;

3.4 实现登录页面与主页布局

登录页面 ( src/views/Login.vue ) :

<template>
  <div class="login-container">
    <el-card class="login-card">
      <h2>心理咨询平台登录</h2>
      <el-form :model="form" :rules="rules" ref="loginFormRef">
        <el-form-item prop="username">
          <el-input v-model="form.username" placeholder="请输入用户名" prefix-icon="User" />
        </el-form-item>
        <el-form-item prop="password">
          <el-input v-model="form.password" type="password" placeholder="请输入密码" prefix-icon="Lock" @keyup.enter="handleLogin" />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="handleLogin" :loading="loading" style="width: 100%;">登录</el-button>
        </el-form-item>
        <div class="link-area">
          <router-link to="/register">注册账号</router-link>
        </div>
      </el-form>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { useUserStore } from '@/stores/user';

const router = useRouter();
const userStore = useUserStore();
const loginFormRef = ref<FormInstance>();
const loading = ref(false);

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

const rules: FormRules = {
  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
  password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};

const handleLogin = async () => {
  if (!loginFormRef.value) return;
  await loginFormRef.value.validate(async (valid) => {
    if (valid) {
      loading.value = true;
      try {
        const success = await userStore.login(form.username, form.password);
        if (success) {
          ElMessage.success('登录成功');
          router.push('/home');
        }
      } finally {
        loading.value = false;
      }
    }
  });
};
</script>

<style scoped>
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
  width: 400px;
}
.link-area {
  text-align: center;
  margin-top: 10px;
}
</style>

主页布局 ( src/views/Home.vue ) : 使用 Element Plus 的 Container 布局组件,结合路由视图 <router-view> 实现主体内容切换。

4. 核心功能模块实现与前后端联调

前后端基础框架搭建好后,我们实现一个核心功能:学生预约咨询。

4.1 后端:咨询预约接口

ConsultationController.java :

package com.example.psyplatform.controller;

import com.example.psyplatform.entity.Consultation;
import com.example.psyplatform.entity.User;
import com.example.psyplatform.service.ConsultationService;
import com.example.psyplatform.vo.Result;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

@RestController
@RequestMapping("/api/consultation")
public class ConsultationController {

    @Autowired
    private ConsultationService consultationService;

    // 学生预约咨询
    @PostMapping("/book")
    public Result<Consultation> bookConsultation(@AuthenticationPrincipal User currentUser,
                                                  @Valid @RequestBody BookConsultationVo bookVo) {
        // @AuthenticationPrincipal 从JWT中注入当前登录用户
        if (!currentUser.getRole().equals(UserRole.STUDENT)) {
            return Result.error("只有学生可以预约咨询");
        }
        Consultation consultation = consultationService.bookConsultation(currentUser, bookVo);
        return Result.success(consultation);
    }

    // 学生查看自己的预约记录
    @GetMapping("/my")
    public Result<List<ConsultationVo>> getMyConsultations(@AuthenticationPrincipal User currentUser) {
        List<ConsultationVo> list = consultationService.getConsultationsByUser(currentUser);
        return Result.success(list);
    }

    // 咨询师确认/取消预约
    @PutMapping("/{id}/status")
    public Result<Void> updateStatus(@PathVariable Long id,
                                     @RequestBody UpdateStatusVo statusVo,
                                     @AuthenticationPrincipal User currentUser) {
        consultationService.updateStatus(id, statusVo.getStatus(), currentUser);
        return Result.success();
    }
}

ConsultationService.java (关键业务逻辑):

@Service
public class ConsultationService {
    @Autowired
    private ConsultationRepository consultationRepository;
    @Autowired
    private UserRepository userRepository;

    @Transactional
    public Consultation bookConsultation(User student, BookConsultationVo bookVo) {
        // 1. 验证咨询师存在且角色正确
        User counselor = userRepository.findById(bookVo.getCounselorId())
                .orElseThrow(() -> new RuntimeException("咨询师不存在"));
        if (!counselor.getRole().equals(UserRole.COUNSELOR)) {
            throw new RuntimeException("该用户不是咨询师");
        }

        // 2. 检查时间冲突(简化版:检查该咨询师在该时间段是否有已确认的预约)
        boolean hasConflict = consultationRepository.existsByCounselorAndScheduledTimeBetweenAndStatusIn(
                counselor,
                bookVo.getScheduledTime().minusMinutes(30),
                bookVo.getScheduledTime().plusMinutes(bookVo.getDurationMinutes() + 30),
                Arrays.asList(ConsultStatus.CONFIRMED, ConsultStatus.IN_PROGRESS)
        );
        if (hasConflict) {
            throw new RuntimeException("该时间段已被预约,请选择其他时间");
        }

        // 3. 创建预约记录
        Consultation consultation = new Consultation();
        consultation.setStudent(student);
        consultation.setCounselor(counselor);
        consultation.setScheduledTime(bookVo.getScheduledTime());
        consultation.setDurationMinutes(bookVo.getDurationMinutes());
        consultation.setStatus(ConsultStatus.PENDING);
        consultation.setStudentNotes(bookVo.getStudentNotes());
        consultation.setCreateTime(LocalDateTime.now());
        consultation.setUpdateTime(LocalDateTime.now());

        return consultationRepository.save(consultation);
    }
}

4.2 前端:咨询预约页面

预约页面组件 ( src/views/consultation/Book.vue ) :

<template>
  <div class="book-container">
    <el-card>
      <template #header>
        <span>预约心理咨询</span>
      </template>
      <el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
        <el-form-item label="选择咨询师" prop="counselorId">
          <el-select v-model="form.counselorId" placeholder="请选择" filterable>
            <el-option v-for="counselor in counselorList" :key="counselor.id" :label="counselor.realName" :value="counselor.id">
              <span>{{ counselor.realName }}</span>
              <el-tag size="small" style="margin-left: 10px;">{{ counselor.profile }}</el-tag>
            </el-option>
          </el-select>
        </el-form-item>
        <el-form-item label="预约时间" prop="scheduledTime">
          <el-date-picker
            v-model="form.scheduledTime"
            type="datetime"
            placeholder="选择日期和时间"
            :disabled-date="disabledDate"
            :shortcuts="shortcuts"
            value-format="YYYY-MM-DD HH:mm:ss"
          />
        </el-form-item>
        <el-form-item label="咨询时长" prop="durationMinutes">
          <el-select v-model="form.durationMinutes">
            <el-option :value="50" label="50分钟" />
            <el-option :value="80" label="80分钟" />
          </el-select>
        </el-form-item>
        <el-form-item label="问题简述" prop="studentNotes">
          <el-input v-model="form.studentNotes" type="textarea" :rows="4" placeholder="请简要描述您想咨询的问题..." />
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="submitForm" :loading="loading">提交预约</el-button>
          <el-button @click="resetForm">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import request from '@/utils/request';

interface Counselor {
  id: number;
  realName: string;
  profile: string;
}

const formRef = ref<FormInstance>();
const loading = ref(false);
const counselorList = ref<Counselor[]>([]);

const form = reactive({
  counselorId: undefined as number | undefined,
  scheduledTime: '',
  durationMinutes: 50,
  studentNotes: '',
});

const rules: FormRules = {
  counselorId: [{ required: true, message: '请选择咨询师', trigger: 'change' }],
  scheduledTime: [{ required: true, message: '请选择预约时间', trigger: 'change' }],
  studentNotes: [{ required: true, message: '请输入问题简述', trigger: 'blur' }],
};

// 加载咨询师列表
const loadCounselors = async () => {
  try {
    const res = await request.get('/user/counselors');
    counselorList.value = res;
  } catch (error) {
    ElMessage.error('加载咨询师列表失败');
  }
};

// 提交预约
const submitForm = async () => {
  if (!formRef.value) return;
  await formRef.value.validate(async (valid) => {
    if (valid) {
      loading.value = true;
      try {
        await request.post('/consultation/book', form);
        ElMessage.success('预约提交成功,等待咨询师确认');
        resetForm();
      } catch (error: any) {
        ElMessage.error(error.message || '预约失败');
      } finally {
        loading.value = false;
      }
    }
  });
};

const resetForm = () => {
  if (!formRef.value) return;
  formRef.value.resetFields();
};

// 禁用今天之前的日期
const disabledDate = (time: Date) => {
  return time.getTime() < Date.now() - 24 * 60 * 60 * 1000;
};

const shortcuts = [
  {
    text: '明天上午9点',
    value: () => {
      const date = new Date();
      date.setDate(date.getDate() + 1);
      date.setHours(9, 0, 0, 0);
      return date;
    },
  },
  // ... 其他快捷选项
];

onMounted(() => {
  loadCounselors();
});
</script>

4.3 联调与测试

  1. 启动后端 :确保 MySQL 服务运行,在 IDEA 中运行 Spring Boot 主类。
  2. 启动前端 :在终端进入前端项目目录,运行 npm run dev
  3. 测试接口 :使用 Postman 或 Apifox 直接测试后端 API,确保 /api/auth/login , /api/consultation/book 等接口正常工作。
  4. 前端调用 :在前端页面操作,打开浏览器开发者工具(F12),查看 Network 标签页中的请求和响应。
  5. 常见联调问题
    • CORS 错误 :前端请求后端出现跨域错误。需要在 Spring Boot 后端配置 CORS。
      @Configuration
      public class WebConfig implements WebMvcConfigurer {
          @Override
          public void addCorsMappings(CorsRegistry registry) {
              registry.addMapping("/api/**")
                      .allowedOrigins("http://localhost:8081") // 前端地址
                      .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                      .allowedHeaders("*")
                      .allowCredentials(true);
          }
      }
      
    • 401 Unauthorized :前端未发送 Token 或 Token 过期。检查请求拦截器是否正确添加了 Authorization 头。
    • 404 Not Found :检查后端接口路径与前端的 baseURL 是否匹配。
    • 500 Internal Server Error :查看后端控制台日志,通常是业务逻辑异常或数据库错误。

5. 常见问题排查与生产环境考量

项目跑通只是第一步,在实际开发和部署中会遇到各种问题。

5.1 开发阶段常见问题

问题现象 可能原因 检查与解决方式
前端 npm run dev 报错 Node.js 版本不兼容或依赖未安装 1. 检查 node -v 是否符合 package.json 要求。
2. 删除 node_modules package-lock.json ,重新 npm install
后端启动失败,端口占用 8080 端口被其他进程占用 1. netstat -ano | findstr :8080 查找进程ID并终止。
2. 修改 application.yml 中的 server.port
数据库连接失败 数据库服务未启动、密码错误、时区配置问题 1. 确认 MySQL 服务运行。
2. 检查 application.yml 中的连接参数。
3. 在 URL 中添加 &serverTimezone=Asia/Shanghai
JPA 实体类无法创建表 实体类映射错误、方言问题 1. 检查 @Entity , @Id 注解是否正确。
2. 检查 spring.jpa.hibernate.ddl-auto 是否为 update
3. 查看启动日志中的 SQL 错误。
前端请求后端 API 返回 404 后端 Controller 路径、请求方法不匹配,或未配置 CORS 1. 核对后端 @RequestMapping 和前端 axios 请求路径。
2. 检查请求方法是 GET/POST 等。
3. 确认已添加 CORS 配置。
登录成功但后续请求 403 Token 未正确传递或解析失败,或接口权限不足 1. 检查前端请求拦截器是否在每次请求都添加了 Token。
2. 检查后端 Security 配置或自定义拦截器是否放行了该路径。
3. 检查 Token 是否过期。

5.2 生产环境部署注意事项

  1. 数据库

    • 切勿使用 ddl-auto: update 。应使用 validate ,并通过 Flyway 等工具进行版本化迁移。
    • 为生产环境创建专用数据库用户,并赋予最小必要权限。
    • 定期备份数据。
  2. 应用配置

    • application.yml 中的敏感信息(如数据库密码、JWT secret)移至环境变量或配置中心。
    • 使用 application-prod.yml 指定生产环境配置,并通过 --spring.profiles.active=prod 激活。
  3. 安全加固

    • 密码加密 :必须使用 BCrypt 等强哈希。
    • SQL 注入 :使用 JPA 或 MyBatis 的参数化查询,避免拼接 SQL。
    • XSS 攻击 :对用户输入进行过滤或转义,或使用模板引擎的自动转义功能。
    • CSRF :如果使用 Session 管理,需启用 CSRF 防护。JWT 方案本身对 CSRF 有一定抵御力,但仍需注意。
    • 文件上传 :限制文件类型、大小,并对上传文件进行病毒扫描,存储路径不要允许直接执行。
  4. 日志与监控

    • 配置日志框架(如 Logback)将日志输出到文件,并设置合理的滚动策略和级别。
    • 集成监控组件(如 Spring Boot Actuator + Prometheus + Grafana),监控应用健康状态、JVM 指标和业务指标。
  5. 前端部署

    • 运行 npm run build 生成静态文件。
    • 可以将 dist 目录内容部署到 Nginx 或对象存储(如 OSS、COS)。
    • 配置 Nginx 反向代理,将 /api 路径的请求转发到后端服务。

5.3 项目扩展方向

  1. 实时通信 :将简单的留言板升级为真正的实时聊天室,集成 WebSocket(如 Spring Boot + STOMP)或第三方 SDK。
  2. 视频咨询 :集成实时音视频 SDK,实现视频咨询功能。
  3. 文章与视频管理 :实现富文本编辑器发布文章,集成视频点播服务(处理 m3u8 格式)。
  4. 数据可视化 :使用 ECharts 等库,为管理员提供咨询数据统计图表。
  5. 移动端适配 :考虑使用响应式设计或开发 Uni-app 跨端应用。
  6. 微服务化 :如果业务复杂,可将用户服务、咨询服务、内容服务拆分为独立微服务。

构建一个完整的平台需要持续迭代。建议先从核心的“用户-咨询”流程跑通,再逐步丰富其他功能。在开发过程中,务必重视代码结构、异常处理和日志记录,这些是区分课程作业与可维护项目的关键。

您可能感兴趣的与本文相关内容

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值