粉丝见面会活动报名系统实战教程从需求拆解到部署上线很多开发者在接到“活动报名”“粉丝见面会购票”这类需求时第一反应是找一个现成的报名平台。但实际业务里常常会遇到活动场次变动、会员优先购、现场签到核销、座位分区、问卷收集等一系列定制化需求通用平台很难完全覆盖。本文用一个“Sweet Honeymoon Party Fan Meeting”粉丝见面会活动为业务背景从零搭建一套适合中小型活动使用的报名与场次管理系统。文章会完整展示数据库设计、后端接口、管理命令、核销流程和部署注意事项适合刚接触全栈项目或正在做课程设计的开发者参考。在开始阅读之前需要说明一点本文涉及的日期、场次、地名均为演示数据实际开发时你需要替换成自己的业务配置。这套系统的核心思路是“活动—场次—报名—核销”四层模型理解了这个模型你就能把它延伸到演唱会、展会、技术沙龙、亲子活动等各种场景。1. 背景与核心概念活动报名系统到底在管理什么1.1 业务场景还原假设我们接到这样一个需求某艺人团体将在 2026 年 8 月 8 日举办一场名为“Sweet Honeymoon Party”的粉丝见面会主办方希望提供一个线上报名页面粉丝可以在页面上选择不同价位的票种填写个人信息并完成报名。报名成功后主办方可以导出名单现场通过扫码或输入编号核销入场。这种需求看起来简单实际落地会遇到几个问题。第一“活动”和“场次”的关系。一些小型活动只有一场但见面会可能会分下午场和晚场需要分开管理库存。第二票种和库存。VIP票、普通票数量不同报满后要自动关闭。第三核销。报名记录需要与现场核销状态关联防止一张票多次入场。第四扩展性。未来可能要增加问卷收集、会员折扣等功能数据结构如果设计得不够灵活后续改动成本会很高。1.2 系统核心模型一个稳定的活动报名系统建议抽象出以下几层。活动Event最高层级代表一次完整的活动包含活动名称、日期、海报图、状态。场次Session活动下的具体演出或见面会时间段一场活动可以有多个场次。票种TicketType每个场次下的票价类型包含价格、剩余数量、总库存。报名记录Registration用户报名后生成的一条记录关联场次和票种。核销记录CheckIn现场核销时产生的时间戳、操作人等信息。这种设计的核心好处是如果活动只有单场你只需要创建一个场次如果后续增加城市站或加场不需要改动现有表结构。1.3 开发者需要掌握什么这类项目很适合作为 Web 后端入门到进阶的练手项目因为它涵盖了常规 CRUD 之外的真实业务难点库存扣减、唯一约束、状态流转、并发控制、数据导出。通过本文你将掌握如何将业务需求转化为数据表结构基于 Spring Boot MyBatis-Plus 实现基础接口如何防止超卖、重复报名如何实现最简单的现场核销闭环项目上线前需要关注的配置与安全问题。文中所有代码示例以教学目的为主为了便于理解部分代码做了简化处理。生产环境需要根据实际并发量做相应加强。2. 环境准备与版本说明2.1 开发环境本文的示例代码基于以下环境编写但这是一套非常通用的技术组合工具用途JDK 8 或 11 以上Java 运行环境Maven 3.6项目构建工具Spring Boot 2.x 或 3.xWeb 开发框架MyBatis-Plus数据访问增强框架MySQL 5.7 或 8.x主数据库H2 Database本地测试备用数据库版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。如果你使用的是 Spring Boot 3.x需要将 javax 包名替换为 jakarta其他核心逻辑基本不变。2.2 创建项目基础结构推荐使用 Spring Initializr 创建项目依赖选择Spring WebMySQL DriverLombok可选用于简化实体类创建完成后项目结构如下fan-meeting-system/ └── src/main/java/com/example/fanmeeting/ ├── FanMeetingApplication.java ├── controller/ │ ├── EventController.java │ ├── RegistrationController.java │ └── CheckInController.java ├── entity/ │ ├── Event.java │ ├── Session.java │ ├── TicketType.java │ └── Registration.java ├── mapper/ │ ├── EventMapper.java │ ├── SessionMapper.java │ ├── TicketTypeMapper.java │ └── RegistrationMapper.java ├── service/ │ ├── EventService.java │ ├── RegistrationService.java │ └── CheckInService.java └── common/ └── Result.java没有强制使用 Service 接口 Impl 结构是为了减少代码文件数量方便初学者阅读。实际企业项目中可根据团队规范进行分层。2.3 application.yml 基础配置在src/main/resources/application.yml中写入基础数据源配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/fan_meeting?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里简单解释两个关键配置项。map-underscore-to-camel-case用于将数据库下划线字段映射为 Java 驼峰字段例如ticket_type_id自动映射为ticketTypeId避免手写繁琐的映射规则。逻辑删除配置是为了不物理删除报名记录安全性和可追溯性更高。比如用户取消报名时我们只是将deleted字段设为 1后台仍可查历史数据。3. 数据库设计与核心表结构3.1 设计思路在创建表之前先画出一张简单的关联关系图。event(活动) └── session(场次) └── ticket_type(票种) └── registration(报名记录) └── check_in(核销记录)使用 ASCII 描述可以更清晰地表达层级关系这也是开发中常用的梳理方式。一个活动下有多个场次一个场次下有多个票种用户报名成功后生成报名记录现场核销后写入核销记录。所有外键关系都在子表中通过业务 ID 进行关联。3.2 SQL 建表脚本CREATE DATABASE IF NOT EXISTS fan_meeting DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE fan_meeting; -- 活动表 CREATE TABLE event ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, event_name VARCHAR(200) NOT NULL COMMENT 活动名称, event_date DATE NOT NULL COMMENT 活动日期, cover_url VARCHAR(500) COMMENT 活动海报地址, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-报名中2-已结束3-已取消, deleted TINYINT NOT NULL DEFAULT 0 COMMENT 逻辑删除0-未删除1-已删除, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 活动表; -- 场次表 CREATE TABLE session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL COMMENT 所属活动ID, session_name VARCHAR(200) NOT NULL COMMENT 场次名称如下午场, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-可报名0-关闭, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 场次表; -- 票种表 CREATE TABLE ticket_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT 所属场次ID, ticket_name VARCHAR(100) NOT NULL COMMENT 票种名称如VIP票, price DECIMAL(10, 2) NOT NULL COMMENT 票价, total_count INT NOT NULL COMMENT 总库存, remain_count INT NOT NULL COMMENT 剩余库存, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 票种表; -- 报名记录表 CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT 场次ID, ticket_type_id BIGINT NOT NULL COMMENT 票种ID, fan_name VARCHAR(100) NOT NULL COMMENT 报名人姓名, fan_phone VARCHAR(20) NOT NULL COMMENT 联系电话, fan_email VARCHAR(100) COMMENT 邮箱, order_no VARCHAR(64) NOT NULL COMMENT 报名单号, ticket_code VARCHAR(64) NOT NULL COMMENT 核销码, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1-待核销2-已核销3-已取消, remark VARCHAR(500) COMMENT 备注, deleted TINYINT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_ticket_code (ticket_code) ) COMMENT 报名记录表; -- 核销记录表 CREATE TABLE check_in ( id BIGINT PRIMARY KEY AUTO_INCREMENT, registration_id BIGINT NOT NULL COMMENT 报名记录ID, check_in_time DATETIME NOT NULL COMMENT 核销时间, operator_name VARCHAR(100) COMMENT 操作人, remark VARCHAR(500) COMMENT 核销备注 ) COMMENT 核销记录表;这里需要注意几个设计要点。第一ticket_code设置了唯一索引。这个字段就是现场出示的核销凭证我们要求全局唯一避免用户串场或重复生成。第二order_no唯一。这是报名单号通常由程序生成例如日期 随机数。第三每张表都带deleted逻辑删除标记。虽然会增加 SQL 编写复杂度但对运营类系统来说保留数据永远比删除数据更安全。3.3 为什么不用视图或 JSON 字段很多初学者会直接把票种、问卷字段以 JSON 形式存在活动表里这样可以减少关联查询。但在报名场景中票种关系到库存扣减需要用独立表加行锁来保证准确性如果把数组塞到一个 JSON 字段里扣库存时将无法使用数据库行锁只能靠应用层同步机制处理并发一高就容易出问题。核心建议需要做“数量控制”和“独立状态管理”的数据不要放进 JSON 字段。4. 后端核心代码实现4.1 实体类编写以 Event 实体为例// 文件路径src/main/java/com/example/fanmeeting/entity/Event.java package com.example.fanmeeting.entity; import com.baomidou.mybatisplus.annotation.*; import lombok.Data; import java.time.LocalDate; import java.time.LocalDateTime; Data TableName(event) public class Event { TableId(type IdType.AUTO) private Long id; private String eventName; private LocalDate eventDate; private String coverUrl; private Integer status; TableLogic private Integer deleted; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; }注意TableLogic注解是 MyBatis-Plus 逻辑删除的核心标注之后MyBatis-Plus 生成的 select 查询会自动追加AND deleted 0。Registration 实体建议单独设计一个查询 DTO因为报名列表需要展示场次名和票种名而这些字段在 registration 表中不存在。可以通过联表查询或组装 VO 来实现。4.2 库存扣减的核心实现库存扣减是报名系统最关键的环节。假如用户同时点击报名而剩余票只有 1 张如果实现不当可能会超卖。比较推荐的做法是使用 SQL 层面的条件更新// 文件路径src/main/java/com/example/fanmeeting/service/RegistrationService.java public boolean deductStock(Long ticketTypeId) { // 乐观扣减库存更新成功则影响行数为1说明库存充足 int rows ticketTypeMapper.deductStock(ticketTypeId); return rows 0; }对应的 Mapper XML 或注解 SQL 为Update(UPDATE ticket_type SET remain_count remain_count - 1 WHERE id #{ticketTypeId} AND remain_count 0) int deductStock(Long ticketTypeId);关键点在于WHERE条件中加入了remain_count 0。这样即使同一时刻有 10 个并发请求数据库行锁会排队执行最终只有剩余库存大于 0 的那些请求能更新成功从根源上避免超卖。但是仅扣减库存还不够。如果库存扣减成功后报名记录插入失败就会出现“票没了但用户没报名成功”的数据不一致问题。因此需要把“扣减库存”和“创建报名记录”放在同一个事务中。// 文件路径src/main/java/com/example/fanmeeting/service/RegistrationService.java Transactional(rollbackFor Exception.class) public Result createRegistration(RegistrationRequest request) { // 1. 校验场次和票种是否可报名 Session session sessionMapper.selectById(request.getSessionId()); TicketType ticketType ticketTypeMapper.selectById(request.getTicketTypeId()); if (session null || ticketType null) { return Result.error(场次或票种不存在); } if (session.getStatus() ! 1) { return Result.error(该场次已关闭报名); } // 2. 扣减库存 boolean success deductStock(request.getTicketTypeId()); if (!success) { return Result.error(该票种已售罄); } // 3. 生成唯一报名单号和核销码 Registration registration new Registration(); registration.setSessionId(request.getSessionId()); registration.setTicketTypeId(request.getTicketTypeId()); registration.setFanName(request.getFanName()); registration.setFanPhone(request.getFanPhone()); registration.setFanEmail(request.getFanEmail()); registration.setRemark(request.getRemark()); String orderNo generateOrderNo(); registration.setOrderNo(orderNo); registration.setTicketCode(generateTicketCode()); // 4. 插入报名记录如果失败则抛出异常触发回滚 registrationMapper.insert(registration); return Result.success(报名成功, registration); }Transactional保证了如果第 4 步插入失败第 2 步的库存扣减会被回滚不会出现一边扣票一边丢失订单的情况。4.3 防止重复报名重复报名是指同一个手机号在同一场次下重复提交。最简单的处理方式是在数据库层对session_id和fan_phone加联合唯一索引。可以补充一个唯一索引ALTER TABLE registration ADD UNIQUE KEY uk_session_phone (session_id, fan_phone);但这里有一个业务判断问题如果用户第一次报名成功后又取消了是否允许重新报名如果允许那么取消报名时不能物理删除记录因为一删掉唯一索引约束就消失了。所以推荐取消状态为 3保留记录。用户再次报名前程序会检测是否已存在未删除且状态不是取消的记录。这种通过数据库约束兜底、程序提前校验的方式是防止重复下单的常用手段。4.4 核销接口实现核销流程通常发生在线下。粉丝到达现场后出示报名成功后生成的核销码工作人员输入或扫码系统将状态从“待核销”变为“已核销”。// 文件路径src/main/java/com/example/fanmeeting/service/CheckInService.java Transactional(rollbackFor Exception.class) public Result checkIn(String ticketCode, String operatorName) { // 1. 查询报名记录 LambdaQueryWrapperRegistration wrapper new LambdaQueryWrapper(); wrapper.eq(Registration::getTicketCode, ticketCode) .eq(Registration::getDeleted, 0); Registration registration registrationMapper.selectOne(wrapper); if (registration null) { return Result.error(核销码不存在请确认报名是否成功); } // 2. 判断状态 if (registration.getStatus() 2) { return Result.error(该票已核销请勿重复入场); } if (registration.getStatus() 3) { return Result.error(该报名记录已取消无法入场); } // 3. 更新报名状态 registration.setStatus(2); registrationMapper.updateById(registration); // 4. 写入核销记录 CheckIn checkIn new CheckIn(); checkIn.setRegistrationId(registration.getId()); checkIn.setCheckInTime(LocalDateTime.now()); checkIn.setOperatorName(operatorName); checkInMapper.insert(checkIn); return Result.success(核销成功, registration); }这里将状态更新与核销记录插入放在同一事务中。如果写核销记录失败状态更新也会回滚避免工作人员扫完码后系统报了成功但实际没留痕。4.5 导出报名名单运营人员经常需要导出 Excel 名单。这里提供一个简化思路先从数据库查出数据再使用 EasyExcel 或 POI 写出文件。示例以 CSV 格式为例因为 CSV 对运维最简单、不依赖额外包、Excel 也能打开。// 文件路径src/main/java/com/example/fanmeeting/controller/ExportController.java GetMapping(/export/{sessionId}) public void exportSessionRegistrations(PathVariable Long sessionId, HttpServletResponse response) throws IOException { ListRegistration registrations registrationMapper.selectBySessionId(sessionId); response.setContentType(text/csv;charsetUTF-8); response.setCharacterEncoding(UTF-8); // 指定 BOM防止 Excel 打开中文乱码 response.getWriter().write(\ufeff); StringBuilder sb new StringBuilder(); sb.append(报名单号,核销码,姓名,手机号,状态,报名时间\n); for (Registration reg : registrations) { String statusText ; if (reg.getStatus() 1) { statusText 待核销; } else if (reg.getStatus() 2) { statusText 已核销; } else if (reg.getStatus() 3) { statusText 已取消; } sb.append(reg.getOrderNo()).append(,) .append(reg.getTicketCode()).append(,) .append(reg.getFanName()).append(,) .append(reg.getFanPhone()).append(,) .append(statusText).append(,) .append(reg.getCreateTime()).append(\n); } response.getWriter().write(sb.toString()); }这段代码里有一个细节值得注意CSV 文件带 UTF-8 BOM 头\ufeff否则 Windows 上的 Excel 打开时中文会乱码。很多开发者明明导出了数据客户却说乱码多半是少了这一步。5. 前端页面与控制台命令示例5.1 管理端命令设计在实际项目中很多运营配置并不需要做网页用命令行工具或 Postman 就可以完成。为了快速演示可以编写一个简单的 CommandLineRunner启动时自动向数据库写入演示数据。// 文件路径src/main/java/com/example/fanmeeting/config/DataInitializer.java package com.example.fanmeeting.config; import com.example.fanmeeting.entity.*; import com.example.fanmeeting.mapper.*; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; Component RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { private final EventMapper eventMapper; private final SessionMapper sessionMapper; private final TicketTypeMapper ticketTypeMapper; Override public void run(String... args) { // 防止重复初始化 Long eventCount eventMapper.selectCount(null); if (eventCount ! null eventCount 0) { return; } Event event new Event(); event.setEventName(Sweet Honeymoon Party Fan Meeting); event.setEventDate(LocalDate.of(2026, 8, 8)); event.setStatus(1); eventMapper.insert(event); Session session new Session(); session.setEventId(event.getId()); session.setSessionName(Honeymoon Special Show); session.setStartTime(LocalDateTime.of(2026, 8, 8, 19, 30)); session.setEndTime(LocalDateTime.of(2026, 8, 8, 21, 30)); session.setStatus(1); sessionMapper.insert(session); TicketType vipTicket new TicketType(); vipTicket.setSessionId(session.getId()); vipTicket.setTicketName(Sweet VIP Pass); vipTicket.setPrice(new BigDecimal(880.00)); vipTicket.setTotalCount(50); vipTicket.setRemainCount(50); ticketTypeMapper.insert(vipTicket); TicketType normalTicket new TicketType(); normalTicket.setSessionId(session.getId()); normalTicket.setTicketName(Honey Standard Pass); normalTicket.setPrice(new BigDecimal(380.00)); normalTicket.setTotalCount(200); normalTicket.setRemainCount(200); ticketTypeMapper.insert(normalTicket); } }启动项目后访问http://localhost:8080数据库会自动生成演示数据。这一步可以大幅缩短联调时间。5.2 报名接口测试使用 curl 模拟用户报名curl -X POST http://localhost:8080/api/registration \ -H Content-Type: application/json \ -d { sessionId: 1, ticketTypeId: 1, fanName: Fan Alice, fanPhone: 13800138000, fanEmail: aliceexample.com }如果报名成功返回结果中会包含报名单号orderNo和核销码ticketCode运营人员可以把核销码提前发给粉丝。接着模拟现场核销curl -X POST http://localhost:8080/api/checkin \ -H Content-Type: application/json \ -d { ticketCode: TM2026080800123456, operatorName: gate-01 }正常会返回“核销成功”。再次提交同样的核销码应该返回“该票已核销请勿重复入场”。5.3 PC 管理端页面简化示例如果需要一个极简管理页面可以写一个 Thymeleaf 模板或直接用静态 HTML 调用后端接口。由于篇幅原因这里展示一个最基础的 HTML 搜索页放在src/main/resources/static/index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleFan Meeting CheckIn/title /head body h3现场核销页面/h3 input typetext idticketCode placeholder请输入核销码 / button onclickcheckIn()核销/button pre idresult/pre script async function checkIn() { const code document.getElementById(ticketCode).value; const response await fetch(/api/checkin, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({ticketCode: code, operatorName: web-gate}) }); const data await response.json(); document.getElementById(result).textContent JSON.stringify(data, null, 2); } /script /body /html实际业务中核销页面还需要接入权限系统至少要确保只有工作人员才能访问。不要小看这个限制公开的核销接口很容易被恶意刷码或遍历。6. 常见问题与排查思路6.1 系统出现超卖剩余库存变成负数问题现象常见原因解决思路剩余票数变成负数扣库存时没有加库存条件判断将 UPDATE 语句改为SET remain_count remain_count - 1 WHERE remain_count 0同一手机号重复报名缺少数据库唯一索引增加(session_id, fan_phone)联合唯一索引并在 Service 层提前查询报名单号重复随机数生成策略碰撞使用 UUID 去横线或使用 Snowflake 算法不要只用时间戳核销后发现订单不存在报名事务未提交被并发读取核销操作与报名状态应处于同一事务并确认数据库隔离级别排查库存问题时最直接的方法是开启 MySQL 通用日志查看实时 SQL。在开发环境将 MyBatis-Plus 的log-impl设为 StdOutImpl控制台就会打印每一条 SQL方便核对扣减语句是否正确。6.2 报名成功但没有扣库存这个问题通常不在 SQL而在事务没有生效。常见原因有三种Service 类内部方法自调用导致Transactional注解失效。异常被 try-catch 捕获后没有重新抛出Spring 感知不到异常因此不触发回滚。方法不是 public或类没有被 Spring 管理。第一个问题经常出现在同一类中方法 A 调用方法 BB 上有事务注解。此时应该把事务方法拆分到另一个 Service 类中或者使用TransactionTemplate手动控制事务。这是很经典的坑如果业务中恰好碰到可以优先检查调用链。6.3 数据库连接超时或 Connection is not available报名系统通常在活动开始前一段时间访问量陡增如果应用默认数据库连接池太小就会出现连接等待超时。HikariCP 默认最大连接数为 10可以将它调大但不要无脑调成几千因为 MySQL 服务端也有并发上限。建议配置spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000同时可以在数据库端查询当前最大连接数SHOW VARIABLES LIKE max_connections;当连接池优化后还是超时需要排查是否为慢 SQL 长时间占用连接重点检查报名记录查询是否缺少索引。6.4 数据库需要加哪些索引以下是报名系统最常用的索引建议表名索引字段原因registrationticket_code核销时通过核销码查询registration(session_id, fan_phone)防止重复报名registration(session_id, ticket_type_id)按场次导出名单ticket_typesession_id查询某场次下的票种sessionevent_id查询某活动下的场次创建联合索引时建议把等值查询条件放在前面。MySQL 索引最左前缀原则决定了索引字段顺序会影响使用效率。上面的(session_id, fan_phone)与(session_id, ticket_type_id)分别服务于不同查询不要随意合并。7. 最佳实践与工程建议7.1 活动配置必须走后台不要写死在代码里很多开发者一开始把活动信息写在配置文件中例如把活动日期写在application.yml里。这样做确实能跑通但一旦活动改期、票价调整、场次增加就需要改代码重新发版效率极低。正确的做法是把活动、场次、票种全部作为数据表配置后台管理员可以在管理界面维护。如果某些配置如活动名称需要在前端展示建议单独做缓存不要在每次请求时都查数据库。7.2 核销接口的权限与防刷核销接口一旦暴露在公网就面临被刷的风险。建议从以下几方面加强接口必须要求管理员登录使用 Spring Security 或 Sa-Token 做认证。核销请求增加验签或临时令牌机制防止第三方直接构造请求。同一个 ticketCode 连续请求失败 5 次临时锁定该 IP。操作日志记录操作人、时间、IP方便事后审计。如果是在局域网内使用可以只做 IP 白名单。但如果你把核销页面部署在公网服务器上那么权限控制是必须项不是可选优化项。7.3 库存扣减与状态流转的最终一致性本文演示的是单库事务方案适合中小型活动。如果未来活动规模变大拆分为微服务报名服务和票务服务分库就需要考虑分布式事务或消息队列。一种常见的降级方案是先将报名请求写入本地消息表然后通过定时任务/消息队列通知票务服务扣库存票务服务处理成功后更新报名状态。虽然一致性时效差几秒但可以避免数据库事务跨服务带来的不确定性。要注意分布式事务不是银弹。若活动并发量只有几百单库事务已经足够稳妥不必为了技术而引入过重的架构。7.4 线上环境变更前必须备份与验证在修改生产环境数据库表结构时务必先在测试环境执行一遍脚本并备份线上数据。涉及删除、更新类 SQL 时应在事务中执行并先使用 SELECT 确认影响行数。例如给 registration 表加联合唯一索引前需要先查询是否有历史重复数据否则索引会创建失败SELECT session_id, fan_phone, COUNT(*) AS cnt FROM registration WHERE deleted 0 GROUP BY session_id, fan_phone HAVING cnt 1;如果存在重复记录需要先与业务方确认保留哪一条然后再清理数据并创建索引。7.5 日志与监控是排查问题的关键报名高峰期最怕系统异常后没有日志可查。建议至少记录以下几类日志报名请求入参和出参但脱敏手机号中间四位。库存扣减结果。核销成功与失败动作。数据导出操作。同时为库存和报名量增加监控指标。如果某票种剩余库存瞬间从 100 降到 0且此时有大量失败请求说明可能存在并发集中访问或脚本刷票需要结合访问日志定位。8. 总结与下一步方向从业务需求出发我们完整实现了一个粉丝见面会报名系统的核心模块梳理了“活动—场次—票种—报名—核销”的数据模型并通过 Spring Boot MyBatis-Plus 完成了报名、扣库存、核销、导出等关键接口。通过这篇文章你应该能理解为什么库存扣减要用条件更新而不是先查后写为什么取消报名保留记录比删除记录更安全以及为什么给核销码建立唯一索引会直接影响现场核销体验。如果你打算基于这个项目继续深入可以从这几个方向入手。第一接入 Spring Security实现管理员登录和接口权限控制这是项目从“能跑”到“能上线”的重要一步。第二使用 Redis 缓存活动详情和场次信息降低数据库压力。第三引入消息队列将报名操作异步化提升活动开抢瞬间的并发处理能力。第四开发一个简单的管理后台页面支持活动配置、名单导出和二维码核销。在动手改造时不用一次性把所有技术都堆上去先把当前系统的并发瓶颈压测出来再针对瓶颈做优化。希望这篇文章能给你提供一套可以照着敲的代码基础。如果已经成功跑通了报名流程可以试着把核销码变成二维码让工作人员在现场用手机扫码核销体验会完整很多。如果你在实践过程中遇到了和库存、事务、索引相关的问题欢迎在评论区留言交流也可以把这篇教程收藏起来等真正需要开发活动报名系统时再按步骤操作。