
从立项到落地我如何用 SpringBootVue3MyBatis 给老年人做了一套景区订票系统今年年初接了个挺有意思的项目给本地几家景区做一套面向老年人的订票系统。拿到需求的时候我第一反应是——这不就是个常规的后台管理系统加一个订票小程序嘛SpringBoot 一搭、Vue3 一套、CRUD 一写就能交差。但真正跟老年用户群体、景区运营方、还有现场志愿者聊过之后才发现这个常规项目里全是坑而且每个坑都跟用户是老年人这个前提强相关。这套系统最终落地采用的是Java SpringBoot Vue3 MyBatis MySQL的前后端分离架构源码已经整理归档。我把整个项目从需求分析、表结构设计、接口实现到前端适配的完整过程拆开讲讲重点说那些书本和视频教程里不会告诉你、但实际开发中绕不开的细节。如果你正准备做类似的景区订票、场馆预约、票务管理系统或者你只是想看看前后端分离项目里 SpringBoot 和 Vue3 到底怎么配合、MyBatis 怎么写才能既灵活又不出幺蛾子这篇内容应该能帮你省下不少走弯路的时间。1. 项目定位与整体设计思路1.1 核心需求解析老年用户到底需要什么样的订票系统刚拿到需求文档时里面只有三句话景区门票在线预订、支持老年优惠票、前后端分离。这三句话看起来没有任何信息量但你去景区门口蹲半天就能明白问题有多具体。第一老年用户不会像年轻人一样自己在网上翻找景点、对比票价。绝大多数情况下是子女帮父母订票或者景区志愿者代订。所以这个系统不能做成用户自选式的复杂电商流程而要做成引导式的极简流程。第二老年用户对手机操作的精通程度普遍有限就算简化到极致的界面仍然存在误触、看不清、找不到按钮的问题。这就要求前端界面字体足够大、按钮足够大、操作路径足够短最好三步之内完成订票。第三老年人出行非常依赖身份证。国内绝大多数景区对 60 岁或 65 岁以上老人有免票或半价政策订票时必须以身份证信息为准进行实名验证而不是简单的手机号注册。这意味着用户模块从设计第一天起就要把身份证作为核心业务字段不能像普通电商那样只存昵称和手机号。第四还有一个容易被忽略的群体——不会用智能手机的老人。系统不能假设所有用户都能网上支付所以要预留代客下单 线下核销的模式。这是后面订单状态设计里很重要的一个分支。基于这些分析我把系统定位成以身份证为核心、以极简快签为体验、以多端适配为目标的票务预订平台。技术上很常规但业务约束非常具体。1.2 技术选型背后的考量为什么是 SpringBoot Vue3 MyBatis MySQL技术选型环节团队内部也讨论过要不要用微服务、要不要上 Redis、要不要引入 MyBatis-Plus 或者 Spring Data JPA。最终敲定的方案非常务实SpringBoot选它不是因为热门而是因为生态成熟、封装度高、社区资料全。这个项目里没有特别复杂的高并发场景SpringBoot 默认的单体架构配合合理的分层足以支撑几千上万的日订票量没必要为了技术而技术地拆微服务。Vue3Vue3 的 Composition API 在处理表单校验、状态管理、组件复用上比 Vue2 写起来顺手得多而且生态里像 Pinia、Vite、Element Plus 这些配套工具已经非常稳定。前端部分除了面向用户的自助购票界面还有一套后台管理界面Vue3 一个技术栈全搞定。MyBatis选 MyBatis 而不是 MyBatis-Plus 或者 JPA原因是票务系统的 SQL 相对复杂——多表关联查询订单、按日期维度锁定库存、统计报表等场景MyBatis 可以精确控制 SQL不跟框架较劲。后期想加缓存也好控制。MySQL数据量不大百万级订单已经很夸张单机 MySQL 完全够用。选 8.0 版本窗口函数、公共表表达式这些新特性在统计报表时很有用。这里有个很多人会踩的坑一上来就堆技术栈Redis 做缓存、MQ 做消息、ES 做搜索。对于一个景区订票项目这些完全是多余的复杂度。系统上线后的真实瓶颈往往在业务逻辑层——比如库存扣减的并发控制、退款状态的一致性而不在高并发流量。技术选型要匹配真实业务规模这个原则在中小型项目里比技术先进性重要一百倍。2. 系统架构与数据库设计2.1 前后端分离架构下的模块划分前后端分离是这个项目的基础架构。后端拆成四个核心模块用户认证模块、景区与票务模块、订单交易模块、统计报表模块。前端拆成两个入口用户端购票页面和后台管理页面。后端我用的是经典的多模块 Maven 结构但要在代码层面严格分层避免出现 Controller 里直接写 SQL、Service 里堆业务逻辑的情况。每个模块内部统一按照 Controller → Service → Mapper 三层结构组织职责划分非常明确。Dev 环境下前端通过 Vite 的代理把/api开头的请求转发给后端的 8080 端口从而规避跨域问题。生产环境更简单——前后端都部署在同一台服务器上用 Nginx 把静态页面和动态接口请求分发到不同端口既不需要额外处理跨域又能利用 Nginx 做静态资源缓存和 Gzip 压缩。这里有个细节值得多说一句跨域配置不是配了 vue.config.js 就万事大吉。如果你用的是 SpringSecurity 或者拦截器还要确保 CORS 预检请求OPTIONS不被拦截器拦截掉同时 Contoller 返回结果里正确带上 CORS 响应头。这个坑我见过太多次前端配好了代理之后本地没问题一上生产换域名就头大。2.2 数据库表结构设计的核心思路数据库设计是这次项目里最花心思的部分。表不多一共 6 张核心表但每张表设计时都围绕票务这个业务特点做了针对性考虑。用户表我取名为user_account除了常规的id、username、password、phone之外重点设计了id_card_no身份证号、real_name真实姓名、birth_date出生日期和user_type用户类型。这几个字段看似冗余实际上大有用处——票务系统需要按身份证判定是否符合老年优惠条件如果把出生日期存成独立字段查询时直接按生日区间筛选即可不需要每次都用身份证号去解析。手机号在设计时设成了唯一索引因为登录、接收取票码都要靠它。景区表scenic_area字段比较常规景区名称、简介、封面图、地址、开放时间、联系电话。值得注意的是加了一个max_daily_visitors每日最大接待量字段这个是后面库存校验的重要依据。门票类型表ticket_type设计成了独立的维度表关联景区 ID包含票种名称成人票、老年票、儿童票、学生票、单价、适用条件说明。这样设计的好处是一个景区可以配置多个票种后续调整价格只需要改这张表不需要动订单表。订单表ticket_order是整个系统的核心。字段包含了订单号order_no雪花算法生成的 19 位数字、下单用户 ID、景区 ID、票种 ID、预约日期、购买数量、订单金额、订单状态、取票码。这里有一个非常关键的设计点订单状态机。我定义为待支付 → 已支付待游玩 → 已游玩 → 已完成 → 已取消 → 已退款六种状态每一笔订单的状态流转都在代码层面做了强校验防止出现已退款的订单还能核销入园这类严重业务事故。订单详情表order_detail单独拆出来而不是冗余在订单表里是因为一个用户可以同时购买多张不同票种的票——比如两个老人加一个儿童系统在后台会分别生成对应票种的明细记录这样后续做统计审计时数据才是干净可追踪的。最后是一张check_in_record核销记录表记录游客持身份证或取票码入园的时间、操作员工号、核销方式。这张表很重要景区运营方每月对账全靠它不能省。订单表关键字段参考字段名类型说明设计考量order_novarchar(32)订单号雪花算法生成全局唯一用于线下查询user_idbigint下单用户 ID关联 user_account 表scenic_idbigint景区 ID关联 scenic_area 表visit_datedate预约游玩日期库存校验的核心维度statustinyint订单状态1待支付 2已支付 3已完成 4已取消 5已退款total_amountdecimal(10,2)订单金额冗余存储避免联表查询pickup_codevarchar(10)取票码6 位随机数字用于线下快速核销2.3 老年用户场景下的特殊数据设计针对老年用户的使用习惯我在表结构设计上额外做了三件事。第一用户表增加is_elderly标记和care_type关怀类型字段。不是所有老年用户都需要特殊关怀有的老人身体硬朗、自己会操作有的则属于陪同入园场景。这个字段让前端可以根据用户画像动态调整界面比如对视力较差的用户强制开启大字体模式对需要陪护的用户在订票时增加陪同人信息录入。第二订单表增加buyer_type字段用来区分本人购票和代客购票。前面提到过很多订单是子女或志愿者代下的有大量用户体验问题出现在取票环节——下单人不是出行人。这个字段配合订单详情里的visitor_real_name和visitor_id_card字段能确保现场核销时流程顺畅。第三预约日期与景区接待能力的库存校验要在数据库层面做。我单独设计了一套可售余票的计算逻辑不是简单地看订单数小于max_daily_visitors而是要考虑不同票种之间的数量配比——比如老年票每天限量 500 张、全价票不限量这种运营规则必须在库存字段里灵活支持。最终实现方案是景区表里增加了一个 JSON 类型的quota_config字段存储每个日期的票种配额这属于 MySQL 5.7 对 JSON 数据类型支持的红利用起来真香。3. 后端核心实现与细节3.1 SpringBoot 基础工程搭建与配置避坑后端工程创建我用的 Spring Initializr 生成基础骨架Java 版本 17、SpringBoot 版本 3.x。选 Java 17 不是因为追新而是因为 SpringBoot 3.x 对 JDK 17 的支持是最稳的而且项目上线后可以享受更长的 LTS 维护周期。pom.xml里最核心的依赖就四个spring-boot-starter-web、mybatis-spring-boot-starter注意版本与 SpringBoot 3 的兼容性、mysql-connector-j、lombok。如果你不想自己写接口文档再加一个springdoc-openapi生成 Swagger 风格文档用于和前端对接口。application.yml里有两个配置值得拿出来说。MyBatis 的驼峰映射必须开启否则你数据库字段是create_time实体类属性是createTime查出来就是 null而且不会报错排查起来特别隐蔽mybatis: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml另外一个坑是 MySQL 连接地址必须带时区参数否则会报数据库连接超时的诡异错误spring: datasource: url: jdbc:mysql://localhost:3306/elderly_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password注意characterEncodingutf8这个参数很多人的中文乱码问题不是代码里没设 UTF-8而是 JDBC 连接串里漏了它。3.2 MyBatis Mapper 层的高效写法与动态 SQL在这个项目里MyBatis 主要承接的是三种类型的 SQL单表 CRUD、多表关联查询、动态条件筛选。后面两种写不好最容易出问题。先说动态 SQL。订票后台的订单查询列表需要按状态、日期范围、景区 ID、关键词订单号或手机号任意组合筛选。如果每个条件都写一个 SQL 方法那代码量不可想象。MyBatis 的if标签是解决这个问题的利器select idselectOrderList resultTypecom.example.entity.TicketOrder SELECT * FROM ticket_order where if teststatus ! null AND status #{status} /if if testscenicId ! null AND scenic_id #{scenicId} /if if teststartDate ! null AND visit_date gt; #{startDate} /if if testkeyword ! null and keyword ! AND (order_no LIKE CONCAT(%, #{keyword}, %) OR pickup_code LIKE CONCAT(%, #{keyword}, %)) /if /where ORDER BY create_time DESC /selectwhere标签会自动处理掉多余的前导 AND这个特性在新手里经常被忽略但你如果手写WHERE 11再拼条件SQL 注入的风险就会悄悄回来。多表关联查询的场景典型的是统计报表——需要查某个月每个景区的销量。这里我用了 MySQL 8.0 的窗口函数一句 SQL 就能算出排名和占比select idselectMonthlySalesRank resultTypemap SELECT scenic_name, total_amount, RANK() OVER (ORDER BY total_amount DESC) AS rank_no FROM ( SELECT sa.name AS scenic_name, SUM(to.total_amount) AS total_amount FROM ticket_order to JOIN scenic_area sa ON to.scenic_id sa.id WHERE to.status 2 AND to.visit_date BETWEEN #{start} AND #{end} GROUP BY sa.name ) t /select窗口函数写起来比在内存里排序高效太多了而且没有多出来的中间层代码。这里还是一个经验之谈能用数据库计算的就不要拖到应用层计算尤其在统计报表场景数据库已经把所有数据都加载了再传给应用层做二次加工纯属浪费。除了 SQL 写法Mapper 层还有一个容易被忽视的细节——参数类型和返回类型的映射。MyBatis 查询返回Map接收时数字类型的字段会被转成Long或BigDecimal前端收到的 JSON 字段类型就不对容易导致 JS 精度问题。稳妥做法是统计查询专门定义 VO 类而不是一律用Map偷懒。3.3 订票核心业务逻辑与并发控制订票是这整个系统的核心业务一共拆成四步校验用户身份、校验库存、扣减库存、生成订单。每一步都不能省。第一步校验用户身份。从请求参数里拿到身份证号先去用户表查询是否已注册未注册则自动创建游客身份然后判断是否满足老年优惠条件。老年优惠的判定规则是动态配置的——有些景区 60 岁免票有些 65 岁半价所以我把规则做成了一个可配置项放在配置表里景区运营人员可以在后台修改。第二步校验库存。针对目标景区和预约日期查询当日已售订单数结合景区每日接待量和票种配额判断是否可售。这一步看起来简单但是要处理一个并发问题两个用户同时提交同一个景区的最后两张票都通过了库存校验结果超卖了。解决办法是给景区加一个乐观锁版本号字段更新库存时带上版本号判断版本不一致则更新失败引导用户重试或者换日期。第三步扣减库存并生成订单。这一步要放在一个事务里执行保证数据一致性。Spring 里用一个Transactional注解就能搞定事务边界一定要覆盖 库存扣减 和 订单创建 两个操作任何一个失败都要整体回滚。这里有个细节数据库的select for update要做在事务内否则锁会在查询结束的时候被释放等于白锁。第四步返回取票码。订单生成后用SecureRandom生成 6 位数字取票码保证随机数强度足够。取票码不总是需要唯一的——如果生成重复了就在代码里循环重试或者直接对取票码加唯一索引靠数据库约束来防重。实测下来在ticket_order表上给pickup_code单独加了一个普通索引而不是唯一索引原因是有奖励机制的羊毛党会反复刷单占掉码段普通索引不影响业务但查询效率有保证。Override Transactional(rollbackFor Exception.class) public Long createOrder(CreateOrderRequest request) { // 1. 校验用户与票价规则 ElderlyUser user userMapper.selectByIdCard(request.getIdCard()); TicketType ticket ticketTypeMapper.selectById(request.getTicketTypeId()); // 2. 校验并锁定库存 int affectedRows scenicMapper.deductStock(request.getScenicId(), request.getVisitDate(), request.getQuantity(), request.getVersion()); if (affectedRows 0) { throw new BusinessException(当前日期余票不足或库存已变更); } // 3. 创建订单和订单详情 TicketOrder order buildOrder(user, ticket, request); orderMapper.insert(order); orderDetailMapper.batchInsert(buildOrderDetails(order, request)); return order.getId(); }deductStock的 SQL 大意如下update iddeductStock UPDATE scenic_day_quota SET sold_count sold_count #{quantity}, version version 1 WHERE scenic_id #{scenicId} AND visit_date #{visitDate} AND version #{version} AND (total_quota - sold_count) #{quantity} /update这段 SQL 的作用是在一次原子操作里同时完成库存判断和库存扣减乐观锁判断条件里带上了(total_quota - sold_count) #{quantity}直接把超卖的门堵死了。这是典型的数据库层控制并发思路比在 Java 代码里加synchronized锁要可靠得多。3.4 面向老年用户的接口设计与无障碍适配后端接口设计时我跟前端同学反复对齐过老年人的操作习惯最后总结出几个原则这里也一并分享给你。响应数据要做到极简。同样的景区列表接口给普通用户可能返回 20 个字段给老年版接口只返回必要的四五个字段名称、封面图、价格、开放时间。字段少了前端渲染出错概率就低也方便在老年用户版页面上做大字号展示。身份证校验逻辑必须放在后端。前端可以只做必填校验真正的 18 位身份证正则校验、生日提取、性别判断全放后端。这样做的目的是防止用户绕过前端直接调接口提交非法数据也便于后续扩展到港澳台同胞或外国人永居证等不同证件类型。接口超时设置要宽容一些。老年用户可能在界面停留时间较长或者网络环境差所以订票接口的 Http 连接超时时间我设置成了 10 秒前端请求超时时间设置成 15 秒给足缓冲避免误报操作失败。提醒与容错提示要友好。尤其是身份证号码输入错误这类问题返回的 error message 不能是技术栈里的 Invalid argument value而是身份证号格式不正确请重新输入这种直白的中文描述。为了让前端直接弹提示框我在统一返回结果ResultT里再包了一层message所有异常处理类都会生成这种格式的返回体。此外针对有人工客服或者线下志愿者代客下单的场景我在订单详情里预留了operator_id字段用来记录代客操作人的账号。这样出现问题的时候可以快速定位是哪位员工处理的订单对售后追溯非常有帮助。4. 前端 Vue3 实现要点4.1 Vite Vue3 Pinia 工程化搭建前端的构建工具我选了 Vite不是 Webpack。Vite 在开发环境下的冷启动速度和热更新体验比 Webpack 好了一个量级尤其 Vue3 单文件组件编译在 Vite 里几乎是毫秒级响应调试效率提升非常明显。工程化搭建三步走npm create vitelatest elderly-ticket-front -- --template vue cd elderly-ticket-front npm install vue-router4 pinia element-plus axios路由设计上用户端和后台管理端分别用了两个独立的布局组件。用户端是一个极简的大卡片式布局只有景区列表、票种选择、确认订单三个页面后台管理端有景区管理、订单管理、票种配置、数据报表四个页面。Pinia 里主要存用户登录态、购物信息缓存、当前景区信息用起来非常顺手。这里有一个踩过坑的点——Element Plus 的按需引入。如果不做按需引入打包出来的 JS 体积会非常大首屏加载极慢对老年用户端的体验影响严重。更好用的方案是直接引入完整包然后配合 CDN 来做但这种方案的体积优化跟发海外版本时有冲突。最终用了unplugin-vue-components和unplugin-auto-import这两个插件自动按需引入组件和 API开发体验和产物体积都兼顾到了。4.2 老年人友好的界面设计落地前端界面是这次项目里客户验收时最有体感的一环。老年用户端的视觉设计可以总结成五个字大、简、明、响、容。大指的是字号和触摸区域。基础字号降到 18px 起步关键操作按钮如立即预订高度至少 56px触摸区域不小于 48x48pt 的无障碍规范要求。文字颜色和背景色的对比度必须达到 WCAG AA 级别不能出现灰色底配白色字这种坑爹配色。简指的是信息层次。景区列表页只展示必要的卡片信息——景区图片、名称、开放时间、价格、状态点进去之后就是两个大按钮选日期和立即预订。整个购票流程从打开页面到下单成功控制在三步以内而且每一步都提供返回上一级的超大按钮防止用户迷路。明指的是反馈要明显按钮点击必须有明显的视觉反馈——按下变亮、弹起复原不能是那种很轻的透明变化。支付成功的跳转页面会有大字号提示订票成功和取票码并且语音播报一遍这是为了照顾视力不好的老人。响是针对有语音能力的设备增加语音反馈。前端通过 Web Speech API 做一个简单的 TTS 播报在页面加载完、下单成功、取票码生成时都播报关键信息。虽然手机上的网页对 Web Speech API 支持程度略有参差但实测下来主流浏览器可用性在八成以上。容是容忍错误。老年人最容易犯的操作错误是重复点击提交。我在提交按钮上做了防重逻辑——点击后按钮立即置灰并显示正在处理...等接口返回后再恢复同时前端做了 3 秒防抖避免同一个订单被提交两次。这个问题如果不处理后端事务再严谨也没用用户端会出现两笔重复订单售后非常麻烦。4.3 与后端接口的交互实现Axios 请求如何统一封装网上的教程千篇一律但真正好用的封装就几个关键点。Response 拦截器统一处理业务状态码。后端约定返回结构是{ code: 200, data: ..., message: ... }code200表示业务成功其他都是失败。Axios 的 response 拦截器先判断code非 200 直接弹出message不成功的结果不进入业务代码。同时特殊处理 401 状态——登录过期跳转登录页并清空本地存储。service.interceptors.response.use( (response) { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 系统错误) if (res.code 401) { localStorage.clear() router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, (error) { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )这个封装用到的场景非常频繁。如果你不做这层封装每个页面的接口调用代码里都会混入大量错误处理逻辑代码会变得非常啰嗦而且容易出现漏处理的情况。5. 常见问题与排查技巧实录5.1 前后端联调中的典型问题跨域问题。前面提过本地开发靠 Vite 代理解决生产环境靠 Nginx 同域转发解决。这里再补充一个排查经验如果前端请求走到了后端但 OPTIONS 预检请求被拒绝了多半是 SpringSecurity 的认证拦截器把 OPTIONS 请求拦截了。解决办法是放行所有 OPTIONS 请求或者把 CORS 配置和 Security 配置解耦。Spring 自带的CorsFilter可以配置全局 CORS 规则配合 Security 放行/api/**即可。接口返回的日期格式问题。SpringBoot 默认返回时间戳或者带时区的 ISO 字符串取决于你用的 Jackson 配置而前端通常需要YYYY-MM-DD HH:mm:ss。我在application.yml里统一做了全局配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这个配置必须和数据库连接的时区保持一致否则会出现差 8 小时的诡异问题。因为 MySQL 驱动默认使用 JVM 时区而 JVM 默认又跟操作系统走如果你的服务器时区是 UTC就会出现时间全部晚 8 小时。排查这类问题最快的方法是分别打印 Java 侧的日期和数据库侧的日期对比偏移量基本一分钟定位。MyBatis 返回 null 不报错。前面提到的map-underscore-to-camel-case配置是高频坑。另外一个更容易忽略的是当 SQL 查询结果字段在实体类里不存在时MyBatis 会直接忽略不报错结果对象里那个字段就是 null。如果你发现查出来的对象部分字段是 null 但是 SQL 直接查能查到值第一反应检查实体类字段名和 SQL 别名是否对应第二反应检查驼峰映射是否生效。Batch Insert 的坑。订单详情批量插入如果用 MyBatis 的foreach拼接多条 values单条 SQL 太长会超过 MySQLmax_allowed_packet限制。我踩过这个坑最后把订单详情的批量插入改成一次不超过 200 条的批次处理同时调大了 MySQL 的max_allowed_packet参数双保险。MySQL 8.0 的身份认证插件问题。如果你连接 MySQL 报Public Key Retrieval is not allowed原因是 MySQL 8.0 默认使用 caching_sha2_password 认证插件JDBC 连接串需要加上allowPublicKeyRetrievaltrue。这个报错信息不直观容易耽误时间提前记下能省你半小时。5.2 MyBatis 使用中的几个隐蔽问题动态 SQL 里的特殊字符转义。在 XML 里写号会被解析成标签所以 SQL 里的visit_date #{today}必须写成visit_date lt; #{today}。这个错误不报错编译通过但运行时报 SQL 语法异常而且 SQL 日志里看起来又很正常排查成本非常高。如果配置了 Mapper 接口扫描XML 和接口必须同名同包。很多人把 XML 文件放在resources/mapper目录下但是接口包路径是com.example.mapper这两者如果不一致启动时就会报 Invalid bound statement (not found)。解决方式要么是 XML 文件与接口类放在同一目录且同名要么在application.yml里明确配置mapper-locations: classpath:mapper/*.xml。表名和字段名用关键字会踩雷。MySQL 里order是保留字表名如果叫order就必须加反引号。我实际项目中把订单表叫ticket_order就是刻意避开这个问题强烈建议你建表时先过一遍 MySQL 保留字列表否则后面每个 SQL 都要背反引号极其痛苦。5.3 部署与运维经验部署这件事一开始就定了目标简单、稳、可回滚。打包方式很简单后端用mvn clean package打成 fat jar前端用npm run build产出dist目录。我用了 Nginx 作为静态资源服务器和反向代理配置核心是静态文件路径和/api反向代理server { listen 80; server_name your-domain.com; root /opt/elderly-ticket/frontend/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }后端 jar 包我用systemd管理开机自启、崩溃自动拉起不需要额外的进程守护工具[Unit] DescriptionElderly Ticket System Afternetwork.target [Service] Userapp ExecStart/usr/bin/java -jar /opt/elderly-ticket/backend/elderly-ticket-1.0.0.jar Restarton-failure RestartSec10 [Install] WantedBymulti-user.target需要特别注意的是proxy_pass后面加不带路径的/这样/api/order/list会被转发到后端的/order/list而不是/api/order/list。如果你反代配置里写的是http://127.0.0.1:8080而不带斜杠URL 会原样带上/api后端又要额外处理一遍前缀。日志方面SpringBoot 自带 logback我在application.yml里配置了按天滚动、保存 15 天的策略。排查线上问题时journalctl和日志文件双通道同时看一个定位服务状态一个定位业务异常效率高很多。数据库每天凌晨自动备份一次备份文件保留两周。这条看起来没啥技术含量但景区高峰期后如果出现运营对账问题能随时恢复到任意一天的数据直接把锅从数据丢了降级成数据查错了。结尾想说的话做完这个项目之后我最大的感触是——所谓技术含量并不仅仅体现在用了多牛的框架、搞了多复杂的架构更体现在能不能把业务流程想透、把用户画像吃透、把每一个可能出错的边界兜住。技术上用的全是市面上最主流的 Java 全家桶没有任何炫技但对业务细节的打磨程度却决定了这套系统能不能真正帮到那些不怎么会用智能手机的老年人。最后再分享一个小技巧。如果你后续也想做类似的订票系统或者预约类系统建议在开发初期就把状态机和日志审计这两件事想清楚。状态机保证业务流程不会走歪日志审计保证出问题时能快速追溯。我在这套系统里给订单状态流转写了一个简单的状态机校验工具类每次更新状态时都会先检查前置状态是否匹配虽然多写了 30 行代码但上线以来没有出现过一例状态错乱这笔投入非常值。另外如果你的景区后续要对接第三方平台比如地图应用里直接购票一定要在设计订单号时预留好渠道标识位。这套系统的订单号是 19 位雪花数前 3 位是渠道编码第三方渠道接入时只需约定一个前缀就能保证全局唯一不用以后重构。这个设计是项目上线接入第一个渠道后我才意识到的幸好当初订单号够长、拆分过段位不然就得为对接渠道专门写一套新号码体系那才是真的折腾。希望这篇记录能给你一些参考。