每年毕设季我都会被问同一个问题:“爱心捐赠系统这种题目,到底怎么做才不像在堆CRUD?”说实话,这类题目的难点从来不是某个技术点,而是它是少数几个能在单一项目里同时覆盖“用户体系、业务流转、资金/物资追溯、后台审批、数据统计”完整链路的选题。我最近完整做了一版基于Springboot的爱心捐赠系统,从需求梳理到数据库设计再到部署上线全程走了一遍,这篇就把整个实现过程拆开讲清楚,文末还会分享几个我实际踩坑后才总结出来的教训。
如果你正打算做类似的项目——不管是为了毕设、课程设计,还是想完整掌握Springboot项目落地流程——这篇文章都值得你从头到尾看完。
1. 为什么爱心捐赠系统值得认真做:功能边界与业务梳理
1.1 从选题到落地:这个系统到底要解决什么问题
很多人一听到“爱心捐赠系统”,第一反应是“不就是用户点一下捐赠按钮,管理员看一眼记录吗”。真做起来就会发现,事情远没有这么简单。
我把这个项目定位为校园或社区内部使用的爱心物资与善款管理信息平台,核心目的是让捐赠者能快速找到需要帮助的项目并完成捐赠,让管理人员能有序地发布项目、审核信息、公示善款去向。这里有一个关键前提必须说清楚:系统内使用的是模拟支付流程,不涉及真实资金清算,也不对接第三方支付商户号。这一点非常重要,既保证了项目的合法合规性,也让我们能把精力聚焦在业务逻辑本身,而不是去申请各种资质。
从业务流程上看,链路是这样的:
- 运营人员提交捐赠项目(包含项目标题、详情、目标金额/物资数量、起止时间)。
- 管理员审核通过后,项目在前台展示。
- 用户浏览项目、选择捐赠金额或登记物资。
- 系统生成捐赠订单,走模拟支付回调确认到账。
- 后台自动生成财务流水,捐赠记录进入待公示列表。
- 管理员定期对捐赠明细进行公示,用户能看到自己的名字和金额被公开。
这个流程覆盖了信息发布、用户操作、资金(模拟)进出、数据核验,可以说把一个小型业务系统该有的模块都串起来了。
1.2 功能模块拆解:用户端、管理端、数据端三个视角
我从一开始就把系统拆成三个视角来设计,这样后面写代码才不会被边界不明的问题反复打断。
- 用户端(前台):注册登录、个人资料维护、项目列表与详情、发起捐赠、我的捐赠记录、我的公示记录。这里用户权限只涉及自身数据,不触碰管理功能。
- 管理端(后台):项目管理(新增、审核、上下架)、捐赠记录管理、物资入库登记、公示管理、用户管理、数据统计。管理员是所有业务的把关人,审核和追踪是核心职能。
- 数据端(统计与追溯):按时间维度的捐赠趋势、项目完成率、物资分类汇总、财务流水对账。这个维度经常被人忽略,但恰恰是系统“看起来完整”的关键。
另外还有一块公共能力模块——文件上传(项目图片)、操作日志、异步通知。这些不单独属于某一个角色,但项目里一定会用到,所以在技术选型阶段就要留好位置。
2. Springboot技术选型:在“够用”和“有亮点”之间取舍
2.1 后端骨架:版本、ORM、构建方式怎么定
技术选型这件事,目标不是“用最主流的版本”,而是“让项目在已有时间预算内稳定落地,同时有可讲的亮点”。我最终确定的后端骨架如下:
- Springboot版本:2.7.x + JDK8。为什么不用Springboot 3.x?因为3.x强制要求JDK17,且部分老依赖(比如某些生成静态文档的插件)需要额外适配。对于以稳定完成为目标的毕设项目,2.7.x生态更成熟,遇到问题搜到的解决方案也更多。如果你要写“技术选型亮点”,可以用一句“Springboot 3.x已具备生产可用能力,但综合考虑生态兼容性选择2.7.x”来交代,老师在答辩时会觉得你考虑过这个问题。
- 持久层框架:MyBatis-Plus 3.5.x。这个选择很务实——单表CRUD完全不用手写SQL,分页插件开箱即用,逻辑删除和自动填充字段也能省下不少时间。多表联查时再手写SQL,主动权始终在自己手里。
- 构建工具:Maven,单模块工程。我见过不少同学为了秀技术强拆多模块,结果就是把自己绕进模块依赖的死循环。这个小项目单模块完全撑得住,关键是把包结构按
controller / service / mapper / entity / common分清楚,一样有层次感。 - 数据库:MySQL 8.0。
Maven在JDK8下有一点容易踩坑——Maven 3.6.x搭配新版本插件会提示“插件不再支持Java 8”,建议直接用Maven 3.8.x或3.9.x,并把<java.version>和<maven.compiler.source/target>都显式配成1.8。
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-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>4.4.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> </dependencies>2.2 存储层选型:MySQL表结构思路 + MinIO做文件存储
图片是捐赠项目的刚需,一个没有配图的项目在前台展示时非常没有说服力。文件存储我选了MinIO,它是开源的对象存储服务,用Docker一条命令就能起一个服务端:
docker run -p 9000:9000 -p 9001:9001 \ -e "MINIO_ROOT_USER=minioadmin" \ -e "MINIO_ROOT_PASSWORD=minioadmin123" \ -v /data/minio:/data \ minio/minio server /data --console-address ":9001"项目里只需要引入Java客户端,写一个配置类读取endpoint、accessKey、secretKey和bucketName,再提供一个上传文件的Service方法即可。这里有个关键点:要把文件的访问地址拼成完整的URL后再存数据库,不要在每次显示时临时拼接,否则一旦MinIO地址调整,历史数据全得重来。
2.3 中间件与扩展:Redis和消息队列,哪些是加分项
Redis在这个项目里不是必需品,但加了之后明显“有东西可讲”。我用来做两件事:一是缓存前端首页的项目列表,减少MySQL压力;二是把JWT的登出Token塞进黑名单,实现真正意义上的“退出登录失效”。
消息队列我用了Springboot原生事件机制——ApplicationEventPublisher,在捐赠成功后发布一个事件,异步去生成通知记录、更新统计缓存。为什么不直接上ActiveMQ或RocketMQ?因为这个项目的业务量根本用不上独立MQ,强行引入只会增加部署复杂度,答辩时还会被追问“你怎么保证消息不丢失”。用Spring事件机制解决业务解耦和异步化,语义明确、代码简洁,面试官反而会觉得你懂得“技术选型要匹配业务场景”。
3. 数据库设计:捐赠系统的核心表结构与关系梳理
数据库是整个项目的地基,我不建议先写代码再建表。正确的顺序是先设计表,再根据表写实体类,最后写接口。捐赠系统的核心表我最终收敛到6张:用户表、角色表、捐赠项目表、捐赠记录表、财务流水表、审计日志表。
3.1 用户、角色、权限三张核心表
用户和角色我采用了最简单的RBAC(基于角色的访问控制)模型,但没有单独建“角色-权限”关联表,而是把权限点直接写死在角色枚举里。这个决定是权衡过的:捐赠系统的角色只有用户、管理员(可扩展志愿者),权限粒度停留在“接口级别”,没必要做成通用权限框架。
t_user用户表的核心字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键自增 |
| username | varchar(50) | 登录名,唯一索引 |
| password | varchar(100) | BCrypt加密后的密码 |
| real_name | varchar(50) | 真实姓名,用于公示 |
| phone | varchar(20) | 手机号 |
| avatar | varchar(255) | 头像URL |
| status | tinyint | 0禁用 1正常 |
| create_time | datetime | 自动填充 |
t_role就两张表的事不用废话,但要注意:用户表里的role_id字段直接指向角色表主键,避免联查时多一次关联。角色表字段是id、role_name、role_code、description,其中role_code写成字符串枚举(ROLE_ADMIN/ROLE_USER),不要用数字去硬记,代码可读性会好很多。
3.2 捐赠项目表和捐赠记录表的设计细节
t_donation_project捐赠项目表是业务核心:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar(100) | 项目标题 |
| description | text | 项目详情 |
| cover_image | varchar(255) | 封面图 |
| target_type | tinyint | 1金额 2物资 |
| target_amount | decimal(12,2) | 目标金额(物资类可为0) |
| target_quantity | int | 目标物资数量(金额类可为0) |
| current_amount | decimal(12,2) | 当前已捐金额 |
| current_quantity | int | 当前已捐件数 |
| status | tinyint | 0草稿 1审核中 2进行中 3已结束 4已下架 |
| create_by | bigint | 创建人 |
| audit_by | bigint | 审核人 |
| audit_time | datetime | 审核时间 |
| start_time | datetime | 开始时间 |
| end_time | datetime | 结束时间 |
t_donation_record捐赠记录表:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| project_id | bigint | 项目ID |
| user_id | bigint | 捐赠人ID |
| donate_type | tinyint | 1金额 2物资 |
| amount | decimal(12,2) | 捐赠金额 |
| quantity | int | 物资数量 |
| goods_name | varchar(100) | 物资名称(金额捐赠可为空) |
| order_no | varchar(64) | 订单号,唯一 |
| status | tinyint | 0待支付 1已支付 2已公示 3已取消 |
| create_time | datetime | 捐赠时间 |
为什么一定要有order_no订单号?因为模拟支付回调、后续对账、用户查询都要靠它做幂等,没有唯一业务订单号,后面统计和追溯会乱成一锅粥。
3.3 财务流水与审计日志:保证数据可追溯
t_fund_flow财务流水表记录每一笔资金(或物资)的进出,字段包括:id、flow_no、project_id、record_id、type(1收入 2退款 3支出)、amount、quantity、balance_after、remark、create_time。
这个表最大的价值在于“可对账”——管理员看到某个项目的累计金额,可以直接从流水表里按项目汇总核对,如果对不上,就是哪条记录出了问题。这种设计在答辩时非常加分,因为老师随便提一句“你怎么保证数据准确性”,你就能拿出完整的追溯链路。
t_audit_log审计日志表也很必要,字段是id、user_id、operation、target_type、target_id、detail、ip、create_time。我用一个Spring AOP切面统一记录增删改操作,不需要在业务代码里到处埋点。
4. 核心功能实现:从登录鉴权到捐赠下单闭环
4.1 JWT登录鉴权与权限拦截
登录接口的设计遵循“无状态”原则:用户输入账号密码后,服务器校验通过就签发一个JWT,前端后续请求都带上这个Token。
JWT工具类中我用HS256算法,密钥放在配置文件中,有效期设置2小时:
public class JwtUtil { private static final String SECRET = "your-secret-key"; public static String createToken(Long userId, String roleCode) { return JWT.create() .withClaim("userId", userId) .withClaim("role", roleCode) .withExpiresAt(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) .sign(Algorithm.HMAC256(SECRET)); } public static DecodedJWT verify(String token) { return JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token); } }拦截器里做三件事:从Header取出Token并校验、解析出userId和role、把用户信息放入ThreadLocal。Controller层需要管理员身份的地方,用自定义注解@RequireRole("ROLE_ADMIN")标记,再配合一个AOP切面校验角色,代码会比在每个接口里手写判断干净得多。
这里有一个值得注意的细节:登录成功后不要频繁重新签发Token,否则用户同时打开两个页面会出现“一个页面被另一个页面顶掉”的诡异问题。我的做法是固定2小时有效,快过期时让前端主动调用刷新接口。
4.2 捐赠流程:项目浏览到订单入账的完整链路
捐赠下单的时序逻辑如下:
- 用户提交捐赠请求(项目ID + 金额或物资信息)。
- 系统校验项目状态必须是“进行中”,且当前时间在起止时间范围内。
- 生成唯一订单号(时间戳 + 用户ID + 随机数),订单初始状态为“待支付”。
- 返回模拟支付参数(本项目直接跳转到支付确认页面)。
- 用户点击“确认支付”,模拟回调接口被执行,状态改为“已支付”。
- 事务内更新项目
current_amount,写入财务流水,记录进入待公示列表。 - 发布Spring事件,异步发送通知并刷新Redis项目缓存。
核心Service方法的实现思路:
@Transactional(rollbackFor = Exception.class) public DonationRecord donate(DonateRequest request, Long userId) { DonationProject project = projectMapper.selectById(request.getProjectId()); // 状态校验 if (project.getStatus() != 2) { throw new BusinessException("项目当前不可捐赠"); } // 生成订单号 String orderNo = generateOrderNo(userId); DonationRecord record = new DonationRecord(); record.setProjectId(project.getId()); record.setUserId(userId); record.setAmount(request.getAmount()); record.setOrderNo(orderNo); record.setStatus(0); // 待支付 recordMapper.insert(record); return record; } @Transactional(rollbackFor = Exception.class) public boolean confirmPay(String orderNo) { DonationRecord record = recordMapper.selectByOrderNo(orderNo); if (record == null || record.getStatus() != 0) { return false; } record.setStatus(1); // 已支付 recordMapper.updateById(record); // 更新项目当前捐赠金额 projectMapper.increaseCurrentAmount(record.getProjectId(), record.getAmount()); // 写财务流水 fundFlowMapper.insert(buildIncomeFlow(record)); // 发布异步事件 eventPublisher.publishEvent(new DonationSuccessEvent(record)); return true; }关键点在于confirmPay必须加事务。如果你不加事务,一旦“更新项目金额”这步失败,订单状态已经是“已支付”了,用户钱(虽然是模拟的)没了但项目金额没变,数据就脏了。整个流程要么全部成功,要么全部回滚,这是财务类业务的基础要求。
4.3 数据统计接口:用什么姿势让图表有数据
统计模块我提供了三个核心接口:捐赠趋势、项目排行、类型分布。
捐赠趋势的SQL核心是“按天分组求和”:
SELECT DATE(create_time) AS day, SUM(amount) AS total_amount, COUNT(*) AS donate_count FROM t_donation_record WHERE status IN (1, 2) AND create_time BETWEEN #{startTime} AND #{endTime} GROUP BY DATE(create_time) ORDER BY day这里要注意的是status IN (1, 2)这个过滤条件——只统计“已支付”和“已公示”的记录,待支付的订单不应该计入任何统计,否则前端图表和后台数据都对不上。
项目完成率是current_amount / target_amount,前端拿到数据后自己算百分比的,后端只返回原始分子分母。理由是:百分比展示格式由前端负责,后端计算容易因为精度问题产生各种零头差异。
5. 角色权限与后台管理:管理员和志愿者的操作边界
5.1 基于角色控制的接口边界
我的权限拦截策略是:全局拦截器只负责登录校验,角色控制全部交给AOP注解。这样做的好处是新增接口时,只在自己Controller方法上标注角色即可,不用改全局配置。
志愿者角色我加了一个ROLE_VOLUNTEER,权限介于普通用户和管理员之间——可以审核部分物资信息、登记物资入库,但不能审核项目、不能查看财务流水导出。这个设计纯粹是从业务合理性出发,避免所有操作都依赖管理员一个人。
5.2 后台管理核心流程:项目审核和公示
项目审核是后台的重点。运营人员创建项目后,状态是“审核中”,管理员审核通过后变成“进行中”,同时要求填写audit_by和audit_time。这样任何一个上线的项目都能追到“是谁在什么时间审核的”,给审计留下证据。
公示环节我做了“公示节流”:后台会按批次生成公示单,把一段时间内已支付的记录标记为“已公示”。公示后的记录,前台用户就能在“爱心公示”页面看到自己的名字和捐赠信息。这个设计模拟了真实公益组织的运作方式——捐赠是即时的,但公开是定期的,既能满足透明度,又避免了手工逐条操作的麻烦。
5.3 审计日志:关键时刻救过我一命
原本加审计日志是为了答辩加分,但后来真的被它救过一次。测试阶段有个“管理员A将项目1下架”的操作,结果第二天项目1又出现在前台了。排查了一圈代码都没发现问题,最后翻审计日志,发现是另一个人登录管理员账号做了“重新上架”操作。如果没有日志,这条线上问题估计得排查到怀疑人生。
实现方式很简单,写一个AOP切面:
@Aspect @Component public class AuditLogAspect { @Around("@annotation(auditLog)") public Object log(ProceedingJoinPoint joinPoint, AuditLog auditLog) throws Throwable { Object result = joinPoint.proceed(); // 记录操作人、方法、参数、时间、IP auditLogMapper.insert(buildLog(joinPoint, auditLog, result)); return result; } }建议在@AuditLog("下架项目")注解里直接把操作描述写上,日志表里记录的就是人话而不是方法名,以后自己查日志也会省很多事。
6. 前后端对接与部署:Vue打包进Springboot的实践
6.1 前端选型与接口联调
前端我用的Vue2 + ElementUI(如果你习惯Vue3 + ElementPlus也一样,不影响后端联调逻辑)。开发阶段通过vite或vue-cli的代理把/api转发到http://localhost:8080,后端接口统一以/api开头,这样前后端联调时不需要处理跨域。
vue.config.js里配置代理:
module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } };6.2 Vue构建产物如何塞进Springboot
部署时最省事的方式是把前端打包后的dist目录直接放进Springboot的src/main/resources/static下,然后后端打成Fat Jar,一个jar包同时提供页面和API。
但这样会遇到一个经典的坑:Vue Router使用history模式时,直接访问/project/detail会404,因为Springboot只能按静态资源路径去找文件,找不到就返回404。解决方案有两种:
方案一(推荐):添加一个转发Controller,把非API路径全部转发到index.html:
@Controller public class SPAForwardController { @RequestMapping(value = {"/", "/project/**", "/admin/**", "/login", "/register"}) public String forward() { return "forward:/index.html"; } }方案二:改为使用hash模式路由——URL变成/#/project/detail,但观感上有瑕疵,我给甲方演示时都不太想用hash模式。
6.3 服务器部署与数据库初始化
生产部署我用的方案是:一台Linux服务器(2核4G足够),数据库用source init.sql初始化,MinIO同样在服务器上以Docker运行,后端直接裸跑Fat Jar:
nohup java -jar donation-system.jar --spring.profiles.active=prod > app.log 2>&1 &生产环境的配置要特别留意三件事:
- 数据库账号密码、JWT密钥、MinIO凭证不要硬编码在代码里,用
application-prod.yml覆盖,并确保该文件不上传到公开仓库。 - MySQL连接串加
useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8,时区问题不配好,时间字段会全部差8小时。 - 图片上传大小限制默认是1MB,如果你要支持更大的图片,必须在配置文件里调
multipart.max-file-size和max-request-size,这个我第一天就掉进坑里过。
7. 踩坑实录:我在这类项目里反复栽过的几个地方
7.1 金额计算不要用浮点数,一次就别用
最开始图省事,金额字段用了double,项目金额统计时偶尔会出现1234.560000000001这种离谱的精度误差。原因很简单:十进制小数在二进制浮点数中无法精确表示,多次运算后误差就积累出来了。
后来把所有金额字段全部改成BigDecimal,数据库用decimal(12,2),所有金额相关运算统一走BigDecimalAPI。有个更隐蔽的点是:从数据库查出BigDecimal后,不要用doubleValue()转成double去算,一旦转换精度就丢了;要用add/subtract/multiply,除法要指定精度divide(amount, 2, RoundingMode.HALF_UP)。
7.2 并发捐赠时的金额累加问题:从超捐到乐观锁
上线测试时我用脚本模拟了50个用户同时给同一个项目捐款,结果项目的current_amount比实际少了将近2000块。问题出在这一行:
project.setCurrentAmount(project.getCurrentAmount().add(amount)); projectMapper.updateById(project);两个事务同时读到current_amount=1000,都各自加100,后提交的覆盖前一个,数据就丢了100。这就是典型的“读改写”并发问题。
解决方案我用的是乐观锁:给t_donation_project表加一个version字段,更新时带上版本条件:
int rows = projectMapper.updateWithVersion(project.getId(), project.getCurrentAmount().add(amount), project.getVersion()); if (rows == 0) { throw new BusinessException("项目金额更新失败,请重试"); }Update SQL:
UPDATE t_donation_project SET current_amount = current_amount + #{amount}, version = version + 1 WHERE id = #{projectId} AND version = #{oldVersion}这里换了一种更稳妥的写法:让SQL自己完成“读取再加值”的原子操作,而不是先读到Java里再加再写回。加上版本号双重保障,并发问题被彻底解决。判断更新是否成功的行数,如果为0说明版本冲突,直接告诉用户重试即可。
7.3 MinIO文件访问地址失效导致图片裂开
项目上线后出现过一次:上午传的图片地址还能打开,下午就全部403。排查后发现是MinIO的bucket访问权限默认是私有,而生成的临时访问链接默认有效期只有7天(默认配置下其实有的客户端生成链接是2小时甚至更短)。我一开始走了弯路去调临时链接过期时间,后来想想不对——这是项目图片,本来就应该公开访问。
解决办法是在MinIO控制台(或通过代码)把对应bucket的访问策略设为公开,同时给文件生成统一规范的URL:
public String upload(MultipartFile file) { String fileName = UUID.randomUUID().toString() + getExt(file.getOriginalFilename()); minioClient.putObject(PutObjectArgs.builder() .bucket(bucketName) .object(fileName) .stream(file.getInputStream(), file.getSize(), -1) .contentType(file.getContentType()) .build()); return endpoint + "/" + bucketName + "/" + fileName; }这里还有一个小坑:文件名必须用UUID重命名,不要直接用用户上传的原始文件名。原因有两个,一是中文文件名会乱码,二是如果有两个用户上传了同名的demo.jpg,后面一个会直接覆盖前面那个,这种事故在真实项目里出现过不止一次。
7.4 分页查询里“总数”对不上——MyBatis-Plus分页插件的正确姿势
还有一个很隐蔽的坑:MyBatis-Plus的分页插件分页查询,如果返回类型是IPage<实体>没问题,但如果你把结果映射成Page<Map<String, Object>>,部分版本在join查询下total字段会变成0或者总页数异常。原因在于分页插件拦截的是外层SQL的COUNT,如果SQL里带了复杂的关联条件,COUNT有问题。
我的对策是:分页查询不搞复杂JOIN,主表信息先分页查,分页结束后再做关联补充。比如捐赠记录分页,先只查t_donation_record,再根据projectId和userId批量查出项目名称和用户昵称,set 回列表。这种“先分页后补全”的方式,能稳定避坑,MySQL的压力也更小。
写在最后
再做一次复盘的话,这个项目让我比较满意的部分其实不是某个花哨的接口,而是“先梳理清业务边界,再设计表结构,最后写代码”的顺序——爱心捐赠系统这种业务链路较长的选题,最容易翻车的地方就是从一开始就陷入CRUD细节,等写到统计模块才发现前面表结构设计得缺胳膊少腿。
如果你后续想把这个项目再往上抬一档,我建议从两个方向入手:一是把模拟支付替换成支付宝沙箱或微信支付沙箱环境,流程几乎不用改,但演示效果会本土化非常多;二是把前后端的部署集成做得更工程化,比如前端用Nginx托管、后端独立运行,再插一个Nginx反代配置,这套体系的能力外溢到企业项目里也完全够用。
最后再额外分享一个小技巧:给所有对外接口的返回结构统一用Result.success(data)/Result.error(code, msg),前端拿到后只判断code字段是否为200就行。这个小约定看似不起眼,但在前后端联调阶段能帮你省掉大量扯皮时间。