☰
客运自助售票小程序实战:SSM架构与余票并发控制
2026/10/10 6:47:48 网站建设 项目流程

简介:以SSM(Spring、SpringMVC、MyBatis)为后端核心,微信小程序为前端载体的客运自助售票整站源码,面向JavaWeb开发者和微信小程序初学者,覆盖车次查询、在线选座、订单支付、后台管理等完整业务链路。压缩包共1276个文件、14.87MB,资源类型丰富:Java后端逻辑、vue后台管理页面、小程序wxml/wxss界面、js交互脚本均搭配齐全,另有png/jpg设计图与sql数据库脚本,目录分层清晰。随包附有设计论文,详细阐述Spring控制反转与面向切面编程、SpringMVC的MVC处理流程、MyBatis持久层映射等关键设计,并给出数据库第三范式表结构、索引和缓存优化方案,便于理解系统架构与排错思路。sql脚本可直接导入MySQL还原数据,配合启动脚本即可跑通前后端联调,降低环境搭建门槛。已有77人学习下载,适合毕业设计选题、课程项目实践,以及希望掌握SSM框架整合与小程序前后端协同开发的开发者参考学习。

1. 客运自助售票小程序:SSM 技术栈为什么值得动手改造

拿到一套客运自助售票小程序源码,先别急着嫌 SSM 老。SSM(Spring + SpringMVC + MyBatis)是比 Spring Boot 早一代的 Java 组合,配合微信小程序端做车次查询、在线购票、退票和订单管理,至今仍是许多高校毕业设计和中小客运站内部预约工具的常见形态。这个标题里的客运自助售票小程序,解决的就是乘客从查车次到买票、退票的全流程问题:服务端管业务和数据库,小程序管用户触达。适合谁?适合要快速交付一个可演示票务系统的人,也适合手里攥着老 SSM 工程想把它改造成 Spring Boot 的人。接下来我从服务端三层、小程序交互、SQL 脚本到部署排错,按能复现的方式讲。

2. SSM 服务端三层怎么落地:Controller、Service、Mapper 与小程序接口对接

2.1 三层架构与一次完整请求的路径

SSM 里的三个组件分工明确:Spring 管对象和依赖,SpringMVC 管 HTTP 路由,MyBatis 管 SQL 映射。小程序端发一个wx.request,请求到达 Tomcat 后被 DispatcherServlet 分发给对应的 Controller,Controller 调 Service 接口,Service 实现类调 Mapper 接口,Mapper 执行 XML 里的 SQL,结果逐层返回并序列化成 JSON。这条链路里最容易忽略的是“谁在做 JSON 转换”——SpringMVC 默认用 Jackson,配置不对会出现第 5 章讲的时间戳翻车现场。

和 Spring Boot 相比,SSM 需要自己维护web.xml、spring-mvc.xml、applicationContext.xml三份配置。客运售票这类项目逻辑集中在车次、订单、余票三块,XML 配置反而让每层边界更清楚。我拿到这类项目的第一件事不是看业务代码,而是把三份配置里的扫描路径梳理一遍:确认 Controller 被扫到、Mapper 接口被扫到、Mapper XML 位置被扫到。这一步是后面所有接口联调的地基,扫描路径出错时接口直接 404 或启动报 Bean 找不到,排查起来非常耗时间。

请求的具体走向可以用一个例子说清:用户查车次,小程序传startStation=北京、endStation=上海、departDate=2025-06-01,后端先按这三个参数构造查询条件,再经 Service 到 Mapper,最终返回一个 JSON 数组。数组里的每个元素对应一条班次记录,包含车次号、出发到达时间、票价、余票数。这个接口就是整个售票系统的入口,后面的下单、支付、退票都建立在“先能查出正确班次”的基础上。

2.2 手写车次查询接口:Controller、Service、Mapper 的代码与参数解释

以“车次查询”这个最核心的接口为例,三层代码分别如下。先看 Controller 层:

@RestController @RequestMapping("/api/coach") public class CoachController { @Autowired private CoachService coachService; @GetMapping("/query") public Result queryCoaches(@RequestParam String startStation, @RequestParam String endStation, @RequestParam String departDate) { List<Coach> list = coachService.queryCoaches(startStation, endStation, departDate); return Result.ok(list); } }

Controller 用@RestController直接返回 JSON,省去每个方法上写@ResponseBody。三个@RequestParam对应小程序端传上来的三个查询条件。Result是统一响应包装类,包含code、msg、data三个字段,多数 SSM 项目会自带这个类,没有的话自己写一个也很容易。注意@GetMapping只接受 GET 请求,查询语义用 GET 是合理的,但如果有很长的筛选条件,建议改成 POST 避免 URL 过长。

Service 层要写接口和实现类,Spring 通过@Service注册,@Autowired按类型注入:

public interface CoachService { List<Coach> queryCoaches(String startStation, String endStation, String departDate); } @Service public class CoachServiceImpl implements CoachService { @Autowired private CoachMapper coachMapper; @Override public List<Coach> queryCoaches(String startStation, String endStation, String departDate) { return coachMapper.selectByCondition(startStation, endStation, departDate); } }

Service 层在这里看起来是透传,但完整项目里的余票判断、班次状态过滤、价格计算都会写在这一层。真正的 SQL 逻辑在 Mapper 的 XML 文件里:

<select id="selectByCondition" resultType="com.example.entity.Coach"> SELECT id, coach_no, start_station, end_station, depart_date, depart_time, arrive_time, ticket_price, remaining_seats, status FROM coach WHERE start_station = #{startStation} AND end_station = #{endStation} AND depart_date = #{departDate} AND status = 1 ORDER BY depart_time </select>

三个参数通过#{}占位符传入,MyBatis 会生成 PreparedStatement,避免 SQL 注入。resultType写实体类的全限定名,包名要和实体类实际位置一致。status = 1过滤掉已停运班次。depart_date是字符串类型,常见设计是直接以yyyy-MM-dd格式存库,省去日期转换的麻烦。对应的 Mapper 接口长这样:

@Mapper public interface CoachMapper { List<Coach> selectByCondition(@Param("startStation") String startStation, @Param("endStation") String endStation, @Param("departDate") String departDate); }

@Mapper让 MyBatis 在启动时扫描到这个接口并生成代理对象。@Param必须写,否则 XML 里多个参数时无法正确绑定。如果项目里用了 MyBatis-Spring 的自动扫描,也可以在配置类上写@MapperScan("com.example.mapper"),两种方式选一种即可,别重复。

2.3 小程序登录与会话:Token 方案的正确打开方式

客运售票小程序必须记住“哪个用户买了票”。传统 SSM 项目习惯用 HttpSession,但小程序发请求时不会自动携带 Cookie,Session 的 JSESSIONID 很难稳定维持。实际项目里更稳的方案是:用wx.login拿 code,后端拿 code 去微信接口换 openid,生成自定义 token 返回给小程序,小程序存进 storage,后续每个请求都在 header 里带 token。后端用拦截器统一校验。

public class TokenInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("token"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } User user = tokenService.getUserByToken(token); if (user == null) { response.setStatus(401); return false; } request.setAttribute("userId", user.getId()); return true; } }

拦截器里只做两件事:token 是否存在、token 是否有效。校验通过后把 userId 塞进 request 属性,Controller 里直接(Long) request.getAttribute("userId")就能拿到当前用户。Token 存 Redis 还是数据库?这类项目很多没有 Redis,存在一张 token 表也可以,但要注意加过期时间字段;有 Redis 就放 Redis 并设置两小时过期,体验好很多。

这里有一个现实取舍:个人开发者没有企业主体资质,不一定能调通微信登录接口。很多 SSM 项目包里的做法是保留“模拟登录”入口,用手机号加验证码或者用户名密码完成登录,成功后在后端生成同一个 token。运营阶段再替换成微信一键登录。这个设计在本地演示和答辩时更省事,不依赖 AppSecret 配置。

3. 小程序端的票务交互:车次查询、下单支付与订单状态联动的完整实现

3.1 项目结构与车次查询页:从日期选择到列表渲染的数据流

微信小程序原生开发的客运售票项目,pages目录下通常包含index(首页)、coach-list(车次列表)、order-confirm(订单确认)、order-list(订单列表)、mine(个人中心)等页面。车次查询页是用户进门第一屏,交互逻辑是:选择出发站、到达站、日期,点击查询,调后端接口,拿到班次数组渲染到列表。

请求封装是所有页面的公共基础,我一般在utils目录维护一个request.js,统一拼 baseURL、带 token、处理错误码:

const BASE_URL = 'http://localhost:8080'; function request(path, method, data) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method: method, data: data, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') }, success: (res) => { if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.msg || '请求失败', icon: 'none' }); reject(res.data); } }, fail: (err) => { wx.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); } module.exports = { request };

BASE_URL在本地开发时指向电脑的局域网 IP 加端口,真机调试时不能写localhost,否则手机找不到。开发者工具调试时勾选“不校验合法域名”,真机预览时手机和电脑连同一个 Wi-Fi。页面逻辑代码:

Page({ data: { startStation: '', endStation: '', departDate: '', coachList: [], loading: false }, onLoad() { const today = new Date(); this.setData({ departDate: this.formatDate(today) }); }, formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; }, onStartChange(e) { this.setData({ startStation: e.detail.value }); }, onEndChange(e) { this.setData({ endStation: e.detail.value }); }, async queryCoaches() { if (!this.data.startStation || !this.data.endStation) { wx.showToast({ title: '请选择站点', icon: 'none' }); return; } this.setData({ loading: true }); try { const list = await request('/api/coach/query', 'GET', { startStation: this.data.startStation, endStation: this.data.endStation, departDate: this.data.departDate }); this.setData({ coachList: list }); } finally { this.setData({ loading: false }); } } });

startStation和endStation用picker组件从站点列表选,departDate默认当天。查询用 GET 是因为不修改数据,语义清晰。loading状态控制页面加载动画。formatDate里的padStart(2, '0')用来补齐月份和日期的前导零,不处理会出现2025-6-1这种格式。

3.2 下单支付流程:预下单、用户确认与模拟支付的取舍

用户点某趟班次的“购票”按钮后进入订单确认页,选择乘车人数,系统根据班次价格算出总价。后端提供“预下单”接口:前端传coachId、userId、passengerCount,后端在事务里校验余票、扣减余票、生成订单,返回订单号和应付金额。

async submitOrder() { const coachId = this.data.coachId; const passengerCount = this.data.passengerCount; wx.showLoading({ title: '提交中' }); try { const order = await request('/api/order/create', 'POST', { coachId, passengerCount }); wx.hideLoading(); this.pay(order); } catch (err) { wx.hideLoading(); } }, pay(order) { const payMode = order.payMode; if (payMode === 'mock') { request('/api/pay/mockPay', 'POST', { orderId: order.id }).then(() => { wx.redirectTo({ url: '/pages/order-list/order-list' }); }); return; } wx.requestPayment({ timeStamp: order.payParams.timeStamp, nonceStr: order.payParams.nonceStr, package: order.payParams.package, signType: 'MD5', paySign: order.payParams.paySign, success: () => { wx.redirectTo({ url: '/pages/order-list/order-list' }); }, fail: () => { wx.showToast({ title: '支付取消', icon: 'none' }); } }); }

payMode是后端根据项目配置返回的字段。真实微信支付需要企业主体和商户号,个人开发者很难拿到资质,所以大量 SSM 售票项目会写一个mockPay分支:演示时点支付按钮直接调后端把订单状态改成“已支付”,运营时替换成wx.requestPayment即可。前端改动很小,后端支付回调的验签逻辑需要另写,但整体架构不需要推倒重来。

3.3 订单状态联动:待支付、已支付、已出票、已退票的状态机设计

订单状态是整个票务系统的核心状态机。建议用整数status字段:0 待支付、1 已支付、2 已出票、3 已退票、4 已过期。前端根据status渲染不同按钮。合法流转路径只有四条:待支付到已支付、待支付到已过期、已支付到已出票、已支付到已退票。

当前状态可流转目标触发动作
0 待支付1 已支付、4 已过期用户支付、超时未付
1 已支付2 已出票、3 已退票系统出票、用户申请退票
2 已出票3 已退票用户申请退票
3 已退票无终态
4 已过期无终态

后端 Service 在修改status前必须校验当前状态,不允许“已退票跳到已出票”这种非法流转。前端拿到订单数据后按状态渲染按钮组:待支付显示“去支付”和“取消订单”,已支付显示“申请退票”,已出票显示“查看车票”。状态机定清楚后,前端和后端的判断逻辑都能收敛,不会出现“支付成功了按钮还是待支付”这类问题。

4. SQL 脚本与表结构设计:余票扣减、订单流水与退票回滚怎么建模

4.1 核心表结构:用户表、班次表、站点表、订单表的字段清单

项目包里的 SQL 脚本一般包含建库建表语句和基础测试数据。客运售票的核心是四张表:用户表、班次表、站点表、订单表,可能还有一张乘车人表。我见过的一组比较稳的字段设计如下。

表名关键字段说明
sys_userid, username, password, phone, create_time登录账号,密码建议 BCrypt 加密
stationid, station_name, city_code, sort_order站点基础数据,前端 picker 数据源
coachid, coach_no, start_station, end_station, depart_date, depart_time, arrive_time, ticket_price, total_seats, remaining_seats, status班次与余票,核心表
sys_orderid, order_no, user_id, coach_id, passenger_count, total_amount, status, create_time, pay_time, refund_time订单主表,order_no 加唯一索引

coach表的remaining_seats是全局最关键的字段,所有售卖出票都围绕它做扣减。total_seats是总座位数,页面展示“余票/总座”要用。sys_order的order_no必须有唯一索引,第 5 章的重复下单问题要靠它兜底。status字段含义和第 3 章保持一致。站点表里的sort_order用来控制出发站下拉列表的排序,避免站点顺序混乱。

4.2 余票扣减的并发控制:事务、行锁与乐观锁怎么选

余票扣减是最容易出问题的环节。直观写法是先查余票、判断大于 0、再 update,但两个请求同时读到余票等于 1 时,都会认为有票可卖,最终超卖。正确做法是把扣减逻辑放进 SQL 条件里。

@Transactional @Override public Order createOrder(Long userId, Long coachId, Integer passengerCount) { Coach coach = coachMapper.selectByIdForUpdate(coachId); if (coach == null || coach.getRemainingSeats() < passengerCount) { throw new BusinessException("余票不足"); } int rows = coachMapper.deductSeats(coachId, passengerCount); if (rows == 0) { throw new BusinessException("余票不足,请重试"); } Order order = buildOrder(userId, coachId, passengerCount, coach.getTicketPrice()); orderMapper.insert(order); return order; }

对应的 Mapper 方法:

@Update("UPDATE coach SET remaining_seats = remaining_seats - #{count} " + "WHERE id = #{id} AND remaining_seats >= #{count}") int deductSeats(@Param("id") Long id, @Param("count") Integer count);

扣减 SQL 里带了remaining_seats >= #{count}条件,MySQL 执行 update 时会对命中行加锁,返回值为 0 就说明余票不够。@Transactional保证扣减余票和插入订单在同一个事务里,要么都成功,要么都回滚。selectByIdForUpdate是悲观锁,适合中小并发量;如果不想锁行,可以改成乐观锁:update 时带 version 条件,失败后重试。

这里有个设计取舍要讲清楚:为什么deductSeats返回int而不是boolean?MyBatis 的 update 返回的是受影响行数,判断rows == 0能精确区分“SQL 执行成功但条件不满足”。如果方法返回 void,你就没办法区分“扣减失败”和“扣减成功”,这属于 MyBatis 使用的常见细节。

4.3 退票逻辑:余票回滚与退款流水的一致性

退票的难点在于三件事必须同时完成:余票加回去、订单状态改变、退款流水落库。常见实现是在一个事务里完成三步。

@Transactional @Override public void refund(Long orderId, Long userId) { Order order = orderMapper.selectById(orderId); if (order == null || !order.getUserId().equals(userId)) { throw new BusinessException("订单不存在"); } if (order.getStatus() != 1 && order.getStatus() != 2) { throw new BusinessException("当前订单不可退票"); } int rows = coachMapper.increaseSeats(order.getCoachId(), order.getPassengerCount()); if (rows == 0) { throw new BusinessException("班次状态异常,退票失败"); } orderMapper.updateStatus(orderId, 3, new Date()); refundMapper.insert(orderId, order.getTotalAmount(), "用户申请退票"); }

第一步校验订单归属和状态,第二步把余票加回去,第三步改订单状态为已退票,第四步插退款流水。increaseSeats不需要带remaining_seats + count条件,因为退票是归还座位,不会产生负数。事务保证四步全部成功,任何一步抛异常都会整体回滚。

有个容易被忽视的细节:退票要按passengerCount还座位,而不是只加 1。如果用户买了两张票却只加回一个座位,余票账面就对不上。increaseSeats的 SQL 是UPDATE coach SET remaining_seats = remaining_seats + #{count},其中count是订单里的passenger_count,这个值的来源必须是订单记录,不能由前端传参决定。

5. 避坑排查:SSM + 小程序合体后的 6 个常见翻车现场

5.1 小程序真机连不上本地接口:合法域名报错

现象:开发者工具里接口调通了,手机上打开小程序却一直转圈,控制台报“不在以下 request 合法域名列表中”。

原因:小程序真机模式强制校验 HTTPS 域名,本地 IP 和端口不在白名单里。

解决:开发阶段在开发者工具右上角“详情-本地设置”勾选“不校验合法域名”;手机预览时,让手机和电脑连同一个局域网,把BASE_URL改成电脑局域网 IP。这一步很容易漏,我见过不少人卡在这个位置浪费半天。正式上线则必须准备已备案的 HTTPS 域名,在小程序后台配置 request 合法域名。

5.2 MyBatis 报 “Invalid bound statement (not found)”

现象:Tomcat 能启动,一调接口就抛BindingException: Invalid bound statement (not found)。

原因:Mapper 接口编译后,MyBatis 没有找到对应的 XML 文件。常见原因是 XML 没有放在 Mapper 接口同包下,或者spring-mybatis.xml里的mapper-locations路径写错。

解决:检查spring-mybatis.xml里mapper-locations的值,确保指向实际目录,比如classpath:mapper/*.xml。同时确认 XML 的namespace是 Mapper 接口的全限定名,每个语句的id和接口方法名一致。改完配置记得 clean 掉 target 目录再重启,有时候是旧编译产物在捣乱。

5.3 查询结果里的时间字段变成一串数字

现象:小程序端拿到的depart_time显示成1728393600000这样的时间戳。

原因:SpringMVC 默认用 Jackson 序列化 Date 类型,默认输出时间戳。

解决:在配置类里加一个Jackson2ObjectMapperBuilderCustomizer,统一日期格式:

@Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder -> { builder.dateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); builder.simpleDateFormat("yyyy-MM-dd HH:mm:ss"); }; }

这段配置对全局生效,避免在每个实体类字段上加@JsonFormat。注意时区问题,如果服务器时区不是东八区,时间会差 8 小时,需要配合builder.timeZone(TimeZone.getTimeZone("GMT+8"))一起设置。这个坑很经典,属于“本地好好的,部署到服务器就时间不对”的典型原因。

5.4 余票瞬间变负数:并发扣减没有兜底

现象:压测时 50 个用户同时买同一趟车次的最后 1 张票,数据库里余票变成了 -1 甚至更小。

原因:业务代码先查余票再判断再更新,没有加锁,也没有在 update 语句里带余票条件。

解决:按第 4 章的写法,把扣减 SQL 改成UPDATE coach SET remaining_seats = remaining_seats - #{count} WHERE id = #{id} AND remaining_seats >= #{count},在事务里同时创建订单。改完用第 6 章的并发脚本验证,这是最后一道保障。

5.5 用户狂点“提交订单”生成多条重复订单

现象:网络卡顿导致用户连点三次提交,后端生成了三张相同车次、相同人数的订单。

原因:前端只做了按钮 disable,后端没有幂等控制。前端按钮 disable 在快速点击时不可靠,请求发出后按钮已经恢复,用户再点就会重复提交。

解决:前端在请求期间加 loading 锁,防止重复点击;后端在sys_order表给order_no加唯一索引兜底。更稳的做法是前端生成一个requestId,每次下单请求都带上,后端在 insert 前查一下是否存在相同requestId的订单,存在则直接返回原订单,不再新建。

微信小程序的wx.request没有原生的取消机制,所以前端防重复和端上唯一索引必须配合使用。如果只做前端防抖,恶意请求或网络重试照样能打穿。

5.6 支付成功后订单列表还是“待支付”

现象:支付流程走完了,返回订单列表页,状态还是“待支付”。

原因:订单列表页在onLoad里拉数据,页面从支付页返回时不会触发onLoad,展示的还是旧数据。

解决:把订单列表的数据请求从onLoad挪到onShow,每次页面显示都重新请求。同时确认后端支付回调或模拟支付接口已经把订单状态落库,不能只改前端显示不改后端数据。这个小问题在微信小程序开发里非常典型,凡是“从子页面返回列表页数据没刷新”的,都是生命周期用错了。

6. 进阶验证:用并发测试确认余票不超卖,再对照论文章节补全设计说明

6.1 用 Python 并发脚本验证余票一致性

改完余票逻辑后,我会先用一个简单的 Python 脚本压后端接口,确认不会超卖。如果后端有 token 拦截器,先配置一个测试白名单或直接用有效 token。假设本地服务跑在 8080 端口:

import threading import requests BASE_URL = "http://localhost:8080" def buy(i): resp = requests.post( BASE_URL + "/api/order/create", json={"coachId": 1, "userId": 100, "passengerCount": 1} ) print(i, resp.status_code, resp.json()) threads = [threading.Thread(target=buy, args=(i,)) for i in range(50)] for t in threads: t.start() for t in threads: t.join()

跑完后执行下面的 SQL 看结果:

SELECT remaining_seats FROM coach WHERE id = 1; SELECT COUNT(*) FROM sys_order WHERE coach_id = 1 AND status = 0;

如果 coach 表起始余票是 50,并发 50 个请求后remaining_seats应为 0,sys_order里成功落单的记录数应等于实际卖出的张数,不能出现负数余票。

注意:coach 表起始余票要和脚本里的并发数对齐,否则验证结果没有参考意义。

如果发现负数,说明扣减逻辑仍然有漏洞,回到第 4 章的方案重新改。这个脚本改动成本很低,却是排查超卖问题最直接的手段。

6.2 论文章节与代码的对照关系

项目包里带论文,写论文时最容易翻车的地方是“设计”和“实现”脱节。论文章节和代码模块的对照关系可以按这个结构来。

论文章节对应代码与数据库内容
需求分析小程序端页面流程:车次查询、下单、支付、退票的用例说明
系统设计三层架构图、订单状态机、数据库 ER 图
数据库设计第 4 章的表结构、索引设计、事务边界说明
系统实现第 2 章的 Controller/Service/Mapper 代码、第 3 章的小程序页面代码
系统测试Python 并发测试结果、功能测试用例表

写系统设计时把订单状态机画清楚,写实现时把deductSeats的 SQL 条件讲明白,答辩时被问“并发怎么控制”就能直接回答,不至于卡住。

6.3 一个值得投入的改造方向

如果这套系统要真正运营,我建议优先做三件事:第一,把 Token 存储从数据库表换成 Redis,加上过期时间,性能和安全性都能提升;第二,把 Spring 的 XML 配置迁移到 Spring Boot 自动配置,降低新人上手成本;第三,余票扣减从悲观锁升级为乐观锁或 Redis 原子操作,以支撑更高并发。这三个方向都不改变现有业务代码结构,属于增量改造,风险可控。

我自己每次拿到这类 SSM 项目,都会先跑一遍第 2 章的请求链路,再跑一遍第 6 章的并发脚本,确认这两个环节没有翻车才敢动业务代码。这个习惯救过我好几次。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询