ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot公益募捐系统设计:资金监管与全流程追溯实战

SpringBoot公益募捐系统设计:资金监管与全流程追溯实战 每年这个时候都会有大量同学在选题阶段纠结觉得公益募捐系统被做烂了没有新意。但说句实在话作为一个从选题、设计到答辩都完整带过这个项目的过来人我反而觉得SpringBoot公益募捐系统是毕业设计里性价比极高的选择——业务模型清晰、技术栈主流、演示效果好而且你完全可以用“资金监管”和“全流程可追溯”这两个点做出差异化。这篇文章我把整个项目从零到一的完整思路、表结构设计、核心接口实现、以及那些只有踩过坑才知道的细节全部拆开来讲希望给正在做这个题目的同学节省几个星期的摸索时间。先说清楚这套系统到底做了什么。整体是基于SpringBoot Vue前后端分离架构的公益募捐平台核心参与者有三类普通用户、公益组织管理员、平台运营管理员。普通用户可以浏览公益项目、发布求助信息、在线捐赠并查看自己的捐赠记录公益组织可以提交项目、发起募捐、申请拨款并上传项目进展平台管理员负责审核项目资质、监管资金流向、查看全平台的数据统计和审计日志。资金这块我做了双账本设计——用户捐赠产生“捐赠流水”平台拨款产生“拨付记录”每一分钱都能从用户钱包一路追踪到项目最终使用明细这也是答辩时最能让评委眼前一亮的点。1. 项目整体设计与技术选型思路1.1 业务需求到底有哪些很多同学拿到这类题目就直接开写代码结果做到一半发现角色权限乱了、业务流程断了返工到怀疑人生。正确的第一步是把需求先理清楚。我把这个系统的业务梳理成四条主链路后面所有开发都围绕它们展开。第一条是项目发布与审核链路公益组织登录后填写项目资料包括项目名称、详情描述、目标金额、起止时间、资质证明图片等提交后进入“待审核”状态。平台管理员在后台可以看到所有待审核项目审核通过后项目上架展示审核不通过则退回并附上原因。这里要特别注意的是公益项目涉及公信力问题所以必须具备“募捐中-已结束-已拨款-已完成”的完整生命周期管理而不是简单的上架下架。第二条是用户捐赠链路用户浏览项目列表和详情选择捐赠金额后生成订单对接微信/支付宝模拟支付流程支付成功后系统自动更新项目已筹金额、写入捐赠流水、生成电子捐赠证书并在项目详情页实时累加显示。这链路里最核心的一点是金额的并发更新处理后面我会详细说。第三条是资金拨付与监管链路当项目募捐期结束且金额达标公益组织可以向平台提交拨款申请上传使用计划或发票材料。平台管理员审核通过后执行拨款系统记录一条资金拨付记录同时更新项目的“已拨款金额”。每次拨款后用户在前端项目详情页都能看到“已筹金额-已拨付金额-剩余待拨付金额”的实时数据这就是“资金透明”的核心功能点。第四条是平台统计与分析链路管理员端需要展示全平台累计筹款总额、捐赠用户数、在募项目数、今日新增捐赠等核心指标用折线图和柱状图展示平台近30天的捐款趋势和各公益领域的占比分布。这个模块看似简单但它是体现系统价值的重要窗口也容易被评委拿来提问所以数据统计的SQL要提前写好测试好。1.2 为什么选SpringBoot这套技术组合技术选型不能光看“哪个流行就选哪个”而是要能说出理由。对于这种典型的业务管理系统SpringBoot 2.7.x MyBatis-Plus 3.5.x MySQL 8.0 Redis Vue 2 Element UI 的组合是我实际使用了比较顺手也最稳妥的方案。SpringBoot的自动配置机制让项目搭建成本极低你只需要引入spring-boot-starter-web、spring-boot-starter-validation、spring-boot-starter-data-redis这几个核心依赖就能得到一个可运行的基础服务不需要像SSH时代那样写一堆XML配置文件。MyBatis-Plus则把单表CRUD、条件构造器、分页查询这些高频操作全部封装好了开发效率至少提升50%这对于毕业设计需要在有限时间内出成果的场景来说非常关键。前端选择Vue 2 Element UI理由是社区资料最多、踩坑答案最全、上手门槛最低。如果你做前后端分离直接使用vue-element-admin的简化版本或者自己搭一个轻量的Vue脚手架都行。后端这一侧的三个关键配置我在下面列一下针对不同的使用场景可以直接参考。# application.yml 核心配置片段 server: port: 8080 servlet: context-path: /api spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/commonweal_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 redis: host: localhost port: 6379 database: 0 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里有个实际项目里容易忽略的细节就是context-path一定要配置。前后端分离项目里如果后端接口路径没有统一前缀前端请求代理的配置会比较麻烦而且部署到服务器后和静态资源的路径容易冲突。我通常统一用/api作为前缀前端配一个baseURL: /api既方便区分也方便后续加网关或拦截器。1.3 数据库设计是这类系统的成败关键公益募捐系统不比普通的CRUD练习它涉及金额、涉及多方角色、涉及审核流转表结构如果设计得不合理后面写业务代码时会处处碰壁。我最终的核心表大概有12张这里挑六张最重要的说明设计意图。用户表t_user包含id、username、passwordBCrypt加密存储、real_name、phone、id_card脱敏展示、user_type1普通用户、2公益组织、3管理员、status、created_time。这里有一个容易被忽略的点公益组织上传的资质材料证件照我单独放了一张t_org_cert表而不是直接塞在用户表里因为一个人可能名下挂多个组织也可能之后扩展子账号体系。项目表t_project是整张业务的地基字段包括id、org_id发起组织、title、cover_image、detail富文本、target_amount、raised_amount、donate_count、status0草稿、1待审核、2募捐中、3已结束、4已拨款、5已完成、audit_remark、start_time、end_time、create_time。设计时raised_amount和donate_count我采用了冗余字段的方式在每次捐赠成功后更新这两个字段。这是典型的“空间换时间”思路避免每次查项目列表都动用聚合查询对列表页性能提升非常明显。捐赠订单表t_donation字段相对简单id、user_id、project_id、order_no业务订单号、amount、pay_type1微信、2支付宝、pay_status0待支付、1已支付、2已退款、donate_time。订单号我强烈建议不要用数据库自增ID而是自己生成一个类似20240601203000123456的规则年月日时分秒用户ID后四位随机数四位这样既保证唯一性又带业务含义查询时还能根据时间范围截断快速筛选。资金拨付表t_disbursement记录的是每一笔项目拨款id、project_id、org_id、apply_user_id、amount、purpose用款说明、voucher_url票据材料、audit_status0待审、1通过、2驳回、audit_user_id、audit_remark、apply_time、audit_time。这表在答辩演示“资金监管”时会频繁被打开所以要提前准备好两条真实的多状态测试数据。项目审核记录表t_audit_log和操作日志表t_operate_log是容易被忽略但实际很有用的表。前者记录每个项目每一次审核的意见和结果后者记录管理员的关键操作包括哪个账号在什么时间干了什么事。系统上线后排查问题时这两张表能帮你省去大量沟通成本。答辩时你只要提一句“系统具备完整的审计追溯能力”评委基本都会点头认可。2. 核心功能模块的拆分与实现2.1 三种角色到底怎么控制权限角色权限这块我是用SpringBoot拦截器 自定义注解来做的没有引入Spring Security或者Shiro这种重量级框架。原因很简单这个系统的角色只有三种、接口量也不大用轻量方案更可控、更便于演示时讲清楚逻辑。具体做法是定义一个RequireRole注解标注在Controller方法上然后配置一个AuthInterceptor来统一校验。前端在用户登录后把token和用户信息存起来后端在需要鉴权的接口上加上注解拦截器里判断当前登录用户的角色是否匹配。下面是我在项目里实际使用的核心代码。// 自定义角色校验注解 Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RequireRole { String[] value(); }// 登录拦截器核心逻辑 Component public class AuthInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } // 非Controller方法直接放行如静态资源 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; // 检查方法上是否有RequireRole注解 RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { return true; } // 从Header中获取token String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !token.startsWith(Bearer )) { return handleUnauthorized(response, 未登录或token为空); } String realToken token.substring(7); // 查询Redis中是否存在该token String userJson redisTemplate.opsForValue().get(login:token: realToken); if (userJson null) { return handleUnauthorized(response, 登录已过期请重新登录); } // 解析用户信息判断角色 UserDTO userDTO JSON.parseObject(userJson, UserDTO.class); String[] requiredRoles requireRole.value(); boolean pass false; for (String role : requiredRoles) { if (role.equals(userDTO.getUserType())) { pass true; break; } } if (!pass) { response.setStatus(403); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无权限访问\}); return false; } // 将用户信息存储到ThreadLocal方便后续获取当前用户 UserContext.set(userDTO); return true; } // 省略辅助方法... }如果你决定用这套方案请一定注意一个顺序问题拦截器里要先放行OPTIONS预检请求。前后端分离项目里前端发POST请求或带自定义Header的GET请求时浏览器会先发一个OPTIONS请求试探如果你在拦截器里把这个预检请求拦截了前端一律会报跨域错误。这个坑我帮好几个同学排查过最后发现就是拦截器没有放行OPTIONS肉眼极难看出来。2.2 公益项目发布与审核状态机设计思路公益项目的状态流转是这个系统里业务逻辑最复杂的部分我参考了实际电商系统的订单状态机模式来做设计而不是简单地用一个状态字段加一堆if。每个项目在任何一个时间点都只会处于一个明确的状态状态之间的转换是被严格限制的。我把状态定义成草稿(0)、待审核(1)、募捐中(2)、已结束(3)、已拨款(4)、已完成(5)。其中草稿只有公益组织自己能看到可以反复修改提交后进入待审核管理员审核通过则进入募捐中驳回则回到草稿并附带驳回原因募捐中到期后自动变为已结束已结束后组织可以发起拨款申请审核通过并拨款后进入已拨款所有拨款都完成后组织上传最终结项报告系统标记整个项目为已完成。这套状态机用代码实现其实不复杂关键是两个点一是非法状态流转必须封死比如你不能让一个“募捐中”的项目直接变成“已完成”那样资金没结清就结束项目显然是错的二是在状态变更的同时要写入审计日志保证每次操作都有迹可循。为了让演示更清晰我实现了一个统一的状态流转方法核心逻辑放在下面。// 项目状态流转核心方法 public synchronized Boolean changeProjectStatus(Integer projectId, Integer fromStatus, Integer toStatus, String operatorId, String remark) { // 校验当前状态是否符合预期 Project project projectMapper.selectById(projectId); if (project null || !project.getStatus().equals(fromStatus)) { throw new BusinessException(项目状态已变更请刷新后再操作); } // 执行状态更新 Project update new Project(); update.setId(projectId); update.setStatus(toStatus); int rows projectMapper.updateById(update); if (rows 0) { throw new BusinessException(项目状态更新失败); } // 写入审核日志 AuditLog log new AuditLog(); log.setProjectId(projectId); log.setOperatorId(operatorId); log.setFromStatus(fromStatus); log.setToStatus(toStatus); log.setRemark(remark); auditLogMapper.insert(log); return true; }同时因为募捐中的项目有结束时间我用了SpringBoot自带的Scheduled定时任务每分钟扫描一次把所有“已达结束时间但状态还是募捐中”的项目批量更新为已结束。定时任务的实现很简单但上线时要注意时间区问题服务器如果是UTC时间那所有时间判断都会差8小时这类问题在日常开发里很常见但查起来非常费时间。我都是在数据库连接串里指定serverTimezoneAsia/Shanghai并且在代码里所有时间格式化也都显式指定东八区。2.3 捐赠流程与资金流水双写保证数据一致捐赠是高频操作也是最容易出bug的地方。用户点完捐赠后后端要做的事包括生成订单、调支付接口demo里我会模拟或对接沙箱环境、支付回调、更新订单状态、增加项目已筹金额、增加捐款人数、写流水、生成证书涉及的用户和项目数据分布在多张表里。这里最核心的问题是项目已筹金额的更新绝对不能简单地先查出来再加回去因为高并发下会出现经典的丢失更新问题。比如两个用户同时各捐100元假设当前已筹是1000两个请求同时读到1000各自加100后都写回1100那么本该是1200的金额就变成了1100有100元凭空蒸发了。解决方式我用了两种乐观锁和数据库原子更新双保险。第一种方式是在项目表加version字段更新时带上旧的version条件。UPDATE t_project SET raised_amount raised_amount #{amount}, version version 1 WHERE id #{projectId} AND version #{oldVersion}如果影响行数为0就重试。第二种方式更直接用数据库自带的行级原子更新相当于直接把“读取并修改”这个操作交给数据库的锁机制去保证。我最终采用了原子更新方式因为实现更简单、性能更好同时配合事务保证多表操作的一致性代码如下。// 捐赠下单核心逻辑 Transactional(rollbackFor Exception.class) public DonationResult createDonation(DonationRequest request) { Long userId UserContext.get().getId(); Long projectId request.getProjectId(); BigDecimal amount request.getAmount(); // 参数校验金额必须大于0项目必须处于募捐中 if (amount.compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(捐赠金额必须大于0); } Project project projectMapper.selectById(projectId); if (project null || project.getStatus() ! 2) { throw new BusinessException(项目不存在或不在募捐期内); } // 防超募目标金额-已筹金额必须大于本次捐赠金额 BigDecimal remain project.getTargetAmount().subtract(project.getRaisedAmount()); if (remain.compareTo(amount) 0) { throw new BusinessException(该项目剩余可募捐金额不足); } // 生成订单号yyyyMMddHHmmss 用户ID后四位 4位随机数 String orderNo generateOrderNo(userId); // 保存捐赠订单此时状态为待支付 Donation donation new Donation(); donation.setUserId(userId); donation.setProjectId(projectId); donation.setAmount(amount); donation.setOrderNo(orderNo); donation.setPayStatus(0); donationMapper.insert(donation); // 模拟调用支付网关返回支付参数 // 实际项目中这里会返回一个微信支付/支付宝支付的收银台参数 return new DonationResult(orderNo, amount); } // 支付成功回调处理方法 Transactional(rollbackFor Exception.class) public Boolean paySuccessCallback(String orderNo) { Donation donation donationMapper.selectByOrderNo(orderNo); if (donation null || donation.getPayStatus() ! 0) { return false; } // 更新订单为已支付 Donation update new Donation(); update.setId(donation.getId()); update.setPayStatus(1); update.setDonateTime(LocalDateTime.now()); donationMapper.updateById(update); // 原子更新项目已筹金额和捐赠人数 projectMapper.increaseRaisedAmount(donation.getProjectId(), donation.getAmount()); projectMapper.increaseDonateCount(donation.getProjectId()); // 写入资金流水 FundFlow flow new FundFlow(); flow.setProjectId(donation.getProjectId()); flow.setOrderNo(orderNo); flow.setType(1); // 1捐赠流入、2拨款流出 flow.setAmount(donation.getAmount()); flow.setRelatedId(donation.getId()); fundFlowMapper.insert(flow); // 生成捐赠证书编号 String certNo CERT- orderNo; donationCertMapper.insert(certNo, donation.getId()); return true; }这里有个细节值得提一下increaseRaisedAmount这个SQL我用的大括号里面是raised_amount raised_amount #{amount}刻意没有在代码里先select金额再update这样彻底绕开了并发问题。另外订单状态从“待支付”到“已支付”的更新我也加了pay_status 0作为条件即使支付平台连续回调多次结果也只会成功一次这在接口幂等设计里是非常常见的做法面试官也喜欢问。3. 实操过程从零搭建到核心代码落地3.1 项目搭建与目录结构设计有了设计之后搭建环节其实很快。我用的是Spring Initializr创建基础工程JDK选择1.8依赖选了Web、MySQL Driver、Redis、Validation、Lombok然后手动引入MyBatis-Plus和Hutool工具包。由于很多同学用IDEA创建SpringBoot项目时会遇到依赖下载慢的问题我建了个内网镜像源配置实测能明显加速依赖下载。目录结构我建议按业务分包而不是按技术分层分包也就是“先按模块拆、后按层拆”。具体来说就是controller、service、mapper、entity这些基础包都在最外层然后每个业务模块内部自己建包或者通过类名前缀区分。对于这个项目来说模块有用户、项目、捐赠、资金、审核、统计、文件上传、消息通知每个模块的Service接口和ServiceImpl分开写这个习惯到真实工作中也非常加分。com.commonweal ├── controller // 接口层 │ ├── AuthController.java │ ├── ProjectController.java │ ├── DonationController.java │ ├── DisbursementController.java │ └── StatisticsController.java ├── service │ ├── ProjectService.java │ └── impl │ └── ProjectServiceImpl.java ├── mapper │ ├── ProjectMapper.java │ └── DonationMapper.java ├── entity │ ├── Project.java │ ├── Donation.java │ └── ... ├── config │ ├── MybatisPlusConfig.java │ ├── RedisConfig.java │ ├── WebMvcConfig.java │ └── CorsConfig.java ├── common │ ├── Result.java // 统一返回体 │ ├── BusinessException.java │ └── GlobalExceptionHandler.java ├── interceptor │ └── AuthInterceptor.java ├── annotation │ └── RequireRole.java └── util ├── JwtUtil.java └── OrderNoUtil.javaController层只负责参数接收和结果封装核心业务逻辑全部沉到Service层。一开始图省事的同学很容易把SQL查询和判断逻辑全写在Controller里后面一旦业务复杂起来改都改不动所以这个分包习惯尽量从一开始就建立。3.2 核心表建表SQL拆解我把用户、项目、捐赠订单、资金流水四张核心表的建表语句贴出来方便直接拿来用。金额字段统一用DECIMAL(10,2)绝对不会出现Double那种精度漂移问题。所有表都带create_time和update_time方便排查问题而且统一用deleted做逻辑删除避免物理删除导致关联数据凭空消失。CREATE TABLE t_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密密码, real_name varchar(50) DEFAULT NULL COMMENT 真实姓名或组织名称, phone varchar(20) DEFAULT NULL, avatar varchar(255) DEFAULT NULL, user_type tinyint NOT NULL DEFAULT 1 COMMENT 1普通用户 2公益组织 3管理员, status tinyint NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, deleted tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT用户表; CREATE TABLE t_project ( id bigint NOT NULL AUTO_INCREMENT, org_id bigint NOT NULL COMMENT 发起组织用户ID, title varchar(100) NOT NULL COMMENT 项目标题, cover_image varchar(255) DEFAULT NULL COMMENT 封面图URL, detail text COMMENT 项目详情富文本, target_amount decimal(10,2) NOT NULL COMMENT 目标金额, raised_amount decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT 已筹金额, donate_count int NOT NULL DEFAULT 0 COMMENT 捐赠人数, status tinyint NOT NULL DEFAULT 0 COMMENT 0草稿 1待审核 2募捐中 3已结束 4已拨款 5已完成, audit_remark varchar(255) DEFAULT NULL COMMENT 最近一次审核意见, start_time datetime DEFAULT NULL COMMENT 募捐开始时间, end_time datetime DEFAULT NULL COMMENT 募捐结束时间, 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 (status), KEY idx_org_id (org_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT公益项目表; CREATE TABLE t_donation ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(64) NOT NULL COMMENT 商户订单号, user_id bigint NOT NULL, project_id bigint NOT NULL, amount decimal(10,2) NOT NULL COMMENT 捐赠金额, pay_type tinyint DEFAULT 1 COMMENT 1微信 2支付宝, pay_status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已退款, donate_time datetime DEFAULT NULL COMMENT 捐赠完成时间, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_project_id (project_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT捐赠订单表; CREATE TABLE t_fund_flow ( id bigint NOT NULL AUTO_INCREMENT, project_id bigint NOT NULL COMMENT 项目ID, order_no varchar(64) DEFAULT NULL COMMENT 关联单号捐赠订单号或拨款申请单号, type tinyint NOT NULL COMMENT 1捐赠流入 2拨款流出, amount decimal(10,2) NOT NULL, related_id bigint DEFAULT NULL COMMENT 关联表主键ID, remark varchar(255) DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_project_id (project_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_general_ci COMMENT资金流水表;需要注意的一点不管是捐赠订单的donate_time还是流水表的create_time都别用timestamp类型项目里统一用datetime因为timestamp有2038年的上限问题而且datetime在业务查询和展示时更直观不容易被时区问题干扰。字段上能加注释就尽量加注释答辩时直接展示数据库设计文档思路会清晰很多。3.3 资金监管模块怎么保证每一笔钱都能追踪资金监管是这个项目区别于普通“玩具系统”的核心亮点。我在系统里设计了两条追踪链路一条是从“用户 → 捐赠订单 → 项目资金池”另一条是“项目资金池 → 拨款申请 → 拨款记录 → 使用凭证”所有金额变动都落到一张t_fund_flow表里这样就可以随时查一个项目的资金全景。为了保证监管可信度我加了一个“资金对账”的约束项目的已筹金额 所有有效捐赠单金额总和项目的已拨付金额 所有已通过拨款申请金额总和项目的可拨付余额 已筹金额 - 已拨付金额。也就是说公益组织永远不可能申请超出当前已筹金额的拨款因为如果那样项目的可拨付余额就会变成负数这是绝对不允许出现的。我把这个校验逻辑放在拨款申请的服务方法里用数据库行锁的方式保证并发下不会超拨。// 拨款申请的核心校验逻辑 Transactional(rollbackFor Exception.class) public Boolean applyDisbursement(DisbursementRequest request, Long orgId) { // 使用行级锁查询项目锁定该行直到事务结束 Project project projectMapper.selectByIdForUpdate(request.getProjectId()); if (project null || !project.getOrgId().equals(orgId)) { throw new BusinessException(项目不存在或无权操作); } if (project.getStatus() ! 3 project.getStatus() ! 4) { throw new BusinessException(项目当前状态不允许发起拨款申请); } BigDecimal usableAmount project.getRaisedAmount().subtract(project.getDisbursedAmount()); if (request.getAmount().compareTo(usableAmount) 0) { throw new BusinessException(申请金额超出可拨付余额当前可拨余额为 usableAmount); } // 插入拨款申请记录状态为待审核 Disbursement disbursement new Disbursement(); disbursement.setProjectId(project.getId()); disbursement.setOrgId(orgId); disbursement.setAmount(request.getAmount()); disbursement.setPurpose(request.getPurpose()); disbursement.setVoucherUrl(request.getVoucherUrl()); disbursement.setAuditStatus(0); disbursementMapper.insert(disbursement); // 记录资金流水type2 表示拨款流出此处为申请冻结不是实际拨款 // 实际拨款完成后才更新项目的disbursed_amount字段 return true; }你要注意这里的selectByIdForUpdate它是SELECT ... FOR UPDATE的MyBatis-Plus写法作用是给项目这一行记录加了悲观锁。这样即使两个拨款申请同时发起第二个请求也必须等第一个事务提交后才能读到最新数据。毕业设计里用悲观锁虽然简单直接但在实际生产环境高并发场景下还是要谨慎使用锁的持有时间越短越好。管理员审核通过拨款申请后系统会执行实际拨款操作更新项目的disbursed_amount字段同时把拨款单状态改成“已通过”。这样用户在前端看到的数据永远是对账后的一致数据。3.4 数据统计与可视化报表的实现统计报表模块如果要做到好看好用核心是聚合SQL不能写错。我在统计接口里用了MyBatis-Plus的Wrapper加自定义SQL结合的方式。比如平台近30天每日捐赠趋势核心SQL就是按天分组求和公益领域分布占比则是通过项目分类字段分组统计。为了查询性能所有统计接口都走了独立的Mapper方法没有在主项目列表查询里嵌套统计。我还顺带做了一个定时任务每天凌晨把前一天的核心指标计算出来存到一张t_statistics_daily表。这样前端首页加载时直接查这张表就能拿到昨天的累计数据不需要实时聚合大表响应速度会快很多也方便以后做数据周报月报。如果你想让答辩更有亮点可以用ECharts做一个大屏展示页把数据可视化效果直接投到屏幕上视觉效果非常加分的。4. 常见问题与排查技巧实录4.1 分页插件不生效MyBatis-Plus分页有个特性分页功能默认是不开启的需要手动配置一个PaginationInnerInterceptor。很多同学一开始没配这个结果Page对象查出来total一直是0或者报错找不到方言。配置其实很简单在配置类里注入一个MybatisPlusInterceptor添加分页拦截器并指定数据库类型为MySQL即可。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setOverflow(false); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }这个setMaxLimit(500L)是防止有人恶意查超大页码导致数据库压力过大日常开发和答辩演示都够用了。分页查询的参数传参方式也要注意前端传过来的页码不能是字符串要转成Integer类型我遇到过因为前端传了pageNum: 1导致分页SQL参数类型不匹配查询直接报错的情况。4.2 金额精度丢失这是所有涉及钱的系统最容易踩的坑。如果你在实体类里面用Double或者Float表示金额那么数据库里DECIMAL(10,2)存的是精确值一旦转成Java的Double就可能出现0.1 0.2 0.30000000000000004这种经典问题。捐赠金额、拨款金额、已筹金额这三个字段我全部用BigDecimal表示并且构造时一律用字符串构造器禁止直接用BigDecimal.valueOf(0.1)这种可能引发精度问题的方式。还有一个小地方很多人不会注意数据库查询出来的DECIMAL在BigDecimal之间比较时不要用equals因为1.0和1.00在equals眼里是不相等的要使用compareTo方法判断大小。在订单金额校验和拨款余额判断时我都统一用compareTo否则会踩到金额比较永远不相等这种隐蔽的坑。4.3 事务失效的几种情况SpringBoot的事务用起来很方便标注Transactional就能生效但有三类场景会导致事务静默失效我在这个项目里都实际碰到过。第一种是方法自调用一个类中的方法A调用同类中的方法BB上有TransactionalB的事务不会生效因为事务是通过代理对象调用的自调用绕过了代理。解决方法是把需要事务的方法放到另一个Service里或者使用AopContext.currentProxy()获取当前代理对象再调用。第二种是捕获了异常没有抛出。事务回滚的前提是异常从方法中抛出而且Transactional默认只对RuntimeException和Error回滚如果你方法里catch住了异常然后吞掉或者返回了一个false事务是不会回滚的。第三种是数据库存储引擎问题MySQL的MyISAM不支持事务只有InnoDB引擎才支持建表时如果忘记指定引擎就会用到默认的MyISAM事务完全失效。我所有的建表SQL都明确带了ENGINEInnoDB就是不想在这个细节上翻车。4.4 并发超募与重复支付处理在演示阶段如果你现场让评委体验捐款功能大概率是多个人或多窗口同时操作这时候就很容易触发并发问题。我在设计时做了双重保障。第一是在项目表里加了校验逻辑在创建订单之前检查剩余金额是否充足这个校验是“软判断”只是提前挡住明显不可能的请求。第二次硬校验则发生在支付回调时同样校验项目状态和剩余金额确保极端情况下也不会出现超募。第二是订单表对order_no建了唯一索引同一笔捐赠请求如果重复提交第二个插入会直接报唯一键冲突从根上杜绝了重复订单的产生。支付回调的幂等处理我也做了首先查询订单号是否已经处于已支付状态如果是就直接返回成功不再执行后续的金额更新和流水写入。这样即使支付平台因为网络原因多次回调业务数据也不会重复累加。4.5 前端部署与后端联调的一些坑前后端分离项目的联调阶段最容易出现跨域问题。我在后端写了一个CorsConfig配置类允许所有来源跨域访问并放行了所有请求头和请求方法。但后来发现了一个隐蔽问题如果配置了拦截器跨域预检请求OPTIONS必须优先放行否则浏览器层面会直接拦截前端看不到具体的接口报错信息只能看到红色的网络错误。还有一个和文件上传相关的问题。公益组织要提交资质证书和项目图片上传接口需要对文件类型和大小做限制。我在接收文件的接口里先判断了文件的扩展名白名单包括jpg、jpeg、png、gif、pdf大小限制是5MB。如果超出了限制就直接返回“文件类型不支持或文件过大”避免系统被恶意上传大文件拖垮。如果你想让文件走CDN或其他存储服务建议将存储的逻辑抽象成一个StorageService接口本地文件和云存储各自实现一个类后续切换非常方便。5. 系统上线前的测试清单与演示准备技巧5.1 功能自测时容易漏掉的检查点快答辩前的一周我整理了一张自测清单把系统里最容易出问题的地方都过了一遍。第一条是用三种不同角色的账号分别登录检查各自看到的菜单和能访问的接口是否有越权。第二条是走一遍完整的项目生命周期创建一个草稿项目、提交审核、管理员通过、用户捐赠、项目到期、组织发起拨款、管理员拨款通过、组织上传结项报告、项目标记完成每一步都要截一张图。第三条是验证金额数据的正确性手工计算某项目已筹金额是否等于所有有效捐赠单之和已拨付金额是否等于所有通过拨款单之和一旦对不上就要查流水表定位问题。第四条是刷新浏览器缓存后检查登录态和页面数据是否正常。这四条我建议你也按这个顺序做一遍。很多同学在演示中途出现自己从未预料到的bug绝大多数都是因为测试不充分而不是系统本身有多复杂。5.2 答辩时怎么演示资金监管资金监管是整个系统的最大亮点在答辩演示时我强烈建议按这个顺序来。打开管理员后台的资金流水页面展示“类型、金额、关联单号、时间”这些字段然后随机点开一条捐赠流水切换到用户端的“我的捐赠”页面找到同一笔订单号证明两条数据是对应的。再点开一个已拨款项目的详情页先展示已筹金额和已拨付金额两个数字然后打开拨款明细列表把每笔拨款的金额加起来等于已拨付金额这个“加总对账”的演示动作非常有说服力。如果你还有富余时间可以展示一下“项目详情页”的公示内容把每次拨款对应的票据材料图展现出来让评委直观感受到这个系统的透明度。有些评委对“公信力”这个点特别感兴趣所以你可以准备一个话术本系统从用户捐赠到项目拨款之间建立了一条可追溯的资金链路每一笔资金变动都有对应的流水记录和审核记录任何一笔资金都可以从订单号反查到完整的业务上下文。5.3 答辩时可能会被追问的问题在我带过的项目里评委问得最多的几个问题分别是为什么用Redis存储登录态而不是直接用JWT项目并发量大了之后资金监管方案还能不能支撑如果支付回调一直不成功怎么办多组织同时发起拨款申请会互相影响吗这几个问题本质上都是在考察你对“基础原理”和“真实业务场景”的理解深度。以第一个问题为例你需要说清楚Redis的过期时间控制、服务端主动注销能力、以及分布式场景下的会话共享优势。以第三个问题为例你要说清楚定时任务兜底、未支付订单的主动关单、以及支付回调的幂等设计。其实你不需要回答得多么高深只要能解释清楚自己系统的取舍逻辑评委就已经认可了。6. 项目后续可以扩展的方向这个系统的核心业务模型已经跑通了如果想在拿到“优秀毕业设计”的基础上更进一步有几个方向值得尝试。第一个是引入消息队列把“支付成功后的发证书、写流水、更新项目金额”这个链路改成异步解耦用本地消息表加定时任务的方式保证最终一致性这个设计在生产系统中非常常见。第二个是接入真实的微信支付或支付宝支付沙箱环境替代模拟支付逻辑这样系统就更贴近真实上线状态但需要注意申请支付接口的资质问题一般毕设阶段用沙箱环境就够了。第三个是增加项目评论与互动功能用户在捐款后可以对项目进行评价和留言公益组织可以回复增加社区属性和用户粘性。如果学有余力还可以把数据统计模块做成一个“资金透明驾驶舱”大屏用柱状图、折线图、饼图实时展示全平台数据放在演示环节的开场能直接抓住评委的注意力。我亲眼见过好几个学生靠这个亮点拿了优秀答辩每次演示时下面一片闪光灯。根据我自己的经验这个题目的核心竞争力不在于做了多少花哨的功能而在于每一笔钱能不能说清楚来龙去脉。所以核心设计思路就一句话用实打实的“全流程可追溯”的资金流转记录把公信力做成系统的金字招牌。要做到这一点你只需要把项目状态流转、权限控制、并发扣减、资金流水这四条线全部走通整个系统就已经远超了“应付毕业设计”的水平。代码不在多而在每条链路都经得起追问。祝你这个项目写得顺手答辩顺利。
返回列表