资讯详情

Java控制台公园售票系统:从建模到并发与测试的实战演练

📅 2026/9/11 20:10:42 | 华诺云谱 👁 阅读
Java控制台公园售票系统:从建模到并发与测试的实战演练
简介Java实现的控制台公园售票管理系统是一份适合Java初学者、课程设计或毕业设计参考的完整项目源码。系统采用命令行界面围绕用户登录验证与票务信息管理实现了增删改查等核心操作并全面运用了面向对象设计、JDBC数据库连接、PreparedStatement防SQL注入、密码哈希存储、异常处理与日志记录等关键技术。资源包共23个文件包含8个java源码、8个class编译文件、6个xml配置文件及1个iml项目文件压缩包大小仅25KB内含src源码目录与IDE工程配置结构清晰导入开发工具即可运行和调试。目前已有616人学习参考尤其适合用来理解Java控制台应用开发、MVC分层架构及JUnit/TestNG测试实践。通过这份资源读者能够系统掌握从用户认证到数据库操作的完整业务实现快速迁移到类似票务管理或课程作业中并为进一步扩展功能提供了清晰的基础代码是一份实用且轻量的Java学习样例。1. 控制台公园售票管理系统是Java基础最值得动手练的项目Java实现控制台公园售票管理系统听起来像个课程设计但认真把它做完就会发现Java基础里面试里反复问的几个点都被它串起来了面向对象建模、集合选型、文件读写、异常处理、接口解耦。用GUI或Web框架去做界面很容易把业务逻辑的粗糙掩盖掉换成控制台用户每输入一次命令都要落到具体逻辑上数据对不对、流程通不通、库存会不会超卖全都会暴露出来。适合刚学完Java基础、想找第一个完整项目的人也适合准备Java面试、想拿一个能讲清楚设计点的人。下面这套方案不假设有现成源码按我日常开发会采用的可编译、可运行、可继续扩展的方式梳理一遍。2. 控制台售票系统的数据建模票种、订单与库存公园售票首先要定义“票”。成人票、儿童票、学生票、老年票如果直接用字符串写在业务代码里后面做价格计算、退票校验、统计报表时会到处出现字符串比较改一个名字就得全项目替换。常见做法是把固定属性做成Java枚举由枚举统一提供票种和基础价格的入口。2.1 用枚举定义票种主数据枚举在Java里天然适合表达固定分类。公园票种相对稳定价格调整发生在配置层可以把枚举当成“主数据表”来用界面展示名和基础价格都放进去。public enum TicketType { ADULT(成人票, 8000), CHILD(儿童票, 4000), STUDENT(学生票, 5000), OLD(老年票, 2000); private final String displayName; private final long priceFen; TicketType(String displayName, long priceFen) { this.displayName displayName; this.priceFen priceFen; } public String getDisplayName() { return displayName; } public long getPriceFen() { return priceFen; } }这里刻意用“分”做金额单位而不是double。Java基础题里经常问0.1 0.2为什么不等于0.3如果售票系统直接用double存票价累计报表时误差会被放大。用long存分打印时再除以100既比BigDecimal轻也不会丢精度。displayName只给控制台展示用不参与计算getPriceFen()是后续所有价格运算的唯一入口周末调价只需要在计算层做一件事不需要到处找价格常量。2.1.1 为什么不在枚举里放游玩日期有些公园票会区分当日票和指定日期票这个字段千万不要放进枚举。枚举擅长表达固定分类日期是动态业务数据同一票种在不同日期可能有不同价格如果把日期和某种票绑定就没法表达“周末成人票上浮”这类规则。正确的做法是让日期只出现在订单创建阶段后面所有日期校验都在这里完成。2.2 订单类保存价格快照而不是引用商店价售票系统最容易踩的坑是订单里的单价用了当前票价。如果枚举里的价格后来调了历史订单的统计营收会跟着一起变账目就对不上。所以订单要把下单时的票种、票名、单价都复制一份成为不可变快照。public class Order { private final String orderId; private final TicketType ticketType; private final String ticketName; // 下单时票名快照 private final long unitPriceFen; // 下单时单价快照 private final int quantity; private final long totalFen; private final LocalDate visitDate; private final LocalDateTime createTime; private final String customerPhone; private boolean refunded; public Order(String orderId, TicketType ticketType, String ticketName, long unitPriceFen, int quantity, LocalDate visitDate, LocalDateTime createTime, String customerPhone) { this.orderId orderId; this.ticketType ticketType; this.ticketName ticketName; this.unitPriceFen unitPriceFen; this.quantity quantity; this.totalFen unitPriceFen * quantity; this.visitDate visitDate; this.createTime createTime; this.customerPhone customerPhone; } public void markRefunded() { this.refunded true; } public boolean isRefunded() { return refunded; } // 其余 getter }把unitPriceFen和ticketName直接复制进订单是为了让订单成为一条独立的历史记录。即使后面枚举调价老订单计算营收时仍然使用当时的价格。ticketType保留枚举类型统计按票种分组时可以直接用groupingBy不用再从字符串解析。refunded只打标记不删订单这样退票记录会在数据文件里保留方便后续对账。2.3 内存“数据表”集合类型怎么选控制台版本可以不接数据库但集合选型决定了代码能不能继续扩展。下面这张选型表可以直接用在设计说明里。数据推荐集合理由库存EnumMapTicketType, AtomicIntegerkey固定遍历顺序和枚举声明一致AtomicInteger为并发准备订单主表LinkedHashMapString, Order按订单号O(1)查找同时按插入顺序输出按日期汇总MapLocalDate, ListOrder日报统计按游玩日期分组空间换时间游客手机号索引HashMapString, ListOrder只做辅助查询不承担订单保存public class ParkTicketContext { private final MapTicketType, AtomicInteger stock new EnumMap(TicketType.class); private final MapString, Order orders new LinkedHashMap(); public ParkTicketContext() { for (TicketType type : TicketType.values()) { int init switch (type) { case ADULT - 500; case CHILD - 200; case STUDENT - 300; case OLD - 100; }; stock.put(type, new AtomicInteger(init)); } } }这里用了Java 14以后的switch表达式如果项目还用JDK 8改成普通switch赋值即可。初始库存放在构造器里而不是枚举里是为了解耦“票种定义”和“库存配置”将来从配置文件读库存时不需要改枚举。AtomicInteger在单线程中也能保证内存可见性后续给售票逻辑加锁时不再需要额外给库存字段加volatile。订单表用LinkedHashMap而不是HashMap是因为控制台打印“全部订单”时用户期望看到先买的排在前面HashMap的遍历顺序不固定排查问题时很难受。3. 控制台交互层菜单循环、输入校验与命令解析控制台程序最常见的结构是“打印菜单、读命令、执行、回到菜单”。这个循环本身不难但退出条件、输入异常、命令分发如果没有设计后期每加一个功能都要动主循环。3.1 主循环先想好退出条件用户按CtrlC强退时内存里的订单数据会全部丢光。我一般会在菜单里显式提供“0. 保存并退出”并在run方法里统一处理异常让程序在任何错误后都能回到菜单而不是直接炸掉。public class ConsoleRunner { private final ParkTicketService service; private final Scanner scanner new Scanner(System.in); public ConsoleRunner(ParkTicketService service) { this.service service; } public void run() { while (true) { printMenu(); System.out.print(请输入操作编号: ); String input scanner.nextLine().trim(); if (0.equals(input)) { service.saveAll(); System.out.println(数据已保存再见。); return; } try { dispatch(input); } catch (BusinessException e) { System.out.println(业务提示: e.getMessage()); } catch (Exception e) { System.out.println(系统异常: e.getMessage()); } } } }这里用nextLine()而不是nextInt()原因是在控制台菜单里用户输入1后会按回车nextInt()遇到非数字会抛异常且不消费错误输入很容易把换行符留到下一次读取。统一用字符串读进来到业务层再解析输入处理只影响交互层不会污染核心逻辑。return直接退出run方法而不是用System.exit(0)这样将来如果把这个Runner嵌进Spring Boot定时任务不会把整个进程杀掉。BusinessException单独捕获让用户看到的是“库存不足”而不是Exception in thread main。3.2 输入校验把“解析”和“业务”拆开控制台项目的大量Bug不在业务而是用户输入了-1、abc、2024-02-30。校验规则最好集中成一个工具类避免每个菜单方法里都写一遍try/catch。输入类型校验规则失败提示操作编号只能是数字且必须在菜单范围内无效操作编号购买数量整数大于0购买数量必须大于0游玩日期yyyy-MM-dd不能早于今天日期格式或范围错误订单号非空且不能带空格订单号不能为空3.2.1 日期解析要避开默认的 SMART 模式日期校验有个隐蔽的坑。DateTimeFormatter.ofPattern(yyyy-MM-dd)默认使用SMART解析碰到2024-02-30会把日期静默修正为2024-02-29。售票系统里用户填错日期系统却自动改掉入园时会对不上。public class InputParser { private static final DateTimeFormatter DATE_FORMAT DateTimeFormatter.ofPattern(uuuu-MM-dd, Locale.CHINA) .withResolverStyle(ResolverStyle.STRICT); public static LocalDate parseVisitDate(String raw) { LocalDate date; try { date LocalDate.parse(raw.trim(), DATE_FORMAT); } catch (DateTimeParseException e) { throw new BusinessException(日期格式错误应该写成 2025-03-09); } if (date.isBefore(LocalDate.now())) { throw new BusinessException(游玩日期不能早于今天); } return date; } public static int parsePositiveInt(String raw, String fieldName) { int value; try { value Integer.parseInt(raw.trim()); } catch (NumberFormatException e) { throw new BusinessException(fieldName 必须是整数); } if (value 0) { throw new BusinessException(fieldName 必须大于0); } return value; } }withResolverStyle(ResolverStyle.STRICT)是强制严格解析非法日期直接抛异常。uuuu和yyyy在严格模式下有区别uuuu更适合公历日期处理yyyy在公元前的场景才有问题这里统一用uuuu更稳。parsePositiveInt里的判断顺序很重要先用Integer.parseInt捕获非数字再判断大于0如果把raw.trim()解析出的负数直接丢给业务后面的库存判断会出现负数扣减。3.3 命令分发用 Map接口不要用巨型 switch菜单一多if/else分发会让run方法越来越长。把每个菜单项封装成执行器用Map建立编号到执行的映射主循环就不用再改。FunctionalInterface interface Command { void execute(); } private final MapString, Command commands new LinkedHashMap(); private void initCommands(ParkTicketService service) { commands.put(1, () - sellTicketMenu()); commands.put(2, () - refundTicketMenu()); commands.put(3, () - dailyReportMenu()); commands.put(9, () - printAllOrders()); }Command是函数式接口execute()不接收参数具体需要的输入由菜单方法内部从Scanner读取。用LinkedHashMap是为了让菜单显示顺序和Map的put顺序一致。以后要加功能只需要写一个方法然后在initCommands里加一行put不会再碰主循环代码。面试聊到扩展性时这也是一个“开闭原则”的真实例子。3.4 Windows 控制台中文乱码和编码设定Java控制台项目在Windows上最常见的坑是中文乱码。问题根源是Scanner默认使用Charset.defaultCharset()和系统码页不一致时读进来的中文会变成问号。在IDEA内置终端里一般不暴露打包成jar双击运行时才出现。常规做法是启动命令里显式指定编码java -Dfile.encodingUTF-8 -jar park-ticket-system.jar。如果必须跑在GBK终端就把Scanner和输出流的编码统一成System.console().charset()。JDK 18以后默认UTF-8但老JDK还大量存在这条建议值得写进README。4. 售票、退票与统计三个不能做错的核心动作数据建模完成后业务核心就是三个动作售票、退票、统计。三个动作都不复杂但顺序错了就会出现库存不一致和账目对不上。4.1 售票流程库存预检和价格计算放在同一把锁里售票不是简单地if (库存 数量)再扣减。库存预检、生成订单、扣减库存要放在同一个同步代码块里否则两个线程同时读到库存为5各卖出5张时系统会卖出10张。控制台系统虽然多数是单机单用户但这个并发问题必须想清楚。public Order sell(TicketType type, int quantity, LocalDate visitDate, String customerPhone) { if (quantity 0) { throw new BusinessException(购买数量必须大于0); } synchronized (this) { AtomicInteger rest stock.get(type); int current rest.get(); if (current quantity) { throw new BusinessException( type.getDisplayName() 库存不足当前剩 current 张); } long unitPrice calcPrice(type, visitDate); String orderId genOrderId(); Order order new Order(orderId, type, type.getDisplayName(), unitPrice, quantity, visitDate, LocalDateTime.now(), customerPhone); orders.put(orderId, order); rest.addAndGet(-quantity); return order; } }genOrderId()一般用yyyyMMddHHmmss加三位自增序号保证同一秒内的订单号不重复。synchronized(this)锁住整个售票流程保证“检查库存、生成订单、扣库存”三步不可分割。关键是先创建订单再扣库存如果订单创建过程中抛异常代码不会走到addAndGet那一行库存自然没有被改掉。calcPrice放在锁内执行保证同一时刻价格规则基于相同的基础数据。步骤动作说明1校验数量小于等于0直接拒绝2同步锁阻止并发扣减交叉3检查库存剩余量不足就报业务异常4计算价格生成价格快照5保存订单写入订单Map6扣减库存原子减少剩余量4.2 价格计算周末上浮用方法而不是if公园常见规则是周末成人票上浮。如果直接在sell方法里写if将来又加夜场票、节假日票业务方法会越来越长。把计价规则单独拆出来至少可以让售票方法的逻辑不变。private long calcPrice(TicketType type, LocalDate visitDate) { DayOfWeek dow visitDate.getDayOfWeek(); boolean weekend dow DayOfWeek.SATURDAY || dow DayOfWeek.SUNDAY; if (weekend) { return Math.round(type.getPriceFen() * 1.2); } return type.getPriceFen(); }这里用Math.round把计算结果四舍五入到分。用long做金额时遇到百分比调价会得到小数必须明确取整规则。如果将来有更多价格策略再把这个方法抽成PriceStrategy接口每种策略只管自己的规则调用处用策略链组合。控制台项目不必提前上设计模式但计价规则已经出现明显if分支时抽象边界就在这里。4.3 退票流程历史记录不能直接删除退票比售票更敏感涉及两件事库存要回补但订单不能从Map里删除。如果直接orders.remove(orderId)当天统计就会少一笔财务对不上。正确做法是继续保留订单用一个refunded标记表示已退。public void refund(String orderId) { Order order orders.get(orderId); if (order null) { throw new BusinessException(订单不存在请检查订单号); } synchronized (this) { if (order.isRefunded()) { throw new BusinessException(该订单已退票不能重复操作); } if (order.getVisitDate().isBefore(LocalDate.now())) { throw new BusinessException(游玩日期已过期无法退票); } stock.get(order.getTicketType()).addAndGet(order.getQuantity()); order.markRefunded(); } }先查订单再判断重复退票和过期最后补库存。LocalDate.now()判断的是“游玩日期已经过去”的硬性条件如果想限制当天开园后不能退需要把判断改成LocalDateTime.now()去比较具体时间。这里和售票用同一把this锁避免了“退票回补库存”和“售票扣减库存”交错执行这是保证库存不超卖的关键。4.4 日报统计Stream聚合代替多个计数变量日报要输出当天有效订单数、售出总张数、实际营收、各票种票数。用for循环也能做但代码里会堆四五个临时变量用Stream聚合更直白。public DailyReport dailyReport(LocalDate date) { ListOrder valid orders.values().stream() .filter(o - o.getVisitDate().equals(date)) .filter(o - !o.isRefunded()) .collect(Collectors.toList()); long ticketCount valid.stream() .mapToLong(Order::getQuantity).sum(); long revenue valid.stream() .mapToLong(o - o.getUnitPriceFen() * o.getQuantity()).sum(); MapTicketType, Long byType valid.stream() .collect(Collectors.groupingBy(Order::getTicketType, Collectors.summingLong(Order::getQuantity))); return new DailyReport(date, valid.size(), ticketCount, revenue, byType); }filter筛出当天且未退票的订单ticketCount是所有实际出票张数不是订单数revenue用单价快照乘以数量再累加不会因为枚举调价而波动。groupingBy(Order::getTicketType)把相同票种分到一组summingLong(Order::getQuantity)作为下游收集器计算总张数。控制台输出时再按枚举声明顺序遍历byType避免HashMap顺序不稳定。4.5 CSV持久化用纯文本而不是Java序列化控制台程序关闭后订单要保留最简单的做法是每次保存时把全量订单写成CSV。Java序列化虽然一行代码就能实现但它和类版本强绑定字段一变旧文件就坏CSV文本能用Excel打开核对更适合这种小系统。public void saveOrdersToCsv(String path) throws IOException { try (BufferedWriter writer Files.newBufferedWriter( Paths.get(path), StandardCharsets.UTF_8)) { writer.write(orderId,ticketType,ticketName,quantity,unitPriceFen,visitDate,isRefunded); writer.newLine(); for (Order o : orders.values()) { String line String.join(,, o.getOrderId(), o.getTicketType().name(), o.getTicketName(), String.valueOf(o.getQuantity()), String.valueOf(o.getUnitPriceFen()), o.getVisitDate().toString(), String.valueOf(o.isRefunded())); writer.write(line); writer.newLine(); } } }CSV字段里如果有逗号需要给字段加双引号并转义。这里订单表字段固定、没有逗号用String.join最简单如果将来加入自定义票种名称就要提供escapeCsvField()做转义。try-with-resources会在方法结束或异常时自动关闭文件流避免保存过程中断电导致文件损坏。加载时用Files.readAllLines逐行反向解析表头或字段数不对的行直接跳过并在控制台提示修复。5. Java售票系统的异常处理与并发边界能跑不代表不会崩功能都跑通之后离“能提交作业”还差一类工作异常处理和并发边界。这部分最容易被忽略也是面试时拉开差距的地方。5.1 业务异常和系统异常分开处理控制台售票系统最怕用户输入一个错误字符串程序就抛出一整段堆栈。更合理的是定义业务异常让错误提示保持稳定。public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } }RuntimeException不需要在方法签名上声明throws可以让命令分发层只捕获少数几个类型。业务异常代表“票卖完、日期过期、订单不存在”这类用户操作错误系统异常代表文件读写失败、代码Bug。控制台主循环里分别捕获用户看到的是可读的提示开发者在日志里才能看到堆栈。异常来源异常类型控制台提示用户输入错误BusinessException业务提示: 库存不足当前剩 X 张文件读写失败IOException系统异常: 保存失败请检查磁盘未预料的BugRuntimeException系统异常 记录堆栈到日志5.2 synchronized 的锁粒度从对象锁到票种锁前面售票和退票用的都是synchronized(this)锁的是整个服务对象。如果同时卖成人票和儿童票两笔订单也会互相等待。控制台单用户场景下无所谓但面试官会问能不能并发卖不同票种。private final MapTicketType, Object typeLocks new EnumMap(TicketType.class); // 初始化给每个票种一个独立的锁对象 public Order sellByTypeLock(TicketType type, int quantity, LocalDate date) { synchronized (typeLocks.get(type)) { // 同一票种的库存检查、订单生成、库存扣减 } }按票种加锁后成人票和儿童票可以同时卖并发度更高。代价是代码里多维护一个EnumMap而且订单Map本身需要并发安全。如果只用synchronized(this)订单写入自然安全拆成票种锁后orders要用ConcurrentHashMap或者单独加锁否则两个线程同时写入不同票种订单时LinkedHashMap内部结构会被破坏。对我的项目来说控制台版本用全局锁更稳妥按票种锁可以作为优化方向写进设计文档。5.3 金额单位统一用分打印时再转换用long存分已经避开了double误差但统计和打印层还会出现把分转成元的操作。这个转换一定要集中在工具方法里不要散落在各个菜单中。public static String fenToYuan(long fen) { return String.format(%.2f, fen / 100.0); }fen / 100.0保留小数点String.format统一输出两位。如果某个地方手滑用了fen / 100整数除法金额会直接抹掉角分。把转换集中在一个方法里至少能减少这类低级错误。营收统计里unitPriceFen * quantity使用long单日几万张票不会溢出要警惕的是某些报表把“元”转成“万元”时除以10000的舍入规则要写清楚。5.4 边界条件空订单表、负数数量、库存为0控制台系统容易崩的场景往往不是复杂逻辑而是边界值。建议在代码里至少覆盖以下用例。边界条件预期行为订单表为空时打印日报返回0不抛空指针购买数量等于当前库存允许购买后库存为0购买数量大于当前库存拒绝提示剩余量退重复订单号拒绝提示已退票游玩日期是昨天拒绝提示已过期CSV文件不存在跳过加载初始化空库存和空订单这些用例可以直接写成JUnit测试也可以先用手工测试过一遍。控制台程序最怕用户输入一个边缘值就把整段流程打乱把边界条件列出来至少心里有底。6. 把控制台售票系统改成可测试的小框架依赖注入与单元测试6.1 核心服务不依赖 Scanner如果ParkTicketService里直接写了new Scanner(System.in)单测时就得模拟键盘输入很痛苦。常见做法是核心服务只接收方法参数返回结果或抛异常ConsoleRunner负责收集键盘输入并调用核心服务这样核心业务保持纯Java不依赖控制台环境。public class ParkTicketService { private final MapTicketType, AtomicInteger stock; private final MapString, Order orders new LinkedHashMap(); public Order sell(TicketType type, int quantity, LocalDate visitDate, String phone) { // 纯业务逻辑 } }测试代码可以直接new ParkTicketService()调用sell()和refund()不再需要准备InputStream。这也是依赖倒置在控制台项目里的最小体现高层模块只依赖service的方法不依赖具体输入源。6.2 用 JUnit 5 做售票与退票回归测试单测不需要覆盖所有菜单重点覆盖“退票后库存回补”和“日期早于今天报错”这两个核心不变式。class ParkTicketServiceTest { private ParkTicketService service; BeforeEach void setup() { service new ParkTicketService(); } Test void 退票后库存应当回补() { Order order service.sell(TicketType.ADULT, 3, LocalDate.now().plusDays(1), 13800000000); service.refund(order.getOrderId()); assertEquals(500, service.getStock(TicketType.ADULT)); assertTrue(order.isRefunded()); } Test void 订票日期早于今天应当报错() { LocalDate past LocalDate.now().minusDays(1); assertThrows(BusinessException.class, () - service.sell(TicketType.ADULT, 1, past, 13800000000)); } }BeforeEach每次测试都新建一个service避免用例之间共享库存状态。第一个用例验证库存从初始值扣掉3退票后又回到500第二个用例验证日期校验会在订单生成之前拦截。当以后改动价格策略或退票规则时这两个测试能在几秒内告诉我是否破坏了原有约定。6.3 小扩展近7天售票柱状图在收尾阶段可以加一个不破坏结构的小功能打印最近7天各天的售票总量用#号画简单控制台柱状图。public void printLast7Days() { for (int i 6; i 0; i--) { LocalDate day LocalDate.now().minusDays(i); long cnt orders.values().stream() .filter(o - o.getVisitDate().equals(day)) .filter(o - !o.isRefunded()) .mapToLong(Order::getQuantity).sum(); System.out.printf(%s | %s (%d张)%n, day, #.repeat((int) Math.min(cnt, 50)), cnt); } }#.repeat是Java 11才有的方法老JDK可以用循环拼接字符串。图表上限截断到50个#避免某天大销量撑爆控制台宽度。这个扩展只用现有orders容器做聚合没有改动ParkTicketService的售票和退票方法如果你在答辩时被问到“系统能不能再加一个功能”这就是一个现成的扩展性示例。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。