尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Spring Boot实现多人协作结算系统:从并发控制到规则引擎设计

Spring Boot实现多人协作结算系统:从并发控制到规则引擎设计 最近在开发一个社交类应用时遇到了一个非常典型的需求如何设计一个既有趣味性又能促进用户互动的“请客”或“分享”功能。这让我想起了线下聚会时常说的“喝博丽劲酒扣亲朋好友”——虽然这更像一句朋友间的玩笑话但它精准地捕捉到了社交活动中“发起者请客参与者共同承担或分享”的核心互动模式。在数字化产品中我们可以将这种模式抽象为“任务发起-多人参与-结果结算”的通用流程。本文将围绕如何从零开始设计并实现一个类似“AA制拼单”、“组队打卡”或“活动分摊”的多人协作与结算系统。无论你是想为小程序增加一个趣味活动功能还是为企业级应用开发一个费用分摊模块这套从产品设计、技术选型到代码落地的完整方案都能提供直接参考。我们将使用 Spring Boot 作为后端框架MySQL 作为数据库并提供一个清晰的前后端交互示例。1. 核心概念与业务场景分析在开始编码之前我们必须明确要构建的是什么。所谓“喝博丽劲酒扣亲朋好友”模式在互联网产品中通常对应以下场景1.1 核心业务模型这是一个典型的“一人发起多人参与事后结算”的模型。发起者创建一项活动如一次聚餐、一个拼单、一项挑战并设定目标总金额、任务目标等。亲朋好友参与者可以加入。活动结束后系统根据预设规则如平均分摊、按比例分摊、任务完成度分摊自动计算每个参与者应承担的份额并可能触发支付、积分扣除或任务完成状态更新。1.2 关键实体与关系活动(Activity)核心实体包含标题、描述、发起人、总目标、状态进行中/已结束、结算规则等。参与者(Participant)记录用户参与活动的信息包括参与状态、应承担份额、实际支付/完成状态等。用户(User)系统的用户可以是发起者或参与者。结算记录(Settlement)活动结束后生成的结算明细记录谁该付多少是否已支付。1.3 技术核心挑战并发控制多人同时参与同一活动时如何确保数据一致性如剩余名额、总额度。事务管理从活动创建、参与、到结算、状态更新涉及多个数据库操作需要保证事务性。规则引擎的灵活性结算规则可能复杂多变固定金额、百分比、动态计算需要设计可扩展的规则处理模块。通知系统活动状态变化如有人加入、活动截止、待支付需要及时通知相关用户。2. 技术栈与环境准备我们将构建一个前后端分离的简易系统来演示核心流程。2.1 后端环境JDK: 17 或 21 (推荐17长期支持版本)构建工具: Maven 3.6框架: Spring Boot 3.x数据库: MySQL 8.0持久层: MyBatis-Plus 3.5.x (简化CRUD操作)项目管理: 使用 Spring Initializr 创建项目依赖包括Spring Web,MyBatis Framework,MySQL Driver,Lombok。2.2 前端环境示例为了简化前端使用axios发起 HTTP 请求并用console展示结果。实际项目中可使用 Vue、React 等任何框架。确保已安装 Node.js 用于运行示例脚本非必须理解接口即可。2.3 数据库表设计我们先创建核心的三张表-- 用户表简化 CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT COMMENT 主键, username varchar(64) NOT NULL COMMENT 用户名, balance decimal(10,2) DEFAULT 0.00 COMMENT 账户余额用于模拟支付, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 活动表 CREATE TABLE activity ( id bigint NOT NULL AUTO_INCREMENT COMMENT 活动ID, title varchar(255) NOT NULL COMMENT 活动标题, description text COMMENT 活动描述, initiator_id bigint NOT NULL COMMENT 发起人ID, total_amount decimal(10,2) NOT NULL COMMENT 目标总金额/总任务量, rule_type varchar(50) NOT NULL COMMENT 结算规则类型AVERAGE(平均), PROPORTION(按比例), FIXED(固定金额), rule_config json DEFAULT NULL COMMENT 规则配置JSON格式如比例明细, status varchar(20) NOT NULL DEFAULT ONGOING COMMENT 状态ONGOING, ENDED, SETTLED, max_participants int DEFAULT NULL COMMENT 最大参与人数, current_participants int DEFAULT 0 COMMENT 当前参与人数, end_time datetime DEFAULT NULL COMMENT 活动截止时间, created_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_initiator (initiator_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT活动主表; -- 参与记录表 CREATE TABLE participant ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint NOT NULL COMMENT 活动ID, user_id bigint NOT NULL COMMENT 参与者ID, status varchar(20) DEFAULT JOINED COMMENT 状态JOINED, PAID, CANCELLED, share_amount decimal(10,2) DEFAULT NULL COMMENT 应分摊金额活动结束后计算, actual_paid decimal(10,2) DEFAULT NULL COMMENT 实际已支付金额, joined_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_activity_user (activity_id,user_id), -- 防止重复加入 KEY idx_activity (activity_id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT活动参与记录表;3. 后端核心代码实现我们按照功能模块来拆解代码。3.1 实体类定义使用 Lombok 简化代码。// 文件路径src/main/java/com/example/settlement/entity/User.java Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; private String username; private BigDecimal balance; } // 文件路径src/main/java/com/example/settlement/entity/Activity.java Data TableName(activity) public class Activity { TableId(type IdType.AUTO) private Long id; private String title; private String description; private Long initiatorId; private BigDecimal totalAmount; private String ruleType; // AVERAGE, PROPORTION, FIXED private String ruleConfig; // JSON字符串存储规则细节 private String status; // ONGOING, ENDED, SETTLED private Integer maxParticipants; private Integer currentParticipants; private LocalDateTime endTime; private LocalDateTime createdTime; } // 文件路径src/main/java/com/example/settlement/entity/Participant.java Data TableName(participant) public class Participant { TableId(type IdType.AUTO) private Long id; private Long activityId; private Long userId; private String status; // JOINED, PAID, CANCELLED private BigDecimal shareAmount; private BigDecimal actualPaid; private LocalDateTime joinedTime; }3.2 创建活动接口核心是处理并发和规则配置。// 文件路径src/main/java/com/example/settlement/controller/ActivityController.java RestController RequestMapping(/api/activity) RequiredArgsConstructor public class ActivityController { private final ActivityService activityService; PostMapping(/create) public ApiResponseLong createActivity(RequestBody Valid CreateActivityRequest request) { Long activityId activityService.createActivity(request); return ApiResponse.success(activityId); } } // 文件路径src/main/java/com/example/settlement/service/impl/ActivityServiceImpl.java Service Transactional RequiredArgsConstructor public class ActivityServiceImpl implements ActivityService { private final ActivityMapper activityMapper; Override public Long createActivity(CreateActivityRequest request) { // 1. 参数校验 if (request.getTotalAmount().compareTo(BigDecimal.ZERO) 0) { throw new BusinessException(总金额必须大于0); } // 2. 构建实体 Activity activity new Activity(); BeanUtils.copyProperties(request, activity); activity.setStatus(ONGOING); activity.setCurrentParticipants(0); // 初始为0 activity.setCreatedTime(LocalDateTime.now()); // 3. 保存到数据库 activityMapper.insert(activity); // 4. 发起人自动作为第一个参与者加入 joinActivity(activity.getId(), request.getInitiatorId()); return activity.getId(); } }3.3 参与活动接口这是“扣亲朋好友”的关键步骤需要处理并发加入和人数限制。// 文件路径src/main/java/com/example/settlement/service/impl/ActivityServiceImpl.java Override public void joinActivity(Long activityId, Long userId) { Activity activity activityMapper.selectById(activityId); if (activity null) { throw new BusinessException(活动不存在); } if (!ONGOING.equals(activity.getStatus())) { throw new BusinessException(活动已结束或关闭无法加入); } // 使用数据库乐观锁控制并发和人数限制 int updated activityMapper.updateParticipantCount(activityId, activity.getCurrentParticipants()); if (updated 0) { // 更新失败可能人数已满或数据已被修改 Activity latest activityMapper.selectById(activityId); if (latest.getCurrentParticipants() latest.getMaxParticipants()) { throw new BusinessException(活动参与人数已满); } else { throw new BusinessException(活动状态已变更请重试); } } // 检查是否已参与 LambdaQueryWrapperParticipant queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(Participant::getActivityId, activityId) .eq(Participant::getUserId, userId); if (participantMapper.selectCount(queryWrapper) 0) { throw new BusinessException(您已参与该活动); } // 创建参与记录 Participant participant new Participant(); participant.setActivityId(activityId); participant.setUserId(userId); participant.setStatus(JOINED); participant.setJoinedTime(LocalDateTime.now()); participantMapper.insert(participant); }对应的 Mapper 更新语句乐观锁!-- 文件路径src/main/resources/mapper/ActivityMapper.xml -- update idupdateParticipantCount UPDATE activity SET current_participants current_participants 1 WHERE id #{activityId} AND current_participants #{oldCount} AND (max_participants IS NULL OR current_participants max_participants) AND status ONGOING /update3.4 活动结算接口活动结束后根据规则计算每人应付金额。这是业务逻辑最复杂的地方。// 文件路径src/main/java/com/example/settlement/service/SettlementService.java public interface SettlementService { void settleActivity(Long activityId); } // 文件路径src/main/java/com/example/settlement/service/impl/SettlementServiceImpl.java Service Transactional RequiredArgsConstructor public class SettlementServiceImpl implements SettlementService { private final ActivityMapper activityMapper; private final ParticipantMapper participantMapper; private final UserMapper userMapper; Override public void settleActivity(Long activityId) { Activity activity activityMapper.selectByIdForUpdate(activityId); // 使用悲观锁防止重复结算 if (!ENDED.equals(activity.getStatus())) { throw new BusinessException(活动尚未结束无法结算); } // 获取所有参与者 ListParticipant participants participantMapper.selectList( new LambdaQueryWrapperParticipant().eq(Participant::getActivityId, activityId) .eq(Participant::getStatus, JOINED) ); if (participants.isEmpty()) { throw new BusinessException(没有待结算的参与者); } // 根据规则类型计算分摊金额 MapLong, BigDecimal shareMap calculateShare(activity, participants); // 更新参与者的分摊金额 for (Participant p : participants) { BigDecimal share shareMap.get(p.getUserId()); p.setShareAmount(share); participantMapper.updateById(p); } // 更新活动状态为已结算 activity.setStatus(SETTLED); activityMapper.updateById(activity); } private MapLong, BigDecimal calculateShare(Activity activity, ListParticipant participants) { MapLong, BigDecimal result new HashMap(); String ruleType activity.getRuleType(); BigDecimal totalAmount activity.getTotalAmount(); int participantCount participants.size(); switch (ruleType) { case AVERAGE: // 平均分摊 BigDecimal average totalAmount.divide(BigDecimal.valueOf(participantCount), 2, RoundingMode.HALF_UP); participants.forEach(p - result.put(p.getUserId(), average)); break; case PROPORTION: // 按比例分摊比例信息从 ruleConfig JSON 中解析 // 示例{userId1: 0.5, userId2: 0.3, userId3: 0.2} JSONObject config JSON.parseObject(activity.getRuleConfig()); for (Participant p : participants) { BigDecimal ratio config.getBigDecimal(p.getUserId().toString()); if (ratio null) { throw new BusinessException(用户 p.getUserId() 的分摊比例未配置); } result.put(p.getUserId(), totalAmount.multiply(ratio).setScale(2, RoundingMode.HALF_UP)); } break; case FIXED: // 固定金额金额信息从 ruleConfig JSON 中解析 // 示例{userId1: 100.00, userId2: 50.00} JSONObject fixedConfig JSON.parseObject(activity.getRuleConfig()); for (Participant p : participants) { BigDecimal fixedAmount fixedConfig.getBigDecimal(p.getUserId().toString()); if (fixedAmount null) { throw new BusinessException(用户 p.getUserId() 的固定金额未配置); } result.put(p.getUserId(), fixedAmount); } // 校验总金额是否匹配 BigDecimal sumFixed result.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); if (sumFixed.compareTo(totalAmount) ! 0) { throw new BusinessException(固定金额总和与活动总金额不匹配); } break; default: throw new BusinessException(不支持的结算规则类型: ruleType); } return result; } }4. 前端交互示例这里提供一个使用axios的纯 JavaScript 示例展示如何调用上述接口。!-- 文件路径frontend-demo.html -- !DOCTYPE html html head title活动结算系统演示/title script srchttps://cdn.jsdelivr.net/npm/axios/dist/axios.min.js/script /head body h2活动结算系统前端演示/h2 div h31. 创建活动/h3 button onclickcreateActivity()创建一次平均分摊的聚餐活动/button p idcreateResult/p /div div h32. 参与活动/h3 活动ID: input typenumber idactivityId placeholder输入上面创建的活动ID 用户ID: input typenumber iduserId placeholder输入参与者用户ID button onclickjoinActivity()参与活动/button p idjoinResult/p /div div h33. 结算活动/h3 button onclicksettleActivity()结算活动/button p idsettleResult/p /div script const API_BASE http://localhost:8080/api; async function createActivity() { const request { title: 周末团队聚餐, description: 庆祝项目上线人均分摊, initiatorId: 1, totalAmount: 600.00, ruleType: AVERAGE, ruleConfig: null, maxParticipants: 10 }; try { const resp await axios.post(${API_BASE}/activity/create, request); document.getElementById(createResult).innerHTML 活动创建成功活动ID: strong${resp.data.data}/strong; } catch (error) { document.getElementById(createResult).innerHTML 创建失败: ${error.response?.data?.message || error.message}; } } async function joinActivity() { const activityId document.getElementById(activityId).value; const userId document.getElementById(userId).value; if (!activityId || !userId) { alert(请填写活动ID和用户ID); return; } try { // 假设参与接口为 /api/activity/{activityId}/join/{userId} await axios.post(${API_BASE}/activity/${activityId}/join/${userId}); document.getElementById(joinResult).innerHTML 用户${userId}成功参与活动${activityId}; } catch (error) { document.getElementById(joinResult).innerHTML 参与失败: ${error.response?.data?.message || error.message}; } } async function settleActivity() { const activityId document.getElementById(activityId).value; if (!activityId) { alert(请填写活动ID); return; } try { // 假设结算接口为 /api/settlement/activity/{activityId} await axios.post(${API_BASE}/settlement/activity/${activityId}); document.getElementById(settleResult).innerHTML 活动${activityId}结算完成请查看数据库participant表的share_amount字段。; } catch (error) { document.getElementById(settleResult).innerHTML 结算失败: ${error.response?.data?.message || error.message}; } } /script /body /html5. 常见问题与排查思路在实际开发和线上运行时你可能会遇到以下问题问题现象可能原因排查思路与解决方案创建活动失败提示“总金额必须大于0”前端传入的totalAmount为 null、0 或负数。1. 前端校验输入框确保金额大于0。2. 后端CreateActivityRequest添加Min(value 0.01, message“总金额必须大于0”)注解。参与活动时提示“活动参与人数已满”活动设置了max_participants且当前人数已达到上限。1. 在活动详情页实时显示剩余名额。2. 优化updateParticipantCountSQL使用更精确的乐观锁条件。多人同时点击“参与”按钮出现超卖人数超过限制高并发下单纯的“查询-判断-插入”逻辑存在竞态条件。必须使用数据库锁或分布式锁。本文示例使用了乐观锁updateParticipantCount生产环境高并发场景可考虑使用SELECT ... FOR UPDATE悲观锁或 Redis 分布式锁。结算时金额出现小数精度问题如 33.333333使用float/double类型存储金额或除法运算未指定精度。1. 数据库金额字段使用decimal(10,2)。2. Java 中使用BigDecimal进行运算并指定舍入模式setScale(2, RoundingMode.HALF_UP)。规则配置rule_configJSON解析出错前端传入的 JSON 格式不正确或与规则类型不匹配。1. 在后端增加 JSON 格式校验。2. 为不同的ruleType定义不同的配置类使用JsonTypeInfo进行反序列化。活动结束后仍有用户支付成功结算状态与支付状态未做联动校验。在支付回调逻辑中增加对活动状态的判断if(activity.status ! ‘SETTLED’) { throw … }。6. 最佳实践与工程建议将这样一个功能投入生产环境需要考虑的远不止基础CRUD。6.1 数据库设计优化索引activity表的initiator_id,status字段participant表的(activity_id, user_id)唯一索引activity_id,user_id单独索引都是查询优化关键。分表分库当活动数据和参与记录量极大时如千万级可按activity_id或创建时间进行分表。字段冗余在participant表中冗余activity_title、initiator_name等字段避免频繁联表查询。6.2 服务架构与性能读写分离结算、查询操作频繁可将读请求路由到从库。缓存应用活动详情、参与者列表等热点数据可存入 Redis设置合理过期时间。异步处理结算过程可能较复杂尤其是涉及外部支付网关时。可以将结算请求放入消息队列如 RabbitMQ, Kafka由消费者异步处理并通过 WebSocket 或推送服务通知用户结果。规则引擎抽象将AVERAGE,PROPORTION等规则抽象为策略模式方便未来扩展“按任务完成度分摊”等新规则。6.3 安全与风控权限校验确保用户只能操作自己发起的活动或只能加入公开活动。在 Controller 层或 Service 层使用PreAuthorize或自定义注解进行校验。参数校验使用Valid和Jakarta Validation注解进行入参校验防止非法数据入库。幂等性设计参与、结算等接口要支持幂等调用防止网络重试导致重复参与或重复结算。可以为每个请求生成唯一业务流水号。金额安全所有金额运算必须使用BigDecimal并在最终落库前进行四舍五入或向上取整确保分毫不差。6.4 可观测性与监控关键日志记录活动创建、用户参与、结算开始/结束、支付回调等关键节点日志便于问题追踪。业务指标监控监控活动创建成功率、参与成功率、结算失败率、平均结算耗时等。告警对结算失败、参与人数异常增长等情况设置告警。从“喝博丽劲酒扣亲朋好友”这个生动的场景出发我们完成了一个多人协作与结算系统的核心设计与实现。这个系统麻雀虽小却涵盖了 Web 开发中常见的并发控制、事务管理、业务规则处理、前后端交互等关键技术点。你可以在此基础上继续扩展支付集成、消息通知、数据分析看板等功能将其打造成一个完整的社交电商或团队协作工具。
返回列表