在实际的毕业设计、课程设计或练手项目中,一个功能完整、技术栈主流的“大学生心理咨询平台”是一个典型且实用的选题。它融合了用户管理、内容发布、在线交流、数据统计等核心业务,非常适合用来串联 Spring Boot 后端与 Vue 前端,并实践数据库设计、接口开发、权限控制等全栈技能。很多同学在初期会感到无从下手,或者代码跑通后却对业务逻辑、异常处理和项目结构理解不深。
本文将带你从零开始,构建一个具备基础咨询功能的平台。我们将聚焦于如何将 Spring Boot 与 Vue 进行工程化整合,实现用户端与管理端的核心功能模块,并解释每一步背后的设计考量与常见陷阱。完成后,你将获得一个结构清晰、可运行、可扩展的项目原型,不仅能用于课程作业,更能作为理解前后端分离开发模式的实战案例。
1. 项目核心功能与架构设计
在动手编码之前,明确项目要做什么以及如何组织代码至关重要。一个心理咨询平台的核心是建立咨询师与来访者(学生)之间的连接,并提供安全、私密的交流环境。
1.1 核心业务模块分析
一个最小可行产品(MVP)应包含以下模块:
- 用户系统 :区分学生、咨询师、管理员三种角色。学生可预约咨询、查看文章、发起匿名倾诉;咨询师可管理自己的日程、回复咨询;管理员管理用户、文章和全局设置。
- 咨询管理 :这是核心业务。学生可以选择咨询师、预约时间段(需考虑咨询师排班),形成咨询订单。咨询支持图文交流(模拟聊天室)。
- 内容管理 :发布心理健康相关的文章、科普视频(如 m3u8 格式流媒体),供学生浏览学习。
- 交流社区 :提供一个匿名的树洞或论坛板块,让学生可以安全地倾诉。
- 后台管理 :管理员需要对用户、咨询记录、文章、社区内容进行审核与管理。
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
}
设计要点 :
- 使用
@Enumerated(EnumType.STRING)存储枚举的字符串值,比存储序号更易读。 - 关联关系 :
@ManyToOne和@OneToMany清晰地表达了用户与咨询订单之间的关系。mappedBy属性指定了关系的维护方。 - 时间字段 :
LocalDateTime类型处理时间,createTime和updateTime用于审计。 - 密码字段 :实际存储的必须是加密后的哈希值,绝不能是明文。
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("注册成功");
}
}
关键点 :
- 密码加密 :必须使用
BCryptPasswordEncoder等强哈希算法,切勿使用 MD5 或 SHA-1。 - JWT 存储 :Token 应包含用户名和角色等必要信息,但 切勿存放敏感信息如密码 。
- 统一响应 :使用
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 联调与测试
- 启动后端 :确保 MySQL 服务运行,在 IDEA 中运行 Spring Boot 主类。
- 启动前端 :在终端进入前端项目目录,运行
npm run dev。 - 测试接口 :使用 Postman 或 Apifox 直接测试后端 API,确保
/api/auth/login,/api/consultation/book等接口正常工作。 - 前端调用 :在前端页面操作,打开浏览器开发者工具(F12),查看 Network 标签页中的请求和响应。
- 常见联调问题 :
- 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 :查看后端控制台日志,通常是业务逻辑异常或数据库错误。
- CORS 错误 :前端请求后端出现跨域错误。需要在 Spring Boot 后端配置 CORS。
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 生产环境部署注意事项
-
数据库 :
- 切勿使用
ddl-auto: update。应使用validate,并通过 Flyway 等工具进行版本化迁移。 - 为生产环境创建专用数据库用户,并赋予最小必要权限。
- 定期备份数据。
- 切勿使用
-
应用配置 :
- 将
application.yml中的敏感信息(如数据库密码、JWT secret)移至环境变量或配置中心。 - 使用
application-prod.yml指定生产环境配置,并通过--spring.profiles.active=prod激活。
- 将
-
安全加固 :
- 密码加密 :必须使用 BCrypt 等强哈希。
- SQL 注入 :使用 JPA 或 MyBatis 的参数化查询,避免拼接 SQL。
- XSS 攻击 :对用户输入进行过滤或转义,或使用模板引擎的自动转义功能。
- CSRF :如果使用 Session 管理,需启用 CSRF 防护。JWT 方案本身对 CSRF 有一定抵御力,但仍需注意。
- 文件上传 :限制文件类型、大小,并对上传文件进行病毒扫描,存储路径不要允许直接执行。
-
日志与监控 :
- 配置日志框架(如 Logback)将日志输出到文件,并设置合理的滚动策略和级别。
- 集成监控组件(如 Spring Boot Actuator + Prometheus + Grafana),监控应用健康状态、JVM 指标和业务指标。
-
前端部署 :
- 运行
npm run build生成静态文件。 - 可以将
dist目录内容部署到 Nginx 或对象存储(如 OSS、COS)。 - 配置 Nginx 反向代理,将
/api路径的请求转发到后端服务。
- 运行
5.3 项目扩展方向
- 实时通信 :将简单的留言板升级为真正的实时聊天室,集成 WebSocket(如 Spring Boot + STOMP)或第三方 SDK。
- 视频咨询 :集成实时音视频 SDK,实现视频咨询功能。
- 文章与视频管理 :实现富文本编辑器发布文章,集成视频点播服务(处理 m3u8 格式)。
- 数据可视化 :使用 ECharts 等库,为管理员提供咨询数据统计图表。
- 移动端适配 :考虑使用响应式设计或开发 Uni-app 跨端应用。
- 微服务化 :如果业务复杂,可将用户服务、咨询服务、内容服务拆分为独立微服务。
构建一个完整的平台需要持续迭代。建议先从核心的“用户-咨询”流程跑通,再逐步丰富其他功能。在开发过程中,务必重视代码结构、异常处理和日志记录,这些是区分课程作业与可维护项目的关键。

292


被折叠的 条评论
为什么被折叠?



