简介:一套基于SSM框架的客运自助售票微信小程序完整工程,面向正在学习微信小程序和Java Web开发的读者,可帮助从零掌握前后端联调与购票系统设计。项目覆盖车次查询、选座、支付等核心流程,前端采用微信小程序规范下的wxml、wxss、js开发,后端由Spring、SpringMVC、MyBatis提供接口与数据持久化,同时附带SQL脚本和毕业论文文档,便于完整复现项目并查阅设计思路。资源包共1276个文件,包体约14.87MB,包含vue、js、java、png等主要类型,另有大量json、xml配置,以及sql数据库脚本和docx论文,前端资源与后端代码分层存放,查询定位方便。源码注释完整、结构清晰,数据库设计遵循第三范式并兼顾索引与缓存优化,多个备份文件保留改动痕迹,适合课程设计、毕业设计或业余自学参考。目前已有77人学习使用,是了解SSM组合在真实小程序项目落地方式的实用样例。
1. 微信小程序客运自助售票:一套能跑的 SSM 整站源码,从哪开始看
微信小程序客运自助售票系统,本质是“用户端小程序 + SSM 后端 + MySQL 数据库”的完整票务闭环:用户在小程序里查车次、看余票、下单支付,到客运站刷电子凭证上车。这套资源打包了整站源码、SQL 脚本和配套论文,对你来说最直接的问题是:它能不能跑,值不值得花时间拆。我拆过几套同类的票务项目,直接说结论——这套最大的优点是链路完整,从建表 SQL 到下单事务、支付状态流转都有对应代码,不是那种只有增删改查的壳子;适合正在做微信小程序方向毕业设计的学生,也适合想用 SSM 把“订单 + 支付 + 余票扣减”完整流程练一遍的人。但它的踩坑点也很集中,比如真机请求失败、日期格式、余票超卖,这些坑不在源码里,而在运行环境。
2. 架构拆解:SSM 三层与小程序前端的边界和接口约定
2.1 为什么这套代码选 SSM 而不是 Spring Boot
客运自助售票系统后端用 Spring + SpringMVC + MyBatis 三件套,很多人第一反应是“都什么年代了还用 SSM”。但这类整站源码面向的主要是课程设计和毕业设计,国内很多高校的 Java Web 课程仍然把 SSM 作为主流框架,配上 XML 配置反而更好讲清楚请求流转。另一个现实原因是,SSM 在整站源码里容易做“分层展示”:Controller 管路由、Service 管事务、Mapper 管 SQL,三层边界比 Spring Boot 的自动配置更直观。
拿到源码先看包结构,一般长这样:
src/main/java ├── controller # 接口层,接收小程序请求 ├── service # 业务层,事务边界都在这里 ├── dao # MyBatis Mapper 接口 ├── entity # 数据库实体类 └── vo # 返回给前端的视图对象 src/main/resources ├── mapper # MyBatis XML 文件 ├── jdbc.properties ├── applicationContext.xml └── spring-mvc.xml这套结构的逻辑是:Controller 不做业务计算,只做参数接收和结果包装;Service 层负责事务,比如下单扣余票必须在一个事务里;DAO 层只做单表或联表查询,不写业务判断。你改代码的时候要守住这条线——很多翻车现场就是把事务注解加到了 Controller 方法上,结果事务根本没生效。
2.2 前后端接口约定:REST + JSON 的调用链路
小程序端和后端的通信不是页面跳转,而是通过 HTTP 接口拿 JSON 数据。这套源码里后端会返回一个统一的 Result 结构,所有接口都遵循同一个格式:
{ "code": 200, "msg": "success", "data": {} }code 是业务状态码,200 表示成功;msg 是给前端提示用的文案;data 是真正的业务数据,可能是对象也可能是数组。小程序端拿到结果后先判断 code,再取 data 渲染页面。
后端对应的 Controller 写法很典型,以车次查询接口为例:
@RestController @RequestMapping("/api/schedule") public class ScheduleController { @Autowired private ScheduleService scheduleService; @GetMapping("/list") public Result list(@RequestParam String startCity, @RequestParam String endCity, @RequestParam String departDate) { List<ScheduleVO> list = scheduleService.querySchedule(startCity, endCity, departDate); return Result.ok(list); } }注意这里用的是 @RestController,方法返回值是 Result 对象,SpringMVC 会自动把它序列化成 JSON。startCity、endCity、departDate 三个参数对应小程序端 wx.request 里 data 字段的 key,命名必须一致,否则后端接收到的就是 null。
2.3 小程序端请求封装:把重复的 wx.request 收敛到一个文件
如果每个页面都直接写 wx.request,你会发现在十几个页面里复制粘贴同样的 header、同样的错误提示,改一个接口地址要翻遍所有文件。规范一点的源码会在 utils 里放一个 request.js 做统一封装:
// utils/request.js const BASE_URL = 'http://localhost:8080/ssm_ticket'; 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', 'Authorization': wx.getStorageSync('token') || '' }, success: (res) => { if (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 };BASE_URL 集中管理的好处是:真机上调试时只需要改这一个常量。header 里带 Authorization 是为了让后端识别登录用户,token 在小程序登录后存到 Storage 里。这里有个习惯——看到源码里每个页面都直接写 wx.request 的,建议你自己动手收敛成这种封装,后面接支付、查订单会省非常多事。
3. 数据库脚本:核心表结构与余票扣减的约束设计
3.1 SQL 脚本里先看哪五张表
拿到整站源码的 SQL 脚本,不要急着全部执行,先把核心表找出来。一套客运售票系统最核心的是用户表、班次表、订单表,再加上车次信息和退款记录,通常不少于五张业务表。其中班次表和订单表是重点,订单表决定了整套票务系统的业务边界。
班次表(t_schedule)的核心字段:
CREATE TABLE `t_schedule` ( `id` int(11) NOT NULL AUTO_INCREMENT, `bus_number` varchar(20) NOT NULL COMMENT '车牌号', `start_station` varchar(50) NOT NULL COMMENT '出发站', `end_station` varchar(50) NOT NULL COMMENT '到达站', `depart_date` date NOT NULL COMMENT '出发日期', `depart_time` time NOT NULL COMMENT '发车时间', `arrive_time` time DEFAULT NULL COMMENT '预计到达时间', `ticket_price` decimal(10,2) NOT NULL COMMENT '票价', `total_seats` int(11) NOT NULL COMMENT '总座位数', `remain_seats` int(11) NOT NULL COMMENT '余票数', `status` tinyint(4) DEFAULT '1' COMMENT '状态:1正常 0停运', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_station_date` (`start_station`, `end_station`, `depart_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意三个细节:remain_seats 是冗余字段,正常情况下余票数 = 总座位数 - 已售订单数,但为了提高查询性能,源码里普遍直接用这个字段,下单时同步扣减;status 字段控制班次是否可售;联合索引 idx_station_date 对应的是小程序端最频繁的查询条件——按出发站、到达站、出发日期筛班次。
订单表(t_order)是另一张关键表:
CREATE TABLE `t_order` ( `id` int(11) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` int(11) NOT NULL COMMENT '下单用户ID', `schedule_id` int(11) NOT NULL COMMENT '班次ID', `ticket_count` int(11) NOT NULL DEFAULT '1' COMMENT '购买张数', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `pay_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '支付状态:0未支付 1已支付 2已退款', `order_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已出票 2已取消 3已退票 4已使用', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_schedule_id` (`schedule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;order_no 是业务编号,不是主键 id。用户在“我的订单”里看到的是 order_no,退款对账也走它;主键 id 只在系统内部关联时用。pay_status 和 order_status 分开维护,因为一笔订单可能先支付后退款,支付状态和订单状态不是一对一关系。
3.2 余票直接存字段还是实时计算:两种设计的选择逻辑
余票设计有两种思路。第一种是每次查余票时统计已售订单数,用 total_seats 减去已售数得出余票,好处是数据永远准确,没有冗余问题;坏处是订单表数据量大之后,查询要扫一遍订单,接口响应会越来越慢。
第二种就是这套源码的做法:在 t_schedule 上直接维护 remain_seats 字段,下单成功就把字段值减掉,退票再加回来。好处是查询快,一条 SQL 就能拿到余票;坏处是需要处理并发——两个用户同时买最后一张票,如果都先查余票再更新,就会超卖。这个问题在第 5 章会展开讲,你改代码的时候优先关注 remain_seats 相关的 update 语句有没有做条件约束。
3.3 订单状态字段:数值字典与状态流转约定
订单表里 order_status 用的是数字字典,从 0 到 4 分别代表:待支付、已出票、已取消、已退票、已使用。你拿到源码后,第一步去 service 里搜orderStatus赋值的地方,把整条状态流转画出来。
正常路径是:下单时创建 order_status=0 的订单,支付成功后改成 1;用户到站验票后改成 4。异常路径有两条:一是用户下单后 15 分钟内没支付,系统自动把订单改成 2,同时把余票加回来;二是已支付但未使用的订单,用户申请退票,状态改成 3,同时触发退款流程。这里最容易漏的是:取消订单或退票时,除了改订单状态,还必须把 remain_seats 加回去,否则会出现“订单取消了但余票没恢复”的 bug。
4. 核心购票链路:从车次查询到下单锁座的代码走读
4.1 车次查询:多条件筛选与 MyBatis 动态 SQL
小程序购票的第一步是查班次。用户选了出发站、到达站和日期,后端需要按这三个条件过滤,同时只返回状态正常的班次。这套源码里用的是 MyBatis 动态 SQL,XML 写法如下:
<select id="querySchedule" resultType="com.example.vo.ScheduleVO"> SELECT id, bus_number, start_station, end_station, depart_date, depart_time, arrive_time, ticket_price, total_seats, remain_seats FROM t_schedule <where> <if test="startCity != null and startCity != ''"> AND start_station = #{startCity} </if> <if test="endCity != null and endCity != ''"> AND end_station = #{endCity} </if> <if test="departDate != null and departDate != ''"> AND depart_date = #{departDate} </if> AND status = 1 </where> ORDER BY depart_date, depart_time </select><where>标签会自动处理第一个条件前面的 AND,这段 SQL 比手动拼接字符串安全得多。注意投票时用参数占位符#{startCity},不是字符串拼接${startCity},前者是预编译,可以防止 SQL 注入;后者是直接拼进 SQL 的,一定不要用。
对应 Service 层的实现逻辑很简单:先校验三个参数不能全为空,再调用 Mapper 查询,最后按发车时间排序返回。查询这块一般不需要加缓存,因为班次数据变化不频繁,但查询量大的时候可以考虑在第 6 章说到的 Redis 方案。
4.2 下单与锁座:事务、行锁和条件更新
下单是整套系统里最需要小心的地方。用户在前端选中一个班次,点击购票,后端要完成三件事:校验余票、扣减余票、创建订单。这三步必须放在同一个事务里,任何一步失败都要回滚。
Service 层核心代码如下:
@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateVO vo, Integer userId) { // 1. 锁定班次行记录,防止并发超卖 Schedule schedule = scheduleMapper.selectByIdForUpdate(vo.getScheduleId()); if (schedule == null || schedule.getRemainSeats() < vo.getTicketCount()) { throw new BizException("余票不足"); } // 2. 条件更新余票 int rows = scheduleMapper.deductSeats(vo.getScheduleId(), vo.getTicketCount()); if (rows == 0) { throw new BizException("余票不足,请刷新后重试"); } // 3. 生成订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScheduleId(vo.getScheduleId()); order.setTicketCount(vo.getTicketCount()); order.setTotalAmount(schedule.getTicketPrice().multiply( new BigDecimal(vo.getTicketCount()))); order.setOrderStatus(0); orderMapper.insert(order); return order; }核心在两步:第一步selectByIdForUpdate用数据库行锁把这条班次记录锁住,锁住之后其他事务只能等当前事务提交或回滚才能继续操作这条记录;第二步deductSeats不是简单的update t_schedule set remain_seats = remain_seats - 1,而是带条件更新:
UPDATE t_schedule SET remain_seats = remain_seats - #{count} WHERE id = #{scheduleId} AND remain_seats >= #{count}remain_seats >= #{count}这个条件就是防超卖的最后一道闸。即使第一步的行锁没生效,或者代码复用的时候漏了for update,这条带条件的 update 也会因为受影响行数为 0 而让事务执行失败回滚。
4.3 超时未支付订单:定时回滚余票的常见做法
用户下单后不一定马上支付,如果一直不处理,这些待支付订单会占用余票,导致其他用户买不到票。源码里常见的做法是:定时任务扫描超过 15 分钟未支付的订单,自动取消并恢复余票。
用 Spring 的定时任务就能实现:
@Scheduled(fixedDelay = 60000) public void autoCancelExpiredOrders() { // 1. 查询超时订单 List<Order> expiredOrders = orderMapper.selectExpiredOrders(15); for (Order order : expiredOrders) { // 2. 取消订单(带上状态条件,防止并发重复处理) int rows = orderMapper.cancelOrder(order.getId()); if (rows > 0) { // 3. 恢复余票 scheduleMapper.increaseSeats(order.getScheduleId(), order.getTicketCount()); } } }cancelOrder 的 SQL 必须带上order_status = 0条件:
UPDATE t_order SET order_status = 2 WHERE id = #{id} AND order_status = 0这样做的原因是:如果用户刚好在超时临界点完成了支付,定时任务就不应该再把订单取消。带上状态条件后,只有 status 还是 0 的订单能被取消,已支付的订单不受影响。
5. 避坑排查:小程序联调 SSM 后端最常踩的六个坑
5.1 真机预览请求失败,开发工具却一切正常
现象:开发者工具里接口正常返回数据,一用手机预览就全部请求失败,报错信息是request:fail。
原因:小程序平台对网络请求有域名白名单限制。开发者工具里可以勾选“不校验合法域名”,但真机上这个选项不生效,所有请求域名必须在小程序管理后台配置为 request 合法域名,且必须是 HTTPS。
解决:开发调试阶段,在开发者工具右上角详情里勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,真机调试使用预览模式时也要确保开启“开发环境不校验请求域名”。如果要正式发布,把后端接口迁到 HTTPS 域名,并在后台配置白名单。
5.2 日期格式在 iOS 上解析失败,Android 却正常
现象:小程序端用new Date("2025-06-01 10:30:00")解析后端返回的时间,Android 正常,iOS 上显示Invalid Date。
原因:iOS 的 JavaScriptCore 不支持yyyy-MM-dd HH:mm:ss这种带横线的时间格式,要求用yyyy/MM/dd HH:mm:ss或 ISO 格式。
解决:后端在 Jackson 配置里统一日期序列化格式:
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss spring.jackson.time-zone=GMT+8如果后端已经返回字符串,前端也做一层兼容:
function parseDate(str) { if (str && str.indexOf('-') > -1) { return new Date(str.replace(/-/g, '/')); } return new Date(str); }5.3 余票出现负数,并发下单超卖
现象:压测或多人同时抢票时,余票数变成负数,订单数量超过班次总座位数。
原因:下单链路里没用行锁,也没用带条件的 update,直接查余票后在代码里判断够不够,然后执行无条件的减余票 update。两个请求同时读到余票 1,判断都通过,都执行扣减,最终余票变成 -1。
解决:把单条剩余票数的更新交给数据库,而不是应用层。用第 4 章提到的带条件 update:
UPDATE t_schedule SET remain_seats = remain_seats - #{count} WHERE id = #{scheduleId} AND remain_seats >= #{count}受影响行数为 0 时说明余票不足,事务回滚。这条 SQL 是防超卖的关键,换任何框架、任何写法都不要丢掉这个条件。
5.4 MyBatis 动态 SQL 拼接导致查询结果不对
现象:按条件筛选班次时,不传出发站只传到达站,结果查出来所有班次,或者直接报 SQL 语法错误。
原因:SQL 里用了<if>拼接条件,但外层没包<where>标签,导致第一个条件前面可能出现多余的AND,例如SELECT * FROM t_schedule WHERE AND end_station = ?。
解决:统一使用<where>标签包裹<if>,MyBatis 会自动去掉紧跟其后的多余 AND 或 OR。同时每个<if>测试条件里同时判断 null 和空字符串,避免前端传空字符串导致条件失效。
5.5 接口地址写死在每个页面里,联调时改到崩溃
现象:本地联调时把接口地址从http://localhost:8080改成局域网 IP,发现十几个页面全都要改一遍,漏改一个就出现部分页面正常、部分页面报错的情况。
原因:源码里每个页面直接调用wx.request时把完整 URL 写死了,没有统一封装。
解决:建一个独立的config.js管理接口基础路径,页面引用这个配置。做法参考第 2.3 小节的 request 封装,改地址只动一处。真机调试时用电脑的局域网 IP,注意不要用localhost,手机访问不到本机地址。
5.6 登录态失效,openid 拿不到
现象:小程序登录后接口返回 401 或openid 为空,每次重启小程序都要重新登录才能正常操作。
原因:后端的登录接口调用微信官方code2Session时,需要把小程序端的 code 换成 openid 和 session_key。常见问题是前端拿 code 后直接传给了后端,但后端配置的 appid 和 secret 是另一个小程序的,或者 code 已经过期(有效期只有 5 分钟)。
解决:确认 appid 和 secret 与当前小程序后台一致;前端在wx.login的回调里获取 code 后立即传给后端,不要存在 Storage 里等下次用。后端拿到 openid 后自己生成业务 token 返回给前端,后续请求头带 token 识别用户,不要每次都调code2Session。
6. 进阶优化:把 SSM 平滑迁到 Spring Boot 并加 Redis 缓存
6.1 迁移思路:保留 MyBatis XML,换掉配置方式
SSM 迁到 Spring Boot 不需要重写业务代码,核心是把 XML 配置改成自动配置。建一个新的 Spring Boot 项目,引入基础依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql-connector-j</groupId> <artifactId>mysql-connector-j</artifactId> </dependency>然后保留原来的 entity、dao、service、controller 包结构,Mapper 接口和 XML 文件可以原样搬过来。application.yml 里配置数据源和 MyBatis:
spring: datasource: url: jdbc:mysql://localhost:3306/ticket_db?useUnicode=true&characterEncoding=utf8 username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity启动类加上@MapperScan扫描 DAO 接口,原来的 spring-mvc.xml 和 applicationContext.xml 就可以删掉了。迁移后的代码量不变,但启动时间、配置复杂度都会明显下降。
6.2 查班次接口加 Redis 缓存,一个注解就够了
迁到 Spring Boot 后再加缓存就容易了。车次查询属于读多写少的接口,适合用 Redis 做缓存。
引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>启动类加@EnableCaching,然后在查询方法上加注解:
@Cacheable(value = "schedule:list", key = "#startCity + '_' + #endCity + '_' + #departDate", unless = "#result == null || #result.isEmpty()") public List<ScheduleVO> querySchedule(String startCity, String endCity, String departDate) { return scheduleMapper.querySchedule(startCity, endCity, departDate); }key 由三个查询条件拼接而成,不同条件的查询结果互不影响;unless 里加了一个判断,结果集为空的请求不缓存,防止把“无票”状态也缓存下来。缓存过期时间在 Redis 配置里设置,我一般建议 30 到 60 秒,既能让班次余票数据不至于太陈旧,又能挡住大部分重复查询压力。
这是这套源码最有价值的进阶方向——底层业务不变,只是把配置方式换掉,再把高频查询挡住,性能就能有很明显的提升。从那以后,我每次拿到这种微信小程序 + SSM 的整站源码,都会先看 SQL 脚本里订单表的状态字段和余票扣减语句,这两处决定一套票务系统能不能真正上线。希望这套分析流程对你有帮助。
本文还有配套的精品资源,点击获取