1. 项目概述
1.1 核心需求解析
这两年随着文旅行业复苏,景区票务系统的线上化率一直在涨,但很多中小景区的购票体验还停留在“线下排队+人工核销”的阶段。我之前帮一个客户做景区数字化改造,发现他们最大的痛点不是硬件,而是没有一个能支撑业务流转、前后端分离的一体化购票平台。于是就有了这个基于SpringBoot与Vue开发的景区在线购票系统,顺手把完整源码和文档都整理了出来。
这个系统能做什么?核心就三件事:用户在线选景区、下单买票、窗口或闸机核销。听起来简单,但涉及的工程点并不少——用户体系、景点管理、票种定价、订单流转、库存扣减、支付回调、核销验票、后台统计,每一块都能延伸到独立的服务设计。拿SpringBoot做后端接口层,Vue做前端管理端+用户端,数据库用MySQL,缓存用Redis,这套组合在Java技术栈中算是最务实的前后端分离方案。
系统围绕三类角色设计角色权限:游客(注册登录、选票下单)、景区运营人员(管理景点与票种、查看订单、核销)、平台管理员(审核景点信息、处理退款、查看报表)。权限体系在登录时通过JWT写入token,前端路由配合动态菜单过滤,后端接口再用拦截器二次兜底,保证越权请求在API层就被拦下。
如果你是Java初学者或正在做课程设计、毕业设计,这套系统完全可以当作业直接交,架构清晰、注释齐全,压缩包里带完整的初始化SQL脚本和接口文档。如果你是有经验的后端开发,想看看前后端分离项目的工程化细节,比如登录鉴权、超卖防护、定时关单,这里也足够你翻着源码参考。
1.2 系统整体功能框架
整个系统拆成两大部分:用户端(Vue单页面应用)和管理端(同套前端工程,通过路由区分)。用户端面向C端,展示景区列表、详情页放景点介绍与票种信息,购物车合并同一景区的票种,订单确认页支持选择出行日期和游玩人数;管理端面向B端,包含景点信息CRUD、票种库存维护、订单查看与退款、核销码扫码验票、按时间维度的营收统计。
功能链路是这样串起来的:游客注册登录后进入景区列表,筛选目的地和游玩日期,选择票种加入订单,确认信息并提交支付,支付成功后生成含二维码的电子票,入园时工作人员扫描核销码完成验证,系统自动更新库存与订单状态。后台管理员对订单退款时,需要先校验订单是否已核销,已核销订单禁止退款,这条业务规则是防止景区资金损失的防线。
说实话,我做这个项目时最费心思的不是接口怎么写,而是订单状态机的流转设计。一张订单从待支付、已支付、已核销、已退票、已过期,每一步都是一个状态迁移,非法状态的跳转必须截断,否则会出现订单已核销还能退款、已退款订单仍能验票这类业务事故。状态机在代码里我用一个枚举类OrderStatus来定义,配合方法明细校验,比散乱if判断清晰得多。
2. 技术选型与架构设计
2.1 SpringBoot版本与内置组件选择
后端基于SpringBoot 2.7.x构建,选这个版本是因为它在稳定性和生态兼容上最平衡。SpringBoot 3.x引入了Jakarta EE命名空间,很多旧教程和第三方starter还没完全跟上,对新手不友好;2.7.x则处于SpringBoot 2.x末代,不仅全量支持javax,还兼容大量现成案例。如果你在配置环境时遇到版本越界的问题,大概率是SpringBoot版本与JDK版本不匹配——SpringBoot 2.7要求JDK 8以上,3.x需要JDK 17,这是新手最容易踩的第一个坑。
持久层框架没有直接用MyBatis原生,也没有上MyBatis-Plus全家桶,而是用了MyBatis-Plus只取它提供的分页插件和通用CRUD。我个人不太建议在中小型项目里手写大量XML,因为景区管理系统里90%的SQL是单表操作和简单联表,MyBatis-Plus的Wrapper机制几行代码就能完成,而复杂统计场景再退回去写自定义SQL,这样两者兼顾。分页插件需要显式配置PaginationInnerInterceptor,别漏了这一步,否则Page对象查出来永远只有全量数据。
接口文档生成用了SpringDoc OpenAPI,替代了老牌的Springfox。原因很实际,Springfox已经很久没更新,对新版本SpringBoot的兼容性差,启动时报错是常事。SpringDoc活跃度高,SWagger注解也兼容旧写法,更重要的是它原生支持SpringBoot 2.6以后的路由匹配策略改动。
2.2 Vue版本与前端工程构建
前端选了Vue 2 + Vue Router 3的组合,而不是Vue 3的写法。给你一个对比参考:下方表格列出了两代技术栈在当前生态中的真实差异。
| 对比维度 | Vue 2 + Vue Router 3 | Vue 3 + Vue Router 4 |
|---|---|---|
| 上手曲线 | 平缓,大量现成案例 | 陡峭,Composition API需要一定适应成本 |
| 配套组件库 | Element UI极度成熟 | Element Plus仍在迭代期 |
| 招聘市场占比 | 存量项目多,岗位需求大 | 新项目占比上升,但教程质量参差 |
| 本项目适配性 | 路由守卫、组件通信资料全 | 会写,但没必要因为项目简单强上Vue 3 |
前端工程用Vue CLI脚手架搭建,安装依赖时建议设置npm镜像源为国内地址,否则安装过程容易因为网络问题卡死。工程内部目录划分为views(页面组件)、components(公共组件)、router(路由表)、api(axios请求封装)、store(状态管理)。
路由设计采用懒加载模式,按需加载组件,避免首屏白屏时间过长。路由表里有一个容易踩的坑:页面刷新后路由守卫校验CurrentUser时,如果Store中的信息已经丢失,需要重新调用接口获取用户信息,否则会导致登录状态丢失。这里我加上了一层拦截器逻辑,刷新后通过token换取用户信息并重建Store数据,实测下来刷新后页面保持稳定。
2.3 数据库选型与存储设计
数据库用MySQL 8.0,使用InnoDB引擎,字符集统一utf8mb4。选择utf8mb4而不是utf8的原因是对emoji表情的原生支持,游客在用户昵称和评价模块里输入emoji已经是常态,用utf8会直接导致插入报错,这类问题排查起来非常耗时。
既然涉及购票,库存扣减和订单一致性是逃不开的话题。我的设计是Redis预热热销票种库存,下单扣减走Lua脚本保证原子性,数据库只保存最终结果。为什么不用纯数据库下单?因为高并发场景下,先查库存再减库存这两步之间存在时间窗口,乐观锁虽然能兜底,但代价是大量请求失败重试。Redis的DECR操作本身就是原子的,多个用户在同一个key上自减不会出现超卖,这是票务系统最务实的并发控制方案。
数据库表总共7张:用户表、景区表、票种表、订单表、订单明细表、支付流水表、核销记录表。这个表设计里有一个很多人忽略的点,订单表和订单明细表为什么刻意分开?因为一个订单可能包含多个票种(比如门票+观光车票),也包含多人多票(比如3张成人票+1张儿童票),订单明细每行记录票种和数量,而订单整体金额是明细的汇总。这样的设计便于扩展优惠券、会员折扣等附加业务。
2.4 工程目录结构与模块划分
项目采用Maven多模块结构,从代码组织上就把后端的职责拆开。工程分为四个子模块:
ticket-server(聚合父工程) ├── ticket-common(通用工具类、统一返回结果、异常码) ├── ticket-pojo(实体类、DTO、VO) ├── ticket-mapper(MyBatis持久层) ├── ticket-service(业务逻辑层,含service接口与实现) └── ticket-web(Controller层、启动类、配置类)这种分层的理由很单纯,避免实体类、工具类、业务接口混在同一个包下,代码量一上来根本没法维护。而且多模块结构天然隔离了依赖,web层引service依赖,service引mapper依赖,单向依赖清晰,协作开发时也不需要担心别人改了一行代码把你的模块牵连崩掉。
前端目录与后端模块对应,views下面的订单相关页面放在order包下,组件复用放components下。实际开发中我养成了一个习惯,api目录的文件名与后端Controller路径对应,例如order.js对应后端/api/order接口,查问题的时候,从前端定位到后端接口只需要按文件名索引,效率比全局搜索高很多。
3. 后端核心模块实现
3.1 用户登录注册与JWT鉴权机制
登录模块用的是JWT令牌方案,用户在登录成功后,后端把userId存进token的claims中,设置7天有效期,返回给前端。前端把这颗token存在localStorage中,并注入到axios的请求头里,每次请求检查token是否存在。服务端有一个全局拦截器JwtInterceptor,对所有接口统一做token解析与验签,除了白名单(如登录、注册、景区列表查询)之外,其他接口受保护。
JWT有一个天然的服务端依赖问题:服务端签发后,无法主动让它失效。用户短期内不能自身定制过期,这在实际中有隐患。比如管理员封禁了一个买家,但对方的token在剩余有效期里依然能访问接口。我的做法是额外加一层Redis存储失效标记,token发布时记录在Redis中,并设置TTL。拦截器在验证JWT签名后,再去Redis查是否存在,如果被标记踢出则拒绝请求。这样既保留JWT无状态的优势,又解决了主动失效问题。
密码存储不能明文。我使用BCryptPasswordEncoder加密,SpringSecurity框架虽然没有完整引入认证流程,但加密工具类单独用了。BCrypt的加密特性是每次生成的盐值不一样,所以同一个密码两次入库的hash值也不一样,安全性比MD5高一个量级。注册时多校验一遍密码强度,至少8位,含字母和数字,别嫌麻烦,用户数据安全要放在心上。
3.2 景区信息管理与文件上传
景区管理这部分既是业务配置,也承担了后台展示功能。实体设计上,除了名称、介绍、地址等基础字段,还有经纬度字段用于地图定位,以及是否上架的开关字段。这里的核心在于上架状态与前端展示联动,后台把某景区下架后,用户端首页立即不可见,订单查询时也不可下单。这需要接口层加过滤,不能只靠前端隐藏,否则用户直接请求下单接口依然会暴露。
景区图片上传用的方式是对象存储MinIO,不是本地目录。本地存储的劣势在部署时立刻暴露:图片随应用进程一起打包,清日志或重新发版容易丢,而且应用多实例部署时文件不共享,导致部分图片404。MinIO以对象形式存储图片,接口返回图片的URL地址,前端直接展示。我的上传接口设了单文件5MB上限,格式校验只灰度jpg、png、webp三种,这两个限制能避免大部分恶意上传问题。
有一次项目现场部署,客户服务器的MinIO启动失败,导致上传接口大面积报错。排查后发现是磁盘空间不足,MinIO默认文件分片存储,连续上传多张图就把小磁盘塞满了。后来我在上传接口前增加了一个磁盘空间预检查,低于200MB直接返回友好提示,这个细节在线上环境很管用。
3.3 订单生成流程与库存扣减
订单生成是整个系统的核心。用户在前端选择日期、票种和数量,提交订单时后端执行如下步骤:
- 校验票种是否在售,库存是否充足
- 通过Redis的Lua脚本原子扣减库存
- 创建订单主表记录,计算总金额
- 创建多条订单明细,记录每个票种的数量和单价
- 返回订单号和待支付金额
这里有一个关键的幂等性问题:用户如果连续点击两次提交按钮,前端应该禁用按钮,但前端限制并不能完全防住恶意请求。我的方案是用Redis存储用户维度下单锁,例如:SET order_lock_userId 1 EX 3 NX,3秒内重复提交直接拒绝。这个粒度能扛住误触和恶意刷接口,又不影响同一用户的多个有效订单。
库存扣减如果失败,需要回滚已经创建的所有数据,这里使用@Transactional事务。要注意的是,Redis库存已扣但数据库事务回滚后数据不一致,所以库存扣减放在数据库事务执行成功之后再执行,扣减失败则告知用户库存不足,不会造成两边数据同步问题。我的建议是逻辑顺序为校验数据库库存、创建订单、事务成功后扣Redis库存,这样一致性更可靠。
订单流水号的设计也有讲究,我用时间戳+用户ID后四位+随机数拼成20位字符串,既满足唯一索引的要求,也能从订单号上快速倒推查询条件。企业级场景如果追求更高性能,可以引入雪花算法生成全局ID,这个项目用时间戳方案已足够。
3.4 模拟支付与回调机制
真实项目的支付接入通常是微信支付或支付宝,但作为源码演示项目,我做了两层设计:接口层完全模拟真实支付体系,提供支付受理、支付结果查询、支付回调三个接口,方便你在此基础上接入真实支付渠道。演示模式下,用户点击确认支付,前端弹窗二维码页,后端直接把订单状态置为已支付,模拟支付成功的回调动作。
模拟支付的启发点是回调重试机制。真实支付场景中,回调通知可能因为网络原因发送失败,必须让状态处理具备幂等性。我在支付回调接口中做了约束:无论回调到达多少次,只有首次能把订单从待支付改成已支付,后续回调最多返回成功标识,不能修改订单状态。这个逻辑通过状态机校验实现,看一眼前端判断condition即可,不要多传数据,避免脏写。
订单超时关闭用的是延迟关闭方案。用户下单后15分钟未支付,订单自动取消并回补库存。实现上采用Redis的key过期监听事件,键值设置为订单号,15分钟后过期,监听器收到过期事件后判断订单状态是否为待支付,是则关闭订单。这个方案的缺点是Redis过期事件并非精确时刻触发,会有秒级延迟,但业务上可以接受。比定时任务轮询表高效得多,也不需要在低频业务上引入集合框架。
3.5 核销验票与二维码生成
核销是线下场景的重头戏。游客支付成功后会得到一张电子票,订单列表页展示一个二维码。工作人员在验票页面使用扫码枪或者手机扫描,后端根据二维码里的凭证码(一个32位的UUID)查找订单和明细数据。校验逻辑包含三层:
- 凭证码是否存在
- 订单是否处于已支付状态
- 是否已核销过,已核销则拒绝并返回核销时间
核销成功后,核销记录表中插入一条新记录,关联订单号、操作员ID、核销时间。核销记录的数据能支撑后续运营分析,比如每日入园人数统计、热门景点核销热度分布。没有独立核销记录表的话,这些统计做起来会很别扭,需要从订单表里反查,性能和结构都不够清晰。
二维码的生成用Google的ZXing库,把凭证码生成QRCode输出到前端页面。这个二维码的有效期我在设计时设了24小时,过期后凭证码失效但用户可刷新换码。别小看这个细节,之前有景区遇到过黄牛截图二维码卖给多个人,虽然核销时第一次已验证能拦截,但截图外泄无法完全杜绝。增加动态刷新时间一定程度缓解了风险,这是低成本解决方案里的最优解。
3.6 MyBatis-Plus分页与多条件查询
订单列表、用户列表、景区列表这些管理端页面都需要分页查询。我用MyBatis-Plus的分页插件,配合自定义Wrapper构造条件查询。例如订单查询支持按订单号模糊搜索、按下单时间范围筛选、按状态条件筛选、按用户ID精准查询,这些条件动态拼装,Wrapper的Lambda写法能避免字段名拼写错误。
分页对象返回结构统一,包含当前页数据List、总条数total、页数pages、当前页码current。前端表格组件拿到这些字段后直接渲染分页器。有一个容易被忽视的细节:总条数total是查询接口额外执行count查询得到的,如果列表查询逻辑过于复杂(比如订单表关联用户表,用户表又关联景区表),count查询的性能会拖垮接口。这个时候推荐对count优化,只count主表的id,不去做多余关联,节省数据库的开销。
管理端的订单筛选场景偶尔会涉及大结果集,比如一周订单量上万。我设置了分页上限,单页200条,超过200的请求自动截断并提示用户缩窄筛选范围。这个限制是一个保护机制,防止有人导出全量订单接口导致数据库慢查询让整个服务超时崩溃。
4. 前端核心模块实现
4.1 Vue Router路由守卫与登录状态管理
前端路由表分为三个区域,对应不同权限:开放路由(首页、景区列表、景区详情)、用户路由(购物车、订单提交、我的订单)、管理路由(后台所有页面)。路由守卫的钩子函数在每次导航前执行,逻辑如下:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login' && token) { next('/') } else if (to.meta.requiresAuth && !token) { next('/login?redirect=' + to.fullPath) } else if (to.meta.requiresAdmin && !isAdmin()) { next('/403') } else { next() } })登录状态的持久化不能只靠检查localStorage里的token是否存在,因为前端无法判断token是否过期。我封装了一个方法,在路由守卫中调用接口GET /api/user/info获取当前用户信息,如果接口返回401,则清除本地状态并跳转登录页。这里本质上每一次刷新页面都会触发一次用户信息查询,请求量会增大,所以我加了缓存策略,用户信息存入Vuex,刷新后只有在Store中不存在时才发请求。
动态路由的管理端模块,不需要在静态路由配置里写明,而是在管理员登录后动态addRoutes。这种设计的好处是,普通游客直接访问后台URL只会命中404页面,看不到实际页面结构。虽然不是绝对安全(真正的安全由后端接口保证),但已经在最小暴露层面上拦了一道。
4.2 Axios请求封装与统一错误处理
我封装了一个独立的request模块,统一配置axios实例。项目里用了baseURL单独配置,方便开发环境用proxy转发,生产环境用nginx代理。拦截器里做了三件事:请求拦截统一加入token头部、带上时间戳params防缓存;响应拦截统一处理HTTP错误码,2xx直接返回data,4xx提示参数错误或未登录,5xx提示服务异常;特殊业务码如库存不足直接返回message提示。
错误处理的精华在message提示的统一化。前端不管在哪个页面,请求失败弹出统一的toast组件,错误信息全部取自后端返回结果。做这个系统的阶段,我把后端加密返回码都做了归一化处理,这样测试阶段只用看页面提示就知道问题出在哪一层。排查问题时,前端network面板看到的是HTTP状态,后端日志能看到业务异常码,两者组合对照定位很快。
上传文件接口是一个例外场景,请求头不能带Content-Type: application/json,而是用FormData形式。我的封装里给上传接口单独写了一个实例配置,避免全局拦截器对上传内容的干扰。如果你做系统涉及表单提交、文件上传,多确认一个细节:multipart/form-data不要走JSON序列化的拦截器。
4.3 用户端页面设计与交互流程
用户端的核心路径是:首页推荐景区列表、详情页查看票种、下单页选日期人数、支付页确认金额。首页的景区列表采用卡片式设计,展示景区海报、名称、评分、门票起价。价格这个字段我专门做了排序逻辑,抛物线价格(就是特价票)需要排序,但排序依据不能直接按票种表里的价格字段,因为一个景区有多个票种,所以查询景区时通过子查询获取该景区最低票价作为排序字段,这样首页才展示出最便宜的景区。
详情页的票种筛选支持按票种类型(成人票、儿童票、学生票、团队票)分组展示,左侧是景区介绍和图集,右侧是为购票面板。购票面板选择了日期+票种数量,底部实时计算购物总金额。这里前端展示的价格需要通过后端接口实时获取,不能从详情页静态写死,因为后端可能调整票种价格或日期限购数量。
下单页使用积分系统?不对,没有积分。我把购物车、地址、联系人信息合并到一张页面,游客选择完票种后,只需要填写一个取票人手机号即可提交。这个流程是为了降低下单流失率,大部分景区购票不需要实名到每个人,只留一个联系人手机号足够后续发送入园通知。
4.4 管理端页面实现要点
管理端的页面相比用户端更直接,功能导向更重。我实现了四个数据密集型页面:景点列表页(支持信息编辑、上下架切换、图片上传)、订单管理页(支持多条件组合筛选和订单详情查看)、核销验票页(扫码枪输入框自动聚焦)、统计报表页(按日和按月展示营业额气泡图)。
表格页使用Element UI的el-table组件,配合el-pagination分页器。这里有个小技巧,当表格列数较多时,启用show-overflow-tooltip属性让超出列宽的内容以悬浮提示展示,而不是撑破表格高度。管理端的数据表格避免使用过长文本列,大字段内容详情页展示,列表页只保留概要信息,这个设计原则能让后台页面在高分辨率下依然整齐。
统计报表页我用ECharts做折线图和柱状图,查询接口支持时间粒度切换,日统计按7天近况展示,月统计按近12个月展示。数据由后端聚合统计返回,前端只负责渲染。这种方案比前端全量获取数据再做聚合运算更能控制查询响应速度,特别是订单量上来之后,前端的JavaScript做大量统计计算压根扛不住。
5. 数据库设计与核心索引
5.1 表结构分析
订单表是这个项目中最具代表性的表,结构设计如下:
CREATE TABLE `t_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` varchar(32) NOT NULL COMMENT '订单编号', `user_id` bigint NOT NULL COMMENT '用户ID', `scenic_id` bigint NOT NULL COMMENT '景区ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总额', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2已核销 3已退票 4已取消', `contact_phone` varchar(11) DEFAULT NULL COMMENT '联系人手机号', `visit_date` date DEFAULT NULL COMMENT '游玩日期', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`), KEY `idx_scenic_visit` (`scenic_id`, `visit_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;订单号增加唯一索引,是防止重复下单兜底。访问订单详情接口时优先按order_no查询,因为它是唯一键且用户感知度高。用户维度的查询走idx_user_id,景区与游玩日期组合索引是为了应对管理端查询某个景区某天的订单量,这类SQL在核销和对账场景中非常高频。
票种表与库存的设计也可以说两句。库存没有直接放在票种表字段里,因为票种表本身承载了大量描述属性,每次并发更新库存都会造成行锁竞争,影响其它查询。单独建一张t_inventory表,记录景区ID、票种ID、日期、总库存和已售数量。以日期为维度存储的原因很简单,景区门票库存与日期强相关,节假日放的库存与工作日不一样,如果不拆日期,根本无法支持不同日期的限购策略。
5.2 事务与并发控制细节
理解事务控制的关键场景在订单创建流程里。前文提到了Lua脚本扣减Redis库存,数据库事务在订单主表与明细表都插入成功后提交。事务的隔离级别使用默认的REPEATABLE READ,对单一订单创建场景没有副作用。
还有个隐藏点你需要注意,我在订单创建时对同一个用户加了一把Redis锁,锁粒度控制在3秒。但如果是购买多人多票的场景,同一用户短时间内多次提交是合理的,所以锁的判定条件是同一用户创建订单的间隔时间在1秒内才拦截。实践经验是,你不要把锁粒度挂在接口方法上(比如对所有createOrder请求加锁),那会直接把高并发下单堵死,粒度细到用户维度是最合适的。
库存回补的场景我单独抽了一个方法,取消或退款时调用,内部使用Redis事务保证回补+状态更新原子性。代码里处理了并发取消的极端情况,用Redis的compareAndSet判断状态,两个取消请求同时到达时只有一个能成功执行。
5.3 定时任务与过期订单处理
超时订单关单是票务系统绕不开的需求。我用了SpringBoot自带的@Scheduled定时任务做双保险,与Redis过期监听配合。
为什么做双保险?Redis过期事件在两种情况下会失效:一是Redis进程重启后事件丢失,二是数据持久化策略不当导致过期键未触发。一旦事件丢失,订单就会一直躺在待支付状态,库存也被占用,影响其他游客购票。因此保留一个兜底定时任务,每5分钟扫描一次超过15分钟未支付的订单,强制关闭回补库存。
定时任务的幂等性靠状态机校验保证:执行批量关闭时,先更新状态条件限制为待支付,更新行数为0的说明已经被处理过,跳过。这里不需要使用乐观锁版本号,因为更新语句本身通过条件限制实现了原子性。这个方法比先查询再更新的传统写法在并发场景下安全得多。
定时任务建议在配置文件中增加开关,方便测试环境不想跑任务时手动关掉。线上环境建议配置成每5分钟执行一次,SQL扫描范围控制在超时时间点加一个小窗口,避免每次都全表扫描拖垮大表查询。
6. 常见问题与排查技巧实录
6.1 前后端联调与跨域配置
前后端分离项目遇到的第一座大山基本是跨域问题。开发环境下,前端地址是localhost:8080,后端接口是localhost:8081,端口不同即触发浏览器跨域保护。我的解决方式分两种:开发环境用Vue CLI的proxy代理,配置vue.config.js里的devServer代理将/api前缀转发到后端地址,浏览器看到的就是同源请求,也顺便解决了Cookie携带问题。生产环境用Nginx反向代理,把前后端挂在同一个域名下,后端路径加/api前缀,由Nginx做流量转发。
如果你遇到前端请求返回CORS error,先别急着在后端加@CrossOrigin。要先排查你请求的地址是否经过代理:如果前端访问的是http://localhost:8080/api/xxx但代理没生效,nginx和后端都会返回失败。检查顺序是浏览器Network标签看请求URL、后端日志看有没有请求进来、代理配置文件看路径转换是否正确,三步走几乎能覆盖所有跨域类问题。
6.2 数据库时间问题与JSON序列化时区
联调中容易踩的一个坑是时间字段在数据库和前端之间的序列化格式不一致。MySQL默认返回的日期时间是datetime格式,后端直接序列化成Java的java.util.Date后,Jackson默认输出的是一串时间戳数字,前端拿到后无法直接渲染。
我配置了全局Jackson格式化,日期统一为yyyy-MM-dd HH:mm:ss,Java侧字段类型使用LocalDateTime配合@JsonFormat注解。注意一个细节:数据库连接串里增加serverTimezone=Asia/Shanghai,否则Java 8时间类型与MySQL交互时会有8小时的时区偏移,典型的症状是前端看到的时间比实际时间少8小时。这个坑排查了很久才发现是时区配置问题,不是代码逻辑问题。
6.3 第三方组件兼容性与版本踩坑
这个技术栈里隐含的兼容坑比较多,我简单整理了一个速查表:
| 组件 | 版本 | 注意事项 |
|---|---|---|
| SpringBoot | 2.7.x | 不要直接用3.x,JDK要求不同 |
| MyBatis-Plus | 3.5.x | 分页插件需手动配置 |
| Redis客户端 | Lettuce | SpringBoot 2.x默认,无需加jedis |
| ZXing | 3.4.x | 二维码生成时注意字符编码为UTF-8 |
| Vue CLI | 4.x | 默认webpack配置,无需额外调整 |
| Element UI | 2.15.x | 与Vue 2配合最稳定 |
Redis版本与SpringBoot 2.7整合时有个隐藏问题:Redis 6.0以后启用了RESP3协议,而Lettuce客户端默认还是以RESP2方式连接,如果服务端强制使用RESP3,会出现连接失败。解决方式是显式设置Lettuce的协议版本为RESP2,代码里加一行连接工厂配置即可。因为默认情况下绝大多数云Redis实例不会强制开启RESP3,所以实际踩到的人不多,但如果遇到了知道原因在哪里。
6.4 调试技巧:日志输出与接口排查
我习惯在两个关键位置打印日志:接口入口处输出请求参数,业务核心流转处输出订单号和状态变化。日志级别在开发环境设为DEBUG,生产环境设为INFO。排查问题时先用接口文档或Postman模拟请求,查看后端日志中真正执行的SQL语句,通常MyBatis-Plus会打印Preparing与Parameters,这两行能把具体SQL和数据看个大概。
如果前端页面报了某个接口500,第一步是看浏览器Network面板。第二步看后端控制台抛出的异常栈,大多数场景是空指针或SQL异常。第三步根据异常类型缩小范围:如果NullPointerException出现在订单创建,优先检查入参里景区ID和票种ID是否为null;如果DuplicateKeyException出现在订单号生成处,就是唯一索引冲突,检查重试机制是否合理,别简单返回失败就完事。
7. 项目部署与文档体系
7.1 本地开发环境启动指南
从零开始启动这套系统的顺序我梳理一遍。第一步装环境,JDK 8以上、Maven 3.6+、MySQL 8、Redis 6。第二步导入数据库脚本,项目中提供ticket.sql全量初始化脚本,包含建库、建表、种子数据,直接用Navicat或命令行source执行。第三步后端启动,修改application.yml中数据库账号密码与Redis连接地址,启动启动类。第四步前端启动,npm install安装依赖后npm run serve。四步完成后浏览器访问前端地址即可看到系统首页。
后端启动时常遇到的两个报错:端口占用和数据库连接失败。端口占用通过修改application.yml中的server.port解决,数据库连接失败十有八九是密码或IP配置错误。Redis连接失败时启动类会报Lettuce连接异常,确认Redis服务已启动且bind地址允许外部访问。
初始化数据的账号密码要提前确认,我在文档里给出两个预置账号:游客账号user/123456,管理员账号admin/123456。项目交付时文档里必须包含账号信息,不然对方找不到入口登录管理后台,第一印象会大打折扣。
7.2 文档结构与内容说明
项目压缩包里文档覆盖面,对该类完备度做个参考:
docs ├── 需求规格说明书.docx(含功能清单、角色权限说明、业务流程时序图) ├── 数据库设计说明书.docx(含表结构说明、E-R图、字段释义) ├── 接口文档.docx(含全部接口列表、请求/响应示例、错误码定义) ├── 部署文档.docx(含环境要求、启动步骤、运维注意事项) └── 系统演示录屏.mp4(含用户端与管理端操作演示)需求文档的价值在于能让读源码的人第一时间搞清楚业务闭环,要知道票务系统的订单状态流转、退款规则、核销规则不是靠读代码就能迅速理解的。接口文档用表格列出每个接口的请求方法、路径、参数类型和返回码,与代码注释和Swagger页面三者互相印证。
毕业设计场景下,我特别建议把数据库设计说明书补充完整,E-R图和字段释义表是答辩环节评委大概率提问的部分。如果文档里只有源码没有设计说明,现场效果会打折扣。
7.3 二次开发扩展方向
这套系统留了几个方便扩展的口子,简单聊聊可以往哪些方向继续做:
一是接入真实支付渠道。模拟支付服务层已经抽象出支付适配器接口,你看一下pay包的实现就知道怎么对接支付宝当面付或微信Native支付。回调接口的幂等处理已就绪,接入真实渠道时只需要替换支付请求和验证逻辑,订单与库存部分完全不用动。
二是增加短信通知。下单成功和出票后给用户发送通知,可以考虑接入阿里云短信服务,只需要在订单状态变更时调用发送接口即可。目前项目里预留了message包的空实现,照着填充就行。
三是多景区票型策略。当前库存模型是按日期+票种存储,如果你想做分时段预约(比如上午场/下午场),在库存表增加一个time_slot字段即可,查询和扣减逻辑变化不大,前端联动增加一个时段选择组件。
四是抽成一个中心化的票务API网关服务。把景区管理、订单服务、核销服务拆成独立模块,用OpenFeign相互调用。这是规模扩大后的自然演进方向,但当前单体架构在小体量业务下性能完全够用,不必过度设计提前拆服务。
8. 部署演示与线上运行心得
8.1 从开发到上线的完整部署记录
我在一台2核4G的云服务器上做过完整部署,操作系统Ubuntu 22.04,在这个配置上带并发量扛住一个小型景区节假日高峰没太大压力。部署架构如下:
Nginx(监听80端口) ├── / 前端静态资源(Vue部署后生成的dist目录) └── /api/ 反向代理到后端8081端口 后端Java进程(jar包运行) MySQL 8.0(端口3306) Redis 6.x(端口6379)打包顺序先前端后后端。前端执行npm run build生成dist目录,上传到服务器/usr/share/nginx/html目录。后端用maven package打成jar包,上传后用nohup启动:
nohup java -jar ticket-server.jar --spring.profiles.active=prod > logs/app.log 2>&1 &生产环境配置与开发环境的差异主要在三处:数据库连接池大小上调、Redis连接超时时间适当拉长、上传文件大小限制放宽。这几项配置我放在application-prod.yml里,通过启动参数指定profile环境切换,这种方式按环境隔离配置比改application.yml再重新打包要高效得多,也避免测试环境配置被带到生产环境。
8.2 系统上线后的性能调优实践
上线后的监控发现一个性能瓶颈:管理端订单列表页在订单量超过5万时打开需要3秒以上。排查发现是默认查询没有走索引,订单表的create_time字段没有加索引,按时间范围筛选时全表扫描。给create_time加上了普通索引后,同样的查询降到200毫秒以内。这个经验特别适合写论文和面试——索引的使用直接决定系统性能表现。
另一个调优点在于Redis连接池配置。SpringBoot默认的Lettuce连接池初始连接数较小,在高峰时段会出现获取连接超时。初始连接数调大到16、最大连接数调到64,连接等待时间设置500ms,实测下来高峰时段的超时问题明显缓解。别动不动就上分布式缓存,先把连接池配好,性价比更高。
前端做了两处性能优化,一处是静态资源打包时开启gzip压缩,Nginx配置gzip on,页面首屏体积压缩近60%;另一处是图片懒加载,首屏以外的景区卡片图片使用v-lazy指令延迟加载,大幅提升长列表页的滚动流畅度和初始加载速度。
8.3 运维监控与安全加固建议
安全层面我做的加固措施包括:SpringBoot的Actuator生产环境关闭所有敏感端点,只保留health健康检查;所有管理端接口在拦截器中校验管理员角色;上传接口做了类型白名单限制,拦截了可执行脚本上传;数据库连接使用最小权限账号,不使用root。
定时备份是运维环节的必备项。我推荐部署一台服务器安装cronjob执行数据库定时备份任务,每天凌晨备份MySQL和Redis持久化文件,保留最近7天备份。至于备份恢复演练,我建议每套系统上线后至少手动实践一次恢复流程,别等到磁盘故障时才第一次尝试恢复操作,那种条件下的体验会很被动。
我会在文档中强调一点:不要把生产环境的数据库密码和Redis密码写进application.yml里的明文配置,使用环境变量或外部配置中心托管。这是基本的安全习惯,运维同学接手时会更放心。
9. 课程设计场景的应用参考
9.1 本系统与Java课程设计的契合度
如果你正处于Java课程设计或毕业设计阶段,这个题目的体量确实合适。景区在线购票系统天然覆盖了Java技术栈课程里最核心的知识点:面向对象设计、SpringBoot自动装配、MyBatis持久层映射、事务管理、接口开发、权限控制。在论文或报告里针对每一项展开写,都有真实代码支撑,不担心答不上来。
与常见的“XX管理系统”(学生管理、图书管理)相比,景区购票系统多了库存并发和订单状态机这两块硬核设计,答辩时会让评委觉得你的课题有真实业务复杂度。我第一次做这个课题时,就在并发扣减这块被追问了很久,如果你能把Redis原子操作和数据库事务的一致性取舍讲清楚,答辩基本稳了。
9.2 答辩常见问题与思路梳理
我梳理了几个答辩高频问题,提前准备一下:
一是“为什么选择SpringBoot而不是SpringMVC或SSH?”回答思路上说SpringBoot简化配置、内嵌Tomcat、自动装配机制,照顾到开发效率,同时后端通过JWT拦截器控制权限,比传统XML配置的SSH方案工程化程度高。
二是“如何解决超卖问题?”回答思路强调Redis Lua脚本保证原子性,以及订单创建成功后才扣减库存的顺序,再补充数据库乐观锁作为兜底方案。把这三层防护讲清楚,基本能展现你对并发控制的理解深度。
三是“订单未支付会一直占用库存吗?”答Redis过期监听与定时任务双保险的配合流程,讲清兜底机制设计,体现方案的工程严谨性。
四是“前后端是如何交互的?”答axios请求封装、Vue Router路由、代理转发、Nginx部署这四层链路,顺带提接口文档规范和错误码统一,这个回答既有全局观又有细节。
本项目为答辩准备的文档里包含完整的系统演示录屏。答辩演示环节不要打开代码就翻页面,而是按“用户购票流程一管理端核销流程一后台统计”三个核心场景串一条线,整个演示控制在五分钟内,重点在于让评委看清楚前后端数据是如何流转的,这个直观印象比语言解释更有说服力。
10. 源码使用与二次开发路径
10.1 开源项目目录与代码导航
拿到源码压缩包后,建议按“后端启动并跑通接口一前端页面联调一理解核心业务流转”的顺序去学习代码。后端从ticket-web模块的启动类进入,顺着Controller层的接口定义,逐步追踪到Service实现类,你会发现订单、支付、库存、核销四条业务链路的代码路径非常清晰。
代码注释风格我做了标准化,关键方法上都有中文注释说明业务意图,类头有作者的创建时间和功能描述。前端代码的重点文件如下:router/index.js看路由设计,store/modules/user.js看登录状态逻辑,api/order.js看订单接口封装,views/order/detail.vue看订单页面的交互实现。按这个顺序读下来,前后端的数据流转就心里有数了。
10.2 常见二次开发场景及代码影响面
改数据库连接与Redis配置这类环境性改动,只涉及application.yml,不需要碰任何业务代码。改票种的库存逻辑,需要同时改t_inventory表结构、库存Mapper接口以及订单创建时的扣减逻辑,三个地方必须同步改,否则库存数据会不一致。新增一个支付渠道,改动范围集中在pay包下的适配器类和支付回调接口,订单状态机的枚举类如果不需要新增状态,基本可以不动。
整个项目中包结构遵循通用分层的简单原则,每个层只做自身职责范围内的事。你如果要放进简历做项目经验,我有几点建议:重点写清楚系统解决了什么问题、你用到了哪些关键技术、你在项目中具体负责的部分,比如“设计并实现了基于Redis的库存管控模块,支撑单日过万张票的售卖,无超卖情况”。简历里写项目,务必写结果、写量化数据,比写技术名词堆砌更有说服力。
10.3 最终交付内容清单
最终交付的压缩包里应该包含以下几类文件,拿到手先检查是否完整:后端完整源码(含数据库初始化脚本、多环境配置、jar包)、前端完整源码(含Vue工程、构建后的dist目录)、全套文档(需求、数据库、接口、部署四份说明),以及一份演示录屏。如果哪个部分缺失,都值得备注一下后续补全。
我第一次做这类项目时,吃过不少亏。最大的教训是在数据库设计阶段没有预留扩展字段,导致后期加功能时要频繁改表结构。你得吸取这个经验,在设计表时为景区、订单这类核心表预留1到2个varchar扩展字段,虽然看起来有点丑,但为二次开发省下很多时间。另外,做技术选型前想清楚你当前处于什么阶段,是学习、做课程设计还是上线商用,不同阶段对你的架构选型决策要求完全不同,别一上来就上微服务,把单体做好已经能应对绝大多数业务了。