资讯动态

SpringBoot+Vue3前后端分离实战:校园志愿者管理系统开发详解

发布时间:2026/9/9 18:16:03 来源:尧图企业网站定制
1. 项目概述——从一次校园招募活动聊起有次在学校团委办公室帮忙处理志愿者招募我真正体会到了什么叫台账灾难。一张几百人的报名表在群里传来传去有人改了版本没同步有人报了名但不知道自己被分到哪个岗位活动结束要统计服务时长时得从聊天记录和纸质签到表里一条条翻。那一刻我意识到校园志愿者工作不是没有管理需求而是缺少一个能让信息跑起来的系统。这个项目就是围绕校园志愿者管理系统做的前后端分离实现。后端用 Java 和 SpringBoot 提供 API 服务ORM 层选了 MyBatis 操作 MySQL 数据库前端用 Vue3 配合 Vite 构建页面整套东西部署起来之后管理员能发布活动、审核报名、记录服务时长志愿者能在小程序或网页端报名、查看活动安排、查自己的累计时长。系统的核心价值不是把纸质表格变成电子表格而是把报名、审核、签到、统计这条链路串起来让每个环节的状态变化有记录、可追溯。文章适合两类人看一类是正在做课程设计或毕业设计的同学可以直接参考数据库设计和后端接口拆解另一类是刚接触前后端分离开发、想搞明白 SpringBoot 和 Vue3 到底怎么配合写业务的初学者。我会把从数据库设计到前后端联调的完整链路拆开讲包括我实际踩过的坑和最终采用的解决方式。2. 技术选型分析——这套组合踩在了什么点上2.1 为什么是 SpringBoot 而不是 SSM 或 Spring Cloud老工程师看到 SpringBoot 可能觉得没什么新鲜的但对校园项目来说SpringBoot 的价值恰恰在于它帮你省掉了大量重复造轮子的工作。如果回到十年前用 SSMSpring SpringMVC MyBatis你至少得手动配置 DispatcherServlet、数据源、事务管理器还得处理一堆 XML 文件。SpringBoot 通过自动配置和起步依赖把这些全部收敛了写一个 Controller 接口从建类到跑通不过几分钟。有同学问过我要不要直接上 Spring Cloud Alibaba 微服务我问他这个系统有几个业务模块他说管理员和志愿者两个角色也就三四十张表。我说这种规模上微服务等于开着卡车去菜市场买菜——不是不能开是毫无必要。单机 Tomcat 完全扛得住校园场景的并发量微服务带来的服务注册发现、配置中心、链路追踪对你目前的问题只增加复杂度没有收益。2.2 MyBatis 和 MyBatis-Plus我为什么没选后者现在很多教程上来就用 MyBatis-Plus因为 BaseMapper 直接把单表 CRUD 封装好了确实省事。但我在这个项目里坚持用原生 MyBatis原因有两个。一是这个项目里有大量多表联查的统计需求比如统计每个志愿者本月的累计服务时长查询某次活动中所有报名学生的院系分布。这类 SQL 用 MyBatis-Plus 的 LambdaQueryWrapper 拼起来非常别扭最后你还是得写 XML 里的自定义 SQL。既然如此不如一开始就把 SQL 掌控在自己手里。二是学习价值。用 MyBatis-Plus 你可能会忽略 resultMap、动态 SQL、一级二级缓存这些核心机制的细节而这些恰恰是面试和实际业务里最常被问到的东西。原生 MyBatis 写起来确实繁琐一点但它逼着你理解每条 SQL 是怎么执行的参数是怎么映射的这层理解在排查问题时非常值钱。2.3 Vue3 Vite 为什么替代了 Vue2 WebpackVue3 的组合式 APIComposition API刚出的时候争议很大用习惯 Options API 的人觉得代码变得散了。但真在项目里跑起来你会发现组合式 API 的优势特别明显把一个业务功能的所有状态和逻辑收拢在一起比如报名模块的 loading 状态、表单数据、提交函数全放在一起维护时不用在 data、methods、watch 之间来回跳了。构建工具选 Vite 也完全是体验降维打击。开发环境下它利用浏览器原生 ES Module 做按需编译启动速度秒开级热更新几乎无感。我之前在 Webpack 项目里改一行代码等四五秒钟刷新的经历换到 Vite 之后彻底消失了。部署时 Vite 打包出来是纯静态文件丢到 Nginx 里就能用跟后端完全解耦。MySQL 作为数据库没有悬念校园系统用不上 Oracle 级别的重型能力MySQL 8.0 在窗口函数比如按月份滚动统计时长、JSON 字段支持上都很好用社区资料也丰富遇到问题基本都能搜到解法。3. 数据库设计——从业务实体到建表语句3.1 核心实体关系梳理校园志愿者管理系统的业务链条可以拆成几个核心实体用户user、志愿者档案volunteer_profile、活动activity、活动报名activity_registration、服务时长记录service_record、公告notice、学院/组织organization。如果画 ER 图关系大概是这样的用户表是登录认证的基础通过 user_type 字段区分管理员和志愿者志愿者档案表和用户表是一对一关系存放学号、学院、专业、联系方式等扩展信息活动和报名表是一对多关系一个活动对应多条报名记录服务时长记录表则连接志愿者和活动每次签到签退产生一条记录同时也是统计报表的数据源。这里容易踩的坑是报名记录和服务时长记录的边界划分。一开始我设想用一张表搞定报名通过后直接往上加签到时间、签退时间、时长字段。后来发现不行——一个志愿者报名了一个活动但没被录用这条报名记录里就不该有时长字段一个活动可能分多天进行志愿者每天签到一次一条报名记录对应多条时长记录。把两个概念拆开之后数据模型才真正贴合实际业务。3.2 数据库表 DDL 实战直接给关键表的建表语句比空谈设计有用得多。这是我调试过很多遍的版本字段和索引都按实际查询路径设置过。-- 用户表 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 登录用户名, password varchar(255) NOT NULL COMMENT BCrypt加密后的密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名, phone varchar(20) DEFAULT NULL COMMENT 联系电话, user_type tinyint NOT NULL DEFAULT 2 COMMENT 用户类型1管理员2志愿者, status tinyint NOT NULL DEFAULT 1 COMMENT 状态1正常0禁用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;注意几个细节。密码字段长度我留了 255因为 BCrypt 加密出来的字符串本身就 60 个字符左右加上未来的算法升级余量255 是稳妥的。username 建了唯一索引这是登录查询的高频路径必须走索引。-- 活动表 CREATE TABLE activity ( id bigint NOT NULL AUTO_INCREMENT COMMENT 活动ID, title varchar(100) NOT NULL COMMENT 活动名称, description text COMMENT 活动描述, location varchar(255) DEFAULT NULL COMMENT 活动地点, start_time datetime NOT NULL COMMENT 开始时间, end_time datetime NOT NULL COMMENT 结束时间, max_volunteers int DEFAULT NULL COMMENT 招募人数上限, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0草稿1招募中2进行中3已结束4已取消, creator_id bigint NOT NULL COMMENT 创建人ID管理员, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_starttime (status, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT志愿活动表;我给活动表的status和start_time建了联合索引。为什么因为首页的活动列表最常见的查询是筛选某个状态下招募中并且按开始时间排序最先开始的排前面联合索引能同时覆盖过滤和排序。单独给 status 建索引效果会打折因为 InnoDB 二级索引本身就包含主键但联合索引能避免回表时额外排序。-- 活动报名表 CREATE TABLE activity_registration ( id bigint NOT NULL AUTO_INCREMENT COMMENT 报名ID, activity_id bigint NOT NULL COMMENT 活动ID, user_id bigint NOT NULL COMMENT 志愿者用户ID, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0待审核1已通过2已拒绝3已取消, apply_reason varchar(500) DEFAULT NULL COMMENT 报名理由, audit_remark varchar(500) DEFAULT NULL COMMENT 审核备注, audit_time datetime DEFAULT NULL COMMENT 审核时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_activity_user (activity_id, user_id), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT活动报名表;报名表这里的关键是那个唯一索引uk_activity_user。它从数据库层面杜绝了同一个用户对同一个活动的重复报名这个约束比在 Service 层写 if 判断靠谱得多并发场景下不会有漏网之鱼。时长记录表的结构稍微特殊一点除了基本字段外需要一张独立的表记录签到签退时间差生成的服务时长-- 服务时长记录表 CREATE TABLE service_record ( id bigint NOT NULL AUTO_INCREMENT COMMENT 记录ID, user_id bigint NOT NULL COMMENT 志愿者ID, activity_id bigint NOT NULL COMMENT 活动ID, registration_id bigint NOT NULL COMMENT 关联的报名记录ID, check_in_time datetime DEFAULT NULL COMMENT 签到时间, check_out_time datetime DEFAULT NULL COMMENT 签退时间, hours decimal(5,2) DEFAULT NULL COMMENT 服务时长小时, status tinyint NOT NULL DEFAULT 0 COMMENT 状态0未签到1已签到2已签退, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_activity_id (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT服务时长记录表;hours字段类型选了DECIMAL(5,2)最多能存 999.99 小时对校园场景绰绰有余。这里不建议用 FLOAT浮点数计算精度会在统计汇总时出问题DECIMAL 是定点数才能保证钱和时长这类数据的精度。3.3 数据字典与状态字段的约定状态字段我习惯用 TINYINT 而不是字符串原因很简单整数比较比字符串快存储空间小而且不会出现已通过通过审核通过这种同义不同值的数据脏乱问题。代价是需要维护一套数据字典在代码里用常量类或者枚举类统一管理。public class ActivityStatus { public static final int DRAFT 0; // 草稿 public static final int RECRUITING 1; // 招募中 public static final int ONGOING 2; // 进行中 public static final int FINISHED 3; // 已结束 public static final int CANCELED 4; // 已取消 }这个做法在团队协作中尤其有价值新同学看到activity.getStatus() ActivityStatus.RECRUITING心里就有数了而不是面对一个魔法数字1去猜含义。4. SpringBoot 后端核心实现细节4.1 项目结构——按业务模块而非技术层分包很多人初学 SpringBoot 喜欢用controller、service、mapper这种按技术层分包的方式项目小的时候没毛病一旦业务模块多起来就会乱改一个活动相关功能要穿梭五六个包。我这次按业务领域分包Alibaba 开发规范里叫按业务垂直划分。com.campus.volunteer ├── common ← 通用工具、常量、统一响应体 ├── config ← 配置类跨域、拦截器、WebMvc等 ├── security ← 登录认证、JWT工具 ├── module │ ├── auth ← 登录注册模块 │ ├── user ← 用户管理模块 │ ├── activity ← 活动管理模块 │ ├── registration← 报名审核模块 │ └── stats ← 时长统计模块每个业务模块内按 controller、service、mapper、entity、dto、vo 分层。这样改一个模块的功能时所有相关文件都在同一个包路径下IDE 折叠起来一目了然。4.2 登录认证与 JWT 无状态会话校园系统不像企业系统那样有严格的企业微信或 OA 单点登录环境JWT 认证是简单又够用的方案。流程是用户提交用户名密码后端校验通过后生成一个 HS256 签名的 JWT 返回给前端前端把它存在本地存储里每次请求在 Header 里带上Authorization: Bearer token后端用拦截器解析 token 并注入用户信息到请求上下文。核心拦截器实现Component public class JwtInterceptor implements HandlerInterceptor { Resource private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String authHeader request.getHeader(Authorization); if (authHeader ! null authHeader.startsWith(Bearer )) { String token authHeader.substring(7); try { Long userId jwtUtil.parseToken(token); // 将用户ID放入当前请求上下文后续控制器直接读取 AuthContext.setUserId(userId); return true; } catch (Exception e) { // token过期或无效 response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write({\code\:401,\msg\:\登录状态过期请重新登录\}); return false; } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }这里有个实际开发中的细节AuthContext底层是 ThreadLocal用完必须清理否则线程池复用线程时会串号。所以一定要在afterCompletion方法里调用AuthContext.clear()。另一个安全要点是密码存储。项目中用 Spring Security Crypto 的 BCryptPasswordEncoder它的特点是自动加盐每次加密同一个密码得到的结果都不一样但校验只需要一个方法调用Resource private BCryptPasswordEncoder passwordEncoder; public String encodePassword(String rawPassword) { return passwordEncoder.encode(rawPassword); } public boolean matches(String rawPassword, String encodedPassword) { return passwordEncoder.matches(rawPassword, encodedPassword); }权限控制方面我在拦截器基础上加了RequireRole注解配合一个简单的注解扫描判断避免引入完整的 Spring Security 框架因为这里只有管理员和志愿者两种角色写十行代码的注解逻辑比引入整个 Security 体系划算得多。4.3 MyBatis XML 映射中的动态 SQLMyBatis 最强大的功能是动态 SQL在 XML 里可以根据条件拼接查询语句。这里以活动条件查询为例这个接口要支持按标题模糊搜索、按状态过滤、按时间段过滤、分页select idselectActivityPage resultTypecom.campus.volunteer.module.activity.vo.ActivityVO SELECT a.id, a.title, a.location, a.start_time, a.end_time, a.max_volunteers, a.status, (SELECT COUNT(*) FROM activity_registration r WHERE r.activity_id a.id AND r.status 1) AS registered_count, u.real_name AS creator_name FROM activity a LEFT JOIN user u ON a.creator_id u.id where if testtitle ! null and title.trim() ! AND a.title LIKE CONCAT(%, #{title}, %) /if if teststatus ! null AND a.status #{status} /if if teststartTime ! null AND a.start_time gt; #{startTime} /if if testendTime ! null AND a.end_time lt; #{endTime} /if /where ORDER BY a.create_time DESC LIMIT #{offset}, #{pageSize} /xmlwhere标签的妙处在于它会自动去掉第一个条件前面的AND这个老手都懂但新手经常下午折腾半天发现 SQL 多出一个 AND 报错。子查询统计已报名人数也是常用手法比在 Java 里循环查报名表高效得多。分页这里用了手动LIMIT #{offset}, #{pageSize}没有引入 PageHelper 插件。我一直觉得 PageHelper 的物理分页通过拦截器改写 SQL 实现遇到复杂嵌套查询时偶尔会报奇葩错误。项目查询量不大手动 LIMIT 足够并且 SQL 执行路径完全透明可控。4.4 事务处理——报名和时长记录不能拆开报名操作涉及两步写入报名记录、更新活动当前报名人数如果需要限制。这两个操作要么一起成功要么一起失败必须用事务包裹。SpringBoot 里的做法就是在 Service 方法上加Transactional注解。Override Transactional(rollbackFor Exception.class) public void registerActivity(Long activityId, Long userId, String reason) { Activity activity activityMapper.selectById(activityId); if (activity null) { throw new BusinessException(活动不存在); } if (activity.getStatus() ! ActivityStatus.RECRUITING) { throw new BusinessException(该活动当前不可报名); } ActivityRegistration registration new ActivityRegistration(); registration.setActivityId(activityId); registration.setUserId(userId); registration.setStatus(RegistrationStatus.PENDING); registration.setApplyReason(reason); try { registrationMapper.insert(registration); } catch (DuplicateKeyException e) { throw new BusinessException(您已报名过该活动请勿重复报名); } }注意两点。第一rollbackFor Exception.class必须写因为 Spring 默认只对 RuntimeException 回滚如果事务方法抛出 CheckedException比如 IOException数据不会回滚这是个非常隐蔽的坑。第二捕获了DuplicateKeyException并转成友好提示这个异常就是前面那张报名表唯一索引在起作用时的表现。日志里保证不暴露 SQL 细节给前端统一返回业务错消息。签到签退逻辑也放在一个事务里先在 service_record 表插入一条记录状态置为已签到当签退时更新同一行记录将状态改为已签退并计算时长Transactional(rollbackFor Exception.class) public void checkOut(Long registrationId) { ServiceRecord record serviceRecordMapper.selectByRegistrationId(registrationId); if (record null || record.getStatus() ! ServiceRecordStatus.CHECKED_IN) { throw new BusinessException(当前状态不允许签退); } record.setCheckOutTime(new Date()); long diffMillis record.getCheckOutTime().getTime() - record.getCheckInTime().getTime(); double hours diffMillis / 3600000.0; // 保留两位小数 BigDecimal hoursDecimal BigDecimal.valueOf(hours).setScale(2, RoundingMode.HALF_UP); record.setHours(hoursDecimal); record.setStatus(ServiceRecordStatus.CHECKED_OUT); serviceRecordMapper.updateById(record); }时长计算这里有个业务导向的决定我采用的是精确到秒的差值然后四舍五入到两位小数而不是按分钟取整。如果按半小时为单位圆弧取整有些活动时长 17 分钟向上取整就是 0.5 小时对学校统计不完全公平四舍五入到两位小数更精确。5. Vue3 前端工程化实践5.1 创建项目与目录组织前端我用 Vite 脚手架创建命令很简单npm create vitelatest volunteer-web -- --template vue创建完基础工程之后装核心依赖npm install vue-router4 pinia axios element-plus目录结构按路由 视图 组件 API Store组织src ├── api │ ├── activity.js │ ├── auth.js │ └── user.js ├── assets ├── components │ ├── ActivityForm.vue │ └── PaginationTable.vue ├── router │ └── index.js ├── stores │ └── user.js ├── views │ ├── login │ ├── admin │ │ ├── ActivityManage.vue │ │ ├── UserManage.vue │ │ └── StatsDashboard.vue │ └── volunteer │ ├── ActivityList.vue │ └── MyVolunteer.vue └── main.jsAPI 层是很多人容易忽略的。我要求自己前端所有请求都通过 api 目录下对应的模块发起不要在组件里直接写 axios 请求字符串。这样后端接口路径改动时只改 api 目录对应文件即可。5.2 封装 Axios 拦截器——统一处理 token 和错误Axios 拦截器是前端工程化的关键一步在 request 拦截器里附加 token在 response 拦截器里统一处理业务错误码import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器附加token request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) // 响应拦截器统一处理业务码 request.interceptors.response.use( response { const res response.data if (res.code 200) { return res } else if (res.code 401) { const userStore useUserStore() userStore.clearAuth() router.push(/login) return Promise.reject(new Error(未登录或登录过期)) } else { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )一个很容易踩的细节响应拦截器里不要直接把response返回而是要返回response.data否则每个业务组件里都要多写一层.data.data。这是我看到很多新手项目代码里最常见的冗余写法。token 存储的位置也有讲究。我推荐存到 Pinia状态管理 localStorage 双写Pinia 保证页面内共享localStorage 保证刷新后不丢登录态。但 localStorage 有 XSS 风险在后端接口中要避免在回复里渲染不可信代码段这是前后端共同防护的工作。5.3 路由守卫——按角色动态拦截页面访问前端路由必须做访问控制否则普通志愿者直接访问/admin/users的前端页面就能看见管理界面。虽然后端接口有权限拦截但前端不拦截的话用户体验很差而且会暴露不必要的信息。// router/index.js const routes [ { path: /login, component: () import(/views/login/LoginView.vue) }, { path: /admin, component: () import(/layout/AdminLayout.vue), meta: { roles: [1] }, children: [ { path: activities, component: () import(/views/admin/ActivityManage.vue) }, { path: users, component: () import(/views/admin/UserManage.vue) }, { path: stats, component: () import(/views/admin/StatsDashboard.vue) } ] }, { path: /volunteer, component: () import(/layout/VolunteerLayout.vue), meta: { roles: [2] }, children: [ { path: activities, component: () import(/views/volunteer/ActivityList.vue) }, { path: my-activities, component: () import(/views/volunteer/MyVolunteer.vue) } ] } ] router.beforeEach((to, from, next) { const userStore useUserStore() if (to.path /login) { next() return } if (!userStore.token) { next(/login) return } const requiredRoles to.meta.roles if (requiredRoles !requiredRoles.includes(userStore.userType)) { next(/403) return } next() })这里用了meta.roles标记路由允许访问的角色角色编码和数据库里user_type字段保持一致1管理员2志愿者前后端一个标准。5.4 典型页面实现——报名与数据可视化活动列表页是整个系统前端最有代表性的页面包含搜索条件区、分页表格、报名操作按钮。Vue3 组合式 API 的写法比 Options API 清晰太多script setup import { ref, onMounted } from vue import { getActivityPage, applyActivity } from /api/activity import { ElMessage, ElMessageBox } from element-plus const queryParams ref({ title: , status: null, pageNum: 1, pageSize: 10 }) const tableData ref([]) const total ref(0) const loading ref(false) async function fetchList() { loading.value true try { const res await getActivityPage(queryParams.value) tableData.value res.data.records total.value res.data.total } finally { loading.value false } } function handleApply(row) { ElMessageBox.confirm(确认报名参加${row.title}活动, 报名确认, { confirmButtonText: 确认, cancelButtonText: 取消, type: info }).then(async () { await applyActivity(row.id, 暂无特殊说明) ElMessage.success(报名成功请等待审核) fetchList() }).catch(() {}) } onMounted(() { fetchList() }) /script管理后台的统计页面用了 ECharts 展示按月的服务时长趋势和活动类型分布。Vue3 里用 ECharts 有两种方式一种是封装成组件用ref绑定 DOM 初始化图表实例并在onBeforeUnmount时dispose销毁另一种是直接用现成的 vue-echarts 封装库。校园项目数据量小直接用 ECharts 原生 API 初始化即可不需要再多引一层封装。template div reftrendRef styleheight: 360px/div /template script setup import { ref, onMounted, onBeforeUnmount } from vue import * as echarts from echarts const trendRef ref(null) let chartInstance null onMounted(() { chartInstance echarts.init(trendRef.value) chartInstance.setOption({ title: { text: 近6个月服务时长统计小时 }, tooltip: { trigger: axis }, xAxis: { type: category, data: [] }, yAxis: { type: value }, series: [{ name: 服务时长, type: line, smooth: true, data: [] }] }) loadTrendData() }) async function loadTrendData() { const res await getMonthlyHoursTrend() chartInstance.setOption({ xAxis: { data: res.data.map(i i.month) }, series: [{ data: res.data.map(i i.totalHours) }] }) } onBeforeUnmount(() { chartInstance?.dispose() }) /script6. 前后端联调与常见坑排查6.1 开发环境的跨域代理配置前后端分离开发时前端跑在 Vite 的 5173 端口后端跑在 8080 端口直接请求必然跨域。方案有后端加CrossOrigin、后端配置全局跨域过滤器、前端配开发服务器代理。我强烈建议用第三种因为前端代理方案在开发环境最方便而且部署到生产环境时用 Nginx 反向代理也不需要后端额外处理。Vite 配置文件里这样配// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 若后端接口没有统一 /api 前缀需要重写路径 rewrite: path path.replace(/^\/api/, ) } } } })如果你后端 Controller 的 RequestMapping 里有/api前缀那就不需要 rewrite。这个规范一定要前后端提前统一比如后端接口全部以/api/xxx开头前端 baseURL 设为/api代理直接把/api原样转发到后端端口。6.2 一个典型的接口字段类型不匹配问题我得分享一个真实踩过的坑。后端返回的时间字段是LocalDateTime经过 Jackson 序列化后默认格式是2025-01-15T10:30:00其中带一个字母 T。前端 Element Plus 的表格和时间选择器展示出来是 2025-01-15T10:30:00用户看着很不舒服而且日期选择器回显时解析不了这个格式。解决方式是统一在后端配置 Jackson 格式化# application.yml spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这个配置对java.util.Date有效但如果你用的是LocalDateTime或LocalDate需要额外配置序列化器。一个更简洁的做法是在实体类的 LocalDateTime 字段上加JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime startTime;这个坑的教训是前后端联调最先暴露的问题往往不是逻辑错误而是数据格式约定不统一。所以项目起步开会时就要把时间格式、金额精度、状态码枚举值这些基础规范列出来签字画押能省后面大量反复沟通的时间。6.3 生产环境部署——Nginx 与 Jar 包部署方案其实非常简单。后端项目用 Maven 打包mvn clean package -DskipTests拿到target目录下的volunteer-server.jar上传到 Linux 服务器用nohup java -jar volunteer-server.jar --spring.profiles.activeprod /var/log/volunteer.log 21 前端构建npm run build得到dist目录上传到服务器的 Nginx 静态目录配置 Nginx 把/api路径反向代理到本机的 8080 端口server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /var/www/volunteer-web/dist; index index.html; try_files $uri $uri/ /index.html; } # API 反向代理 location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html这行是 Vue Router history 模式必须的配置否则刷新非首页路由时 Nginx 会去找不存在的资源路径返回 404。7. 实测体验与后续扩展方向我把系统部署完后模拟了完整的业务流程创建活动、志愿者报名、审核通过、签到签退、查看统计。实测的感受是一个完整闭环做下来后端接口 API 大约 30 个前端页面 12 个代码量中等偏上一个人两三周完全可以完成重点难点其实不在于写代码而在于把注册、报名、审核、签到、统计这条数据链路的表结构设计清晰。测试过程中发现的最大瓶颈在报名接口的并发取舍上。试想一个大型活动开放报名时几百个学生同时提交MySQL 的 InnoDB 行锁和唯一索引能保证不重复报名但事务开启时间过长会拉高锁等待时间。我通过一个简单优化解决了报名接口先做一次存在性查询走唯一索引存在则直接返回友好提示不存在再走插入流程。这个查询动作能拦截掉绝大多数重复请求把真正需要写库的请求量降到很低。不指望它能扛住电商级别的秒杀但校园场景绰绰有余。另一个值得说的点是系统上线后的数据治理。我在代码里给每个与金额、时长相关的 SQL 都做过一轮精度检查避免Float累加误差在月度汇总报表中暴露出来。DECIMAL字段在 SUM 时也要注意转成 BigDecimal 再累加小数位不同的字段之间直接相加也会出现精度丢失。这个项目目前的架构还有很多可以扩展的方向。一是增加志愿者星级评定模块根据服务时长和活动评价自动计算星级二是接入微信小程序让志愿者直接在小程序里接收活动推送和扫码签到二维码里编码注册 ID服务端扫码后自动完成绑定三是引入活动反馈问卷活动结束后给志愿者推送评价链接数据统计分析服务于下一年的志愿活动规划。这些方向都会用到现有表和接口架构上不需要推翻重来。最后说一个运维上的小细节MySQL 8.0 的默认认证插件是caching_sha2_password如果你的连接 URL 没指定allowPublicKeyRetrievaltrue某些场景下连接池初始化会报错。这些细枝末节的东西不看官方文档很难发现踩过一次记住了下次就顺了。开发中始终保持一个习惯把异常堆栈从日志文件里捞出来读完整而不是只看第一行就急着搜解决方案。很多怪异问题的根因其实就藏在你不愿意往下看的那几行 Caused by 里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价