1. 这类"善筹网"项目为什么每年都有学生选,它到底在锻炼什么
先说个现象:每年毕业设计选题季,"SpringBoot+Vue前后端分离的众筹互助网站"这类题目几乎每个高校的软件工程、计算机科学专业都会出现。很多人觉得它"烂大街",但站在实际答辩和找工作两个角度看,这个选题恰恰是被反复验证过的稳妥之选。
为什么?因为一个众筹互助平台(善筹网)的完整业务闭环覆盖了Web开发最核心的几件事:用户注册登录、角色权限控制、项目发布与审核、捐赠下单、进度统计、数据可视化。它既有普通电商项目的基础操作,又比纯图书管理、学生管理系统多了一层业务状态流转(从发起、待审、筹款中、完成到失败),这才像"能上线跑的真实产品",而不是纯粹的CRUD。
从技术栈角度看,SpringBoot负责后端API,Vue负责前端交互,天然贴合当前企业里主流的"前后端分离"开发模式。你在简历上写"独立开发前后端分离平台",面试官起码能跟你聊下去——因为它涉及JWT鉴权、跨域处理、文件上传、数据库表设计、事务与并发控制,这些都是企业开发绕不开的基础功。
这篇文章我就按我当时带A同学做这个题目的完整思路来写:从架构划分、后端模块、数据库设计,到前端联调、部署答辩,把每一步为什么这么做讲清楚。已经做完或者正在做类似平台的同学,可以直接对照你的实现查漏补缺。
2. 从零搭建后端:SpringBoot的模块划分与权限体系
2.1 先分清"包结构怎么拆",后端才不会写成一坨
很多人一上来就按controller、service、mapper三层拆包,然后全部塞进去。做这种规模的项目确实能跑,但后期改需求会非常痛苦。我更推荐按业务域归类,尤其当一个系统里有"前台用户操作"和"后台管理员操作"两套逻辑时,这种划分优势很大:
com.example.shanchou ├── config // 跨域、JWT拦截器、MyBatis-Plus分页插件等配置 ├── common // 统一返回结果 Result、异常处理、工具类 ├── entity // 数据库实体类 ├── mapper // MyBatis-Plus的 Mapper 接口 ├── service // 业务逻辑接口与实现 ├── controller │ ├── user // 前台:登录注册、个人中心、发起项目、捐赠 │ └── admin // 后台:项目审核、用户管理、数据统计 └── dto // 前端传入的对象,避免直接用实体接收这里有个细节值得注意:controller不要直接接收实体类,尤其涉及金额、状态的写入操作。比如发起项目时前端传来的是一个CreateProjectDTO,里面可以带title、description、targetAmount这些字段,而数据库里的userId、status、createTime必须由后端自己填充。这是答辩时安全性的一个加分点,也是实际开发里防止字段被恶意篡改的常规做法。
2.2 登录注册与JWT拦截:前后端分离项目的身份认证关键
前后端分离后,Session其实不太好用了,因为前后端可能部署在不同域名或端口下,Cookie的跨域行为很麻烦。所以善筹网这类项目最合理的方案就是JWT(JSON Web Token)。
流程很简单:用户登录成功后,后端生成一个token返回给前端;前端每次请求在Header里带上Authorization: Bearer <token>;后端写一个拦截器统一解析token,把用户信息取出来放到请求上下文。
具体实现上,注册时密码必须加密存储,我建议直接用Spring Security自带的BCryptPasswordEncoder,比MD5安全得多。登录时的核心逻辑类似下面这样:
public LoginResult login(String username, String password) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, username); User user = userMapper.selectOne(wrapper); if (user == null || !passwordEncoder.matches(password, user.getPassword())) { throw new BusinessException("用户名或密码错误"); } if (user.getStatus() == 0) { throw new BusinessException("账号已被禁用"); } String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole()); return new LoginResult(token, user.getNickname(), user.getRole()); }拦截器里要注意一个点:需要放行的路径要提前列清楚,比如/api/auth/login、/api/auth/register、首页项目列表、项目详情查询这些公开接口。我当时把"公开接口"和"需要登录接口"用了一个简单的路径前缀约定:公开接口在/api/public/**下统一不做拦截,需要登录取用户信息的接口走token解析。这样配置清晰,也好答辩解释。
2.3 从发起到募捐成功,项目的状态流转要算清楚
众筹项目的状态不能只有"已发布/未发布"两个,否则业务逻辑会有漏洞。我在设计时给项目定了这么几个状态:
| 状态值 | 含义 | 触发条件 |
|---|---|---|
| 0 | 草稿 | 用户发起项目时保存,但未提交审核 |
| 1 | 待审核 | 用户提交项目,等待管理员审核 |
| 2 | 筹款中 | 管理员审核通过,展示在前台可被捐赠 |
| 3 | 已成功 | 达到目标金额且未过截止时间 |
| 4 | 已失败 | 截止时间到了但未达到目标金额 |
| 5 | 已下架 | 管理员手动下架或违规项目被关闭 |
很多人容易忽略的是状态流转的触发时机。比如"已成功"这个状态,主要有两个触发点:一是在用户下单捐赠后,同步检查当前项目的累计金额是否达到targetAmount,达到则改为"已成功";二是在每次查询项目详情时做一次"惰性检查",如果当前时间超过endTime且金额未达标,就更新为"已失败"。
这两个触发点缺一不可。前者保证实时性,后者保证那些"没人捐赠所以一直挂着"的项目最终也会有个了结状态。答辩时把这个逻辑讲清楚,再配合时间字段的索引设计,会显得你考虑得很周全。
3. 前端Vue:路由设计、页面骨架与接口联调
3.1 路由守卫与登录态维持,别再每个页面判断一遍
前端最忌讳的一件事:在几十个页面的mounted里写"判断有没有token,没有就跳登录页"。这种事应该交给路由守卫统一处理。善筹网项目我用Vue Router的beforeEach做了全局前置守卫,效果很好:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }); } else if (to.path === '/login' && token) { next('/'); } else { next(); } });同时在路由meta里给需要权限的页面打标记,比如"发起项目"页requiresAuth为true,"后台管理"页加一个meta.roles: ['admin'],守卫里再校验用户角色。这样新增页面的时候,只要在路由配置里声明访问要求,其他什么都不用管。
这里我想多说一句:前端只用localStorage.getItem('token')其实不够严谨,因为用户被禁用后token可能仍然有效。所以更稳妥的做法是,在全局的axios拦截器里遇到401状态码时,清除本地token并跳转登录页。后端的JWT拦截器保证"无效或过期token直接返回401",前端拿到401统一处理,这套闭环才能撑起整个登录态。
3.2 三个核心页面的实现思路与关键交互
善筹网前端的核心页面,我总结下来是首页、项目详情页、发起众筹页。三个页面的技术侧重点不一样,但都是答辩时会被深入追问的地方。
首页按板块展示众筹项目。通过条件查询接口带category和sort参数,前端用el-tabs切换"最新/最热/即将结束"等筛选,底层其实是同一个list接口。这里值得注意的细节是:分页参数不要硬编码固定数值,我用的Element UI分页组件,一页8条数据,切换页时重新调用接口,数据变化不会出现"点击过快导致列表错乱"的问题。
项目详情页比较考验你对众筹业务的理解。页面除了项目信息外,还得展示当前进度条、已筹金额、支持人数和剩余时间。进度百分比可以在前端根据接口返回的currentAmount / targetAmount计算,但剩余天数建议用后端返回值,不要前端自己用JS算——因为后端和前端服务器时间可能不一致,会出现倒计时不准的情况。
发起众筹页是表单密集型页面,涉及项目标题、封面图、目标金额、截止时间、详细描述。封面图上传我用的是Element UI的Upload组件,注意把action指到后端文件上传接口,同时在后端返回文件访问URL后回填到表单字段里。详情描述可以用富文本编辑器,如果不想引入太重的组件,可以把description转成纯文本存储,页面展示时用v-html渲染——但必须注意XSS风险,建议后端做一次内容清洗,或者简单点,直接过滤掉<script>标签。
3.3 Axios封装:统一统一再统一
Vue项目里如果每个页面直接axios.get('http://localhost:8888/api/project/list'),你会遇到两个问题:一是跨域配置写死,二是错误处理散落各处。我当时给A同学封装了一个request.js,把baseURL、请求超时、token注入、错误提示、Loading控制全部收拢到一个文件里:
const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { Message.error(res.msg); return Promise.reject(new Error(res.msg)); } return res; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } Message.error(error.message || '请求失败'); return Promise.reject(error); } );baseURL设成/api是为了开发环境和生产环境都能平滑切换。开发环境在vue.config.js里配置devServer的proxy把/api转到后端地址,生产环境由Nginx做反向代理。这样代码里就不用写任何硬编码域名,换环境只改代理配置,非常干净。
4. 数据库设计:几张核心表的字段取舍与进度统计
4.1 用户、项目、捐赠订单:这三张表决定业务上限
一个众筹互助网站最核心的数据表就是用户表、项目表、捐赠订单表。很多人的设计问题出在字段过多或者过少,我建议照着下面的核心字段来控制范围:
用户表(user):
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', nickname VARCHAR(50) DEFAULT '' COMMENT '昵称', phone VARCHAR(20) DEFAULT NULL COMMENT '手机号', avatar VARCHAR(255) DEFAULT NULL COMMENT '头像URL', role TINYINT DEFAULT 1 COMMENT '角色:1普通用户 2管理员', status TINYINT DEFAULT 1 COMMENT '状态:0禁用 1正常', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'项目表(project):
id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '发起人', title VARCHAR(100) NOT NULL COMMENT '项目标题', category VARCHAR(50) DEFAULT NULL COMMENT '分类', cover VARCHAR(255) DEFAULT NULL COMMENT '封面图', description TEXT COMMENT '项目详情', target_amount DECIMAL(10,2) NOT NULL COMMENT '目标金额', current_amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '当前已筹金额', donate_count INT DEFAULT 0 COMMENT '参与捐赠人数', status TINYINT DEFAULT 0 COMMENT '状态:0草稿 1待审核 2筹款中 3成功 4失败 5下架', start_time DATETIME COMMENT '开始筹款时间', end_time DATETIME COMMENT '截止时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_status_end (status, end_time)捐赠订单表(donation_order):
id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单号', project_id BIGINT NOT NULL COMMENT '项目ID', user_id BIGINT NOT NULL COMMENT '捐赠者ID', amount DECIMAL(10,2) NOT NULL COMMENT '捐赠金额', remark VARCHAR(255) DEFAULT NULL COMMENT '留言', status TINYINT DEFAULT 0 COMMENT '支付状态:0未支付 1已支付', pay_time DATETIME DEFAULT NULL COMMENT '支付时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_project (project_id), INDEX idx_user (user_id)注意几个细节:金额字段一律用DECIMAL,不要用DOUBLE或FLOAT;订单号要全局唯一,可以简单用时间戳+用户ID+随机数生成,但格式要稳定;status字段不仅要存,还要配合end_time建联合索引,因为"查出所有筹款中的项目并按剩余时间排序"这个查询很频繁。
4.2 众筹进度实时更新:不要频繁SUM,也别只存一个数
众筹进度(current_amount)有两个落地方案:一个是每次展示时SELECT SUM(amount) FROM donation_order WHERE project_id=? AND status=1,另一个是直接在project表里维护一个current_amount字段。
纯SUM方案在数据量小的时候没问题,但每次列表查询都要做聚合计算,性能会随着订单量增长明显下降。而单纯维护字段又面临一致性问题——如果同一时间有多个人捐赠,后写的人可能覆盖先写的人的结果。
我采用的是"字段为主、事务内累加"的做法:捐赠成功后,在同一个事务里先插入订单,再用一条带条件的UPDATE累加项目金额:
@Transactional public void donate(DonateDTO dto) { // 1. 插入订单,状态为已支付(这里模拟支付成功) // 2. 累加项目金额 int rows = projectMapper.increaseAmount(dto.getProjectId(), dto.getAmount()); if (rows == 0) { throw new BusinessException("项目不存在或不在筹款中"); } // 3. 同步更新捐赠人数 projectMapper.increaseDonateCount(dto.getProjectId()); }increaseAmount对应的SQL是UPDATE project SET current_amount = current_amount + #{amount}, donate_count = donate_count + 1 WHERE id = #{projectId} AND status = 2。这样利用数据库行锁天然解决并发累加丢更新的问题,比先select再update靠谱得多。每次查询项目列表时,直接把current_amount返回给前端展示,既快又准确。
4.3 状态字段与时间字段的边界处理
这里我想特别提醒一个坑:项目截止时间的判断标准要统一。我在后端做了三处检查,保证同一个项目不会出现"一边显示筹款中,一边已经过了截止时间却没更新状态"的情况:
- 捐赠前校验:
end_time > NOW(),否则拒绝捐赠; - 项目详情查询时做惰性状态刷新,发现过期未达标就更新为失败;
- 后台管理员列表查询时不额外处理,直接看status字段。
如果前后端时间不同步、或者定时任务没写,这套"写入校验+惰性刷新"的组合已经足够撑起毕业设计的正确性。真要上生产环境,加一个每分钟跑一次的定时任务把过期项目状态扫一遍即可。
5. 实测最容易翻车的几个点:事务、并发、金额精度
5.1 同一项目同时被多人捐赠,数据会不会丢
这是答辩时老师最爱问的问题,也是实际测试中最容易暴露的问题。如果你用"先查项目当前金额,加50,再更新回去"的方式写捐赠逻辑,在并发情况下必然会丢更新。
举个实际场景:项目当前金额是100元,A用户和B用户同时各捐50元。两条线程都先读到100,各自加50变成150,再写回数据库——最后金额是150而不是200,白白丢了50元。
解法就是我上一节说的原子UPDATE,或者用乐观锁加version字段。我更推荐前者,因为它把"判断项目状态"和"更新累计金额"合并成了一条SQL,天然带行锁,代码也简单。面试时顺带提一句"通过数据库行锁保证并发安全",比只说"加synchronized"要专业得多。
5.2 金额精度:Double算钱等于给自己埋雷
我见过不少项目用double存金额,前端显示99.99,后端算完变成99.9900000001,订单历史对不上。做任何涉及钱的系统,必须用BigDecimal或者数据库DECIMAL。
Java后端接收前端传来的金额时,建议直接定义成BigDecimal字段,并且在service层做校验:金额必须大于0、不得超过项目剩余额度(targetAmount - currentAmount)。这里还有一个细节:如果有"最少捐赠1元"的约束,校验要统一在后端做,不能只靠前端提示。
前端展示时也要注意格式,比如金额用toFixed(2)保留两位小数,进度百分比要处理除数为0的情况。还有一点:目标金额如果是100000.00元,前端输入框要限制只能输入数字和小数点,避免用户输入非法的字符导致后端解析报错。
5.3 文件上传的路径与访问映射
项目封面、用户头像都会用到文件上传。常见的坑是:开发环境上传到本地磁盘D:/upload,前端能访问;换一台电脑跑,路径变了,图片全部404。
我的做法是在后端配置里抽出一个上传路径配置项,比如:
upload: path: ./upload然后写一个WebMvcConfigurer把该路径映射为静态资源:
registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath + "/");这样前端图片地址永远是/user/server/upload/xxx.jpg这种相对路径,不管后端部署在哪台机器,只要upload目录存在就行。有一点要提醒:上传时文件名的生成不要用用户原始文件名,用UUID或者时间戳重命名,避免中文、特殊字符导致访问失败,也避免同名文件互相覆盖。如果是大文件图片,还可以压缩或限制大小,但这个项目里控制一下文件类型(jpg/png)和大小(比如5MB以内)就够了。
6. 前后端联调、部署与答辩准备
6.1 开发环境代理和生产部署的差异要提前想清楚
开发时前端跑在8080端口,后端跑在8888端口,直接跨域请求会因为CORS被浏览器拦截。我建议用两种方式配合解决:一是后端配置全局CORS(allowedOriginPatterns("*")配合allowCredentials(true)),二是在前端devServer里配置代理:
devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8888', changeOrigin: true, pathRewrite: { '^/api': '/api' } } } }生产部署时一般用Nginx把静态打包产物和SpringBoot服务都代理到同一个域名下,转发带/api前缀的请求到后端地址。这样彻底规避跨域问题。答辩时可以主动提一句"生产环境通过Nginx反代保证前后端同域",这是一个加分项。
6.2 答辩被追问"用户量大了怎么办",怎么答不翻车
如果老师问"现在项目能用,但如果用户量大了呢",不要慌,也不要吹牛说"完全没问题"。比较稳妥的回答思路是:承认当前实现是单机版,然后给出演进方向。
可以从这几个点展开:数据库层面,给查询频繁的表加索引、把状态+时间联合索引用到位,先把慢查询优化做扎实;引入Redis,把项目详情、热门项目列表做缓存,登录token也可以从JWT改成Redis存储,实现服务端主动下线功能;流量再大,就把文件存储从本地磁盘换成云存储对象存储,静态资源走CDN;最后才是考虑服务拆分和集群部署。
关键是回答逻辑要连贯,体现"我知道当前架构的边界在哪里,也知道往哪个方向走",而不是只说一个名词堆积。
6.3 时间充足的话,这几个扩展点是加分项
如果核心功能做完还有富余时间,我强烈建议挑一到两个扩展点落地:
- 数据可视化仪表盘:后台加一个统计页面,展示项目分类占比、每月募集金额趋势、用户增长曲线。用ECharts画图其实不难,但能让系统看起来完整很多。
- 站内消息通知:项目审核通过后给发起人发一条站内信;捐赠成功后给捐赠者展示感谢留言。这个功能逻辑简单,但体现了对业务细节的关注。
- 搜索功能:首页加一个项目搜索框,后端用模糊查询
title LIKE '%keyword%',配合前端防抖和空状态展示,实现起来时间成本很低。
我个人的体会是,毕业设计最忌"为了炫技而堆功能",倒不如把众筹业务本身的闭环走通:发起、审核、展示、捐赠、统计、进度更新、状态变更,每一步都有明确的验收标准。这套东西做完,你对SpringBoot和Vue的整体理解,尤其在事务、权限、跨域、部署这几个企业级必问方向上,会比单纯背面试题扎实得多。
最后再说一个小技巧:Vue项目的package.json和SpringBoot的pom.xml里建议都加上版本注释,比如"Node 16 + npm 8"、"JDK 1.8 + SpringBoot 2.7 + MyBatis-Plus 3.5",这样换电脑或者复盘的时候不用靠回忆。我当年帮A同学调试时,就是因为JDK版本不一致导致DateTimeFormatter报错,折腾了一个下午。提前把这些环境边界钉死,能帮你省掉很多"玄学问题"。