news 2026/10/31 8:38:05

Java三层架构实战:嗖嗖移动大厅控制台项目代码精读与改造

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java三层架构实战:嗖嗖移动大厅控制台项目代码精读与改造

简介:面向Java Web初学者与移动端页面练手者的「嗖嗖移动大厅注释+代码」资源,以带注释的HTML页面源码呈现移动服务大厅界面,便于对照理解页面结构、样式组织与基础交互逻辑。压缩包内共1个文件,为单个HTML页面,包体约9KB,体积轻量,可直接在浏览器中打开查看效果,并逐段阅读注释、拆解代码组织方式;虽规模不大,却覆盖了移动大厅常见的头部、导航、内容区块与按钮等页面元素。已有474人学习下载,适合作为课堂示例、课程设计参考或前端基础练习素材。读者可从中获取移动大厅页面的布局思路、HTML标签组织方式及注释阅读线索,并据此尝试扩展样式、补充交互或预留后端接口;对刚接触Java Web、需要直观了解页面层实现的学习者较为友好。

1. 拿到「嗖嗖移动大厅注释+代码」先别急着敲编译命令

不少人拿到这份 JAVA 练手工程的第一反应是直接编译运行,控制台刷出一屏红字,或者进去点两下就卡在菜单里出不来,然后就搁下了。这套「嗖嗖移动大厅注释+代码」本质上是一个控制台版的营业厅业务模拟系统:开户、选套餐、充值、通话扣费、查账单、停复机、销户,一条业务链路都没落下。它的价值不在语法有多花哨,而在于把一堆散落的 if-else 组织成了有层次的调用结构,注释又标在关键分支的判断依据上。适合两类人:刚学完 JAVA 基础需要完整工程练手的,以及课程设计要交东西但想真看懂每一行的人。读它的正确顺序是先看目录分层,再顺着一条「开户→充值→通话」的链路走一遍,最后才动手改。

2. 拆工程结构:JAVA 三层分层在这套营业厅代码里怎么落地

控制台项目最容易写成一锅粥:所有逻辑塞进 main 方法,几百行 switch 套 if,改一个计费规则要翻三处。这套代码的第一个可取之处是把职责切开了,阅读时能按层定位问题,写的时候也知道新功能该落在哪个包里。先把目录结构摊开看,再挑一条完整链路走一遍调用顺序,最后讨论存储方案怎么选,这三步走完,后面改代码基本不会迷路。

2.1 目录与类职责:Entity、Dao、Service、View 各管什么

工程按包名分层,规模不大但边界清楚。把目录树打印出来贴在编辑器侧边,读代码时对照着看,效率会高不少:

src/ └── com/sousou/ ├── Main.java 入口,只做对象装配和启动 ├── entity/ 数据载体,只有字段和 getter/setter │ ├── MobileUser.java 手机号、姓名、密码、余额、套餐、状态 │ ├── Plan.java 套餐编号、名称、月租、包含分钟、超出单价 │ └── Bill.java 流水号、卡号、类型、金额、时间、备注 ├── dao/ 数据访问,内存 Map 实现 │ ├── UserDao.java │ ├── PlanDao.java │ └── BillDao.java ├── service/ 业务规则,所有校验和计算在这里 │ ├── UserService.java 开户、销户、状态变更 │ ├── ChargeService.java 充值、扣费、余额校验 │ └── CallService.java 通话计时、套餐内外计费 ├── view/ 控制台交互,只负责读输入和打印 │ ├── MainMenu.java │ └── UserMenu.java └── util/ ├── InputUtil.java 统一的输入读取与重试 └── BizException.java 业务异常,带提示语

依赖方向是单向的:view 调 service,service 调 dao,dao 操作 entity,entity 谁都不依赖。这条规则看着简单,但它是这套代码能读下去的根本。常见的翻车写法是在 view 里直接 new 一个 UserDao 去查余额,看起来省事,等你要把内存存储换成文件存储时,就得把所有菜单类翻一遍。

Main.java的写法值得学一下,它不做任何业务判断,只负责把 dao、service、view 三层的对象创建好、按依赖顺序组装、然后把控制权交给菜单:

// Main.java —— 纯装配,不写业务 public class Main { public static void main(String[] args) { // 1. 先建 dao 层,内存实现,进程结束数据即消失 UserDao userDao = new UserDao(); PlanDao planDao = new PlanDao(); BillDao billDao = new BillDao(); // 2. 再建 service 层,把 dao 通过构造器注入,而不是在 service 内部 new UserService userService = new UserService(userDao); ChargeService chargeService = new ChargeService(userDao, billDao); CallService callService = new CallService(userDao, planDao, billDao); // 3. 最后交给菜单层,菜单只认 service new MainMenu(userService, chargeService, callService).start(); } }

构造器注入在这里不是为了炫技,而是为了后面替换实现时不用改 service 的代码:想从内存存储切到文件存储,只需要在 main 里把new UserDao()换成new FileUserDao(),service 和 view 一行不动。字段含义上,MobileUser.status用整数编码(0 销户、1 正常、2 停机、3 挂失),比用字符串安全,也方便做状态迁移校验;Plan.planId为 0 表示用户还没选套餐,通话按标准资费走。这类约定注释里都写了,读的时候别跳过。

2.2 一条「开户→充值→通话扣费」的调用链走查

光看目录看不出所以然,挑一条主链路跟一遍。开户这一步,菜单层收集完姓名和证件信息后调UserService.openAccount(),service 生成号码、初始化余额和状态、落库,返回整个用户对象给菜单层打印:

// service/UserService.java —— 开户 public MobileUser openAccount(String name, int planId) { // 号码段写死为 138 开头 + 8 位序号,仅演示用 String mobileNo = "138" + String.format("%08d", userDao.nextSeq()); // 前置校验:套餐必须存在,不存在则拒绝开户 if (planId != 0 && planDao.findById(planId) == null) { throw new BizException("套餐不存在,请重新选择"); } MobileUser u = new MobileUser(); u.setMobileNo(mobileNo); u.setName(name); u.setPwd("123456"); // 初始密码,首次登录强制修改 u.setBalance(0.0); // 开户不预存话费 u.setPlanId(planId); u.setStatus(1); // 1 = 正常 if (!userDao.insert(u)) { throw new BizException("号码分配冲突,请重试"); } return u; }

重值的逻辑在 dao 层,insert用 Map 的putIfAbsent保证号码不重复,返回布尔值让 service 决定抛不抛异常。这里的分工很关键:dao 只回答「存进去了没有」,service 决定「存不进去意味着什么、给用户什么提示」。很多练手代码把提示语写在 dao 里,一旦要改文案就得动数据层,最后没人敢改。

充值和通话扣费是同一条链路上的两个分支,前者加钱、后者减钱,但都要先过用户状态这一关。充值的实现是这份代码里比较典型的一段:

// service/ChargeService.java —— 充值 public double recharge(String mobileNo, double amount) { if (amount <= 0) { throw new BizException("充值金额必须大于 0"); } MobileUser u = userDao.findByNo(mobileNo); if (u == null) { throw new BizException("卡号不存在"); } // 销户和挂失状态不允许充值,停机可以(充值后自动复机) if (u.getStatus() == 0) { throw new BizException("已销户账户不能充值"); } u.setBalance(u.getBalance() + amount); // 停机用户在充值后自动复机,这条规则写在 service,不写在菜单 if (u.getStatus() == 2 && u.getBalance() > 0) { u.setStatus(1); } userDao.update(u); Bill bill = new Bill(); bill.setBillNo(billDao.nextSeq()); bill.setMobileNo(mobileNo); bill.setType("充值"); bill.setAmount(amount); bill.setCreateTime(new Date()); bill.setRemark("账户充值"); billDao.add(bill); return u.getBalance(); }

参数上,amount用 double 是这套代码的一个历史包袱,真实项目里金额一律用BigDecimal或按「分」存 long,这一点在第 5 章会展开讲。findByNo是 O(1) 的 Map 查找,update因为存的是对象引用,其实可以不写,但显式调用能让「这里改动了数据」这件事可读,换成文件或数据库实现时也不用改调用方。余额变更和流水写入必须成对出现,中途抛异常就会造成「钱扣了流水没记」的对账黑洞,练手阶段可以先不管事务,但心里要有这根弦。

2.3 存储选型:内存集合、CSV 文件还是 JDBC

这套代码默认用LinkedHashMap做存储,进程退出数据全没,好处是零配置、跑起来就能玩,坏处是每次调试都要重新录入一堆测试数据。实际拿它练手时,三种存储方案的取舍大致是这样:

方案实现成本关闭程序后数据适合阶段主要改造点
内存 Map极低,几十行全部丢失读懂业务逻辑无需改造,开箱即用
CSV 文本文件低,一两百行保留,可用 Excel 查看课程设计交作业每个 dao 加 load/save
JDBC + H2/MySQL中,要写建表脚本保留,支持并发想写进简历连接管理、事务、SQL

我一般建议先用内存版跑通全部业务,确认计费规则、状态流转都符合预期,再动手换存储。顺序反了会很痛苦:一边调 SQL 一边调业务逻辑,出问题分不清是哪层的锅。换 CSV 时不用改 service,只要新增一个FileUserDao implements UserDao,把insert/findByNo/update三个方法重写,启动时从文件加载进 Map,每次写操作后全量刷盘。这套做法的代价是全量写,数据量大时会慢,但几百个用户完全够用;真正的坑在于流没关、编码不一致、字段里有逗号,这些在第 5 章逐条说。

3. 业务内核:套餐计费、状态流转与菜单循环怎么写才不出错

分层只是骨架,真正决定这份代码能不能当参考的是三个地方:钱怎么算、状态怎么变、菜单怎么循环。这三块写错了,跑起来不一定报错,但结果一定是错的,而且错得很隐蔽——余额差几毛、状态卡在中间、输入一个字母整个程序退出。逐个拆。

3.1 计费模型:月租、包含分钟与超出单价的差值算法

套餐计费最容易写错的地方不是公式,是「跨套餐边界的这一次通话怎么算」。假设套餐含 100 分钟,本月已用 95 分钟,本次通话 10 分钟,其中有 5 分钟在套餐内、5 分钟在套餐外。如果按「本次通话开始时是否已超」来判断,整通电话都会被算成套餐外,用户多掏钱;如果按「本次结束后是否超」判断,又整通算套餐内,运营商亏。正确的做法是算差值:

// service/CallService.java —— 单次通话费用计算 public double calcCallFee(MobileUser u, int minutes) { Plan plan = planDao.findById(u.getPlanId()); if (plan == null) { // 未选套餐,按标准资费 0.2 元/分钟 return minutes * 0.2; } // 本月已用分钟数(不含本次),按账期起始日过滤 int usedBefore = callDao.sumMinutesInCycle(u.getMobileNo(), cycleStartDay()); int freeMinutes = plan.getFreeMinutes(); // 超出套餐的分钟数 = 本次结束后的超出量 - 本次开始前的超出量 int overBefore = Math.max(0, usedBefore - freeMinutes); int overAfter = Math.max(0, usedBefore + minutes - freeMinutes); int overMinutes = overAfter - overBefore; double fee = overMinutes * plan.getOutPrice(); // 月租不在通话时扣,统一在账期首日扣,避免重复扣 return fee; }

这段的核心是两行Math.max。overBefore表示通话前已经超了多少分钟,overAfter表示通话后超了多少,两者相减就是本次通话真正落在套餐外的部分。用 95 分钟已用、100 分钟套餐、10 分钟通话代入:overBefore = 0,overAfter = 5,本次套餐外 5 分钟,套餐内 5 分钟,完全正确。参数上,cycleStartDay()返回账期起始时间戳,用来过滤本月通话记录,如果这份代码里没实现,就是把它当成「自然月」,每月 1 号清零,课程设计够用,真实系统要按用户开户日滚动。

还有一个容易忽略的点:套餐内分钟要不要「结转」。这套代码不结转,本月没用完的分钟数下月作废,注释里写明了。改造成结转模式的话,需要在用户表加一个carryOverMinutes字段,账期切换时把freeMinutes - used的正数部分存进去,下月可用额度变成freeMinutes + carryOver,同时要限制结转只保留一个月,否则越滚越大。这类规则改动是练习业务建模的好机会,改之前先想清楚数据放哪张表、什么时候写、什么时候读。

3.2 用户状态机:四种状态的合法迁移与统一拦截

状态字段最怕写成散落的 if:开户时判断一次、充值判断一次、通话判断一次,改一条规则要改五处,漏一处就出 bug,比如销户用户还能打电话。比较稳的写法是把状态迁移集中成一张表,用查表代替写判断:

// service/UserService.java —— 状态迁移白名单 private static final Map<Integer, Set<Integer>> TRANSFER = new HashMap<>(); static { TRANSFER.put(1, new HashSet<>(Arrays.asList(2, 3, 0))); // 正常 -> 停机/挂失/销户 TRANSFER.put(2, new HashSet<>(Arrays.asList(1, 0))); // 停机 -> 复机/销户 TRANSFER.put(3, new HashSet<>(Arrays.asList(1, 0))); // 挂失 -> 解挂/销户 TRANSFER.put(0, Collections.emptySet()); // 销户是终态,不可逆 } public void changeStatus(String mobileNo, int target) { MobileUser u = userDao.findByNo(mobileNo); if (u == null) throw new BizException("卡号不存在"); Set<Integer> allowed = TRANSFER.getOrDefault(u.getStatus(), Collections.emptySet()); if (!allowed.contains(target)) { throw new BizException("当前状态不允许该操作"); } u.setStatus(target); userDao.update(u); }

TRANSFER这张表的可读性比一长串 if 好太多,产品经理问「挂失能不能直接销户」,你看一眼表就能回答。注意销户被设计成终态,Collections.emptySet()表示没有任何出边,这样「销户后复机」这种非法路径在编译期之后就被堵死了。

光有迁移表还不够,读操作也要拦一道。比如通话前必须检查状态是不是 1,停机、挂失、销户都不能打:

// service/CallService.java —— 通话前置校验 public double call(String mobileNo, int minutes) { MobileUser u = userDao.findByNo(mobileNo); if (u == null) throw new BizException("卡号不存在"); if (u.getStatus() != 1) throw new BizException("当前状态不允许通话"); if (minutes <= 0 || minutes > 720) throw new BizException("通话时长不合法"); double fee = calcCallFee(u, minutes); if (u.getBalance() < fee) throw new BizException("余额不足,请先充值"); u.setBalance(u.getBalance() - fee); userDao.update(u); // 余额扣到 0 以下不存在,扣到 0 时自动停机 if (u.getBalance() <= 0) { u.setStatus(2); userDao.update(u); } return fee; }

minutes > 720这个上限是拍脑袋定的,防止用户输入一个天文数字把余额算成负数,真实系统不会这么限制,但练手项目加个边界值能挡住大部分误操作。余额判断要放在计费之后、扣款之前,顺序反了就会出现「先扣钱再说不合法」的荒唐结果。

3.3 控制台菜单:循环驱动、输入校验与异常兜底

菜单层最容易翻车的是Scanner.nextInt()。用户手滑输入一个字母,InputMismatchException直接抛出,程序退出;更糟的是异常被 catch 住但没清空缓冲区,while循环高速空转,控制台刷屏。这套代码的InputUtil用nextLine()读整行再解析,把「读」和「解析」拆开:

// util/InputUtil.java —— 读一行,解析失败就重试 public static int readInt(Scanner sc, String tip, int min, int max) { while (true) { System.out.print(tip); String line = sc.nextLine().trim(); // 永远只读整行 try { int v = Integer.parseInt(line); if (v < min || v > max) { System.out.println("请输入 " + min + "~" + max + " 之间的数字"); continue; } return v; } catch (NumberFormatException e) { System.out.println("输入格式不对,请重新输入"); // 不抛异常,继续循环,缓冲区已被 nextLine 消费干净 } } }

菜单主体用while(running) + switch驱动,每个 case 只做「收参数 → 调 service → 打结果」三件事,业务异常统一在 case 内部捕获并打印提示,不让异常穿透到主循环:

// view/MainMenu.java —— 主循环骨架 public void start() { boolean running = true; while (running) { printMenu(); int choice = InputUtil.readInt(scanner, "请选择:", 1, 6); try { switch (choice) { case 1: doOpenAccount(); break; case 2: doRecharge(); break; case 3: doCall(); break; case 4: doQueryBill(); break; case 5: doChangeStatus();break; case 6: running = false; break; default: break; } } catch (BizException e) { // 业务异常只提示,不退出;程序异常则往上抛,暴露 bug System.out.println("操作失败:" + e.getMessage()); } } System.out.println("已退出"); }

BizException和普通异常要分开处理:业务异常(余额不足、状态不对)是预期内的,打印提示让用户重来;NullPointerException这种是代码 bug,不该被 catch 掉,让它把堆栈打出来,你才知道哪里漏了判空。很多练手代码图省事写一个catch (Exception e)全兜住,结果 bug 被吞掉,调试时一脸懵。

4. 跑通并改造:从 javac 命令到加一个账单导出功能

看懂结构和业务之后,必须自己跑一遍才有体感。这一章分三步:先编译运行确认环境没问题,再把内存存储换成文本文件体会一下分层的好处,最后加一个原代码没有的账单导出功能,验证自己真的读懂了数据流。

4.1 环境准备与两种编译运行方式

JDK 版本 8、11、17 都能跑,代码里没用到新特性。唯一要盯住的是编码:源码是 UTF-8,Windows 控制台默认 GBK,直接编译会出现中文乱码甚至编译报错「不可映射的字符」。Linux 和 macOS 下一条命令搞定:

# 编译:-encoding 指定源码编码,-d 指定输出目录,-sourcepath 让 javac 自动找依赖类 javac -encoding UTF-8 -d out -sourcepath src src/com/sousou/Main.java # 运行:-cp 指向编译输出目录,后面跟全限定主类名 java -Dfile.encoding=UTF-8 -cp out com.sousou.Main

Windows 下没有find命令,别去抄 Linux 的写法,用-sourcepath让编译器自己顺着依赖找就行,一条命令即可:

javac -encoding UTF-8 -d out -sourcepath src src\com\sousou\Main.java chcp 65001 java -Dfile.encoding=UTF-8 -cp out com.sousou.Main

参数含义逐条说清楚:-encoding UTF-8告诉编译器源文件是什么编码,漏了这行,中文字符串常量在 GBK 环境下会变成问号;-d out把.class按包名输出到 out 目录,目录不存在会自动创建;-sourcepath src是关键,它让 javac 在编译Main.java时自动找到并编译它引用的其他类,不用手动列出几十个文件;运行时的-Dfile.encoding=UTF-8影响System.out的默认编码,Windows 下配合chcp 65001才不会乱码。用 IDEA 或 Eclipse 的话,把项目编码、控制台编码、运行配置的 VM 参数三处都设成 UTF-8,能省掉一半玄学问题。

提示:编译报「找不到符号」但类明明存在,九成是包名和目录结构对不上,或者源文件顶部的package声明写错,先检查这个再怀疑环境。

4.2 用 CSV 文件替换内存集合做持久化

这一步是检验分层是否真的解耦。目标是不动 service 和 view,只新增一个 dao 实现。先定义接口,把原来UserDao的方法抽出来:

// dao/UserDao.java —— 抽成接口,内存实现改名 InMemoryUserDao public interface UserDao { boolean insert(MobileUser u); MobileUser findByNo(String mobileNo); boolean update(MobileUser u); List<MobileUser> findAll(); int nextSeq(); }

然后写文件实现。启动时全量加载进 Map,每次写操作后全量刷盘,简单粗暴但可靠:

// dao/FileUserDao.java —— CSV 持久化,一行一个用户 public class FileUserDao implements UserDao { private static final Path FILE = Paths.get("data", "users.csv"); private final Map<String, MobileUser> cache = new LinkedHashMap<>(); private int seq = 10000000; public FileUserDao() { load(); // 构造时加载,文件不存在则跳过 } private void load() { if (!Files.exists(FILE)) return; try (BufferedReader r = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) { String line; while ((line = r.readLine()) != null) { if (line.trim().isEmpty()) continue; String[] f = line.split(",", -1); // -1 保留末尾空字段 MobileUser u = new MobileUser(); u.setMobileNo(f[0]); u.setName(f[1]); u.setPwd(f[2]); u.setBalance(Double.parseDouble(f[3])); u.setPlanId(Integer.parseInt(f[4])); u.setStatus(Integer.parseInt(f[5])); cache.put(u.getMobileNo(), u); } } catch (IOException e) { throw new RuntimeException("用户数据加载失败", e); } } private void flush() { try { Files.createDirectories(FILE.getParent()); try (BufferedWriter w = Files.newBufferedWriter(FILE, StandardCharsets.UTF_8)) { for (MobileUser u : cache.values()) { w.write(String.join(",", u.getMobileNo(), u.getName(), u.getPwd(), String.valueOf(u.getBalance()), String.valueOf(u.getPlanId()), String.valueOf(u.getStatus()))); w.newLine(); } } } catch (IOException e) { throw new RuntimeException("用户数据保存失败", e); } } @Override public boolean insert(MobileUser u) { if (cache.containsKey(u.getMobileNo())) return false; cache.put(u.getMobileNo(), u); flush(); return true; } // findByNo / update / findAll 同样基于 cache 操作,此处省略 }

split(",", -1)的第二个参数必须写 -1,否则末尾连续空字段会被丢弃,字段数对不上直接数组越界。Files.newBufferedReader显式传StandardCharsets.UTF_8,不依赖系统默认编码,跨平台不会乱。刷盘用 try-with-resources 保证流一定关闭,忘记关流在 Windows 上会导致文件被占用、下次启动读不到。最后在Main.java里把new UserDao()改成new FileUserDao(),service 和 view 一行不动——这就是分层带来的收益,改造范围可控。

4.3 动手加一个账单导出功能

原代码只有控制台打印账单,加一个导出 CSV 的功能,正好把「view 收参数、service 做组装、util 做格式化」这条线走一遍。先在 service 加方法:

// service/ChargeService.java —— 导出指定号码的全部流水 public int exportBills(String mobileNo, Path target) throws IOException { if (userDao.findByNo(mobileNo) == null) { throw new BizException("卡号不存在"); } List<Bill> bills = billDao.findByNo(mobileNo); try (BufferedWriter w = Files.newBufferedWriter(target, StandardCharsets.UTF_8)) { w.write('\uFEFF'); // BOM,让 Excel 正确识别 UTF-8,否则中文列名乱码 w.write("流水号,卡号,类型,金额,时间,备注"); w.newLine(); SimpleDateFormat fmt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); for (Bill b : bills) { w.write(String.join(",", String.valueOf(b.getBillNo()), b.getMobileNo(), b.getType(), String.format("%.2f", b.getAmount()), // 金额固定两位小数 fmt.format(b.getCreateTime()), b.getRemark() == null ? "" : b.getRemark())); w.newLine(); } } return bills.size(); }

\uFEFF这个 BOM 头是血泪经验:不加的话,Excel 打开 UTF-8 编码的 CSV 会把中文表头显示成乱码,用户以为文件坏了。金额用String.format("%.2f")格式化,避免0.1 + 0.2这类浮点误差直接暴露在账单里。remark判空是为了防止String.join遇到 null 抛 NPE。菜单层只加一个 case,收卡号和路径、调方法、打印条数,逻辑不多写一行。跑一遍:开户、充值两次、打一次电话,然后导出,用 Excel 打开检查金额和类型是否对得上,对得上说明整条数据流你摸清了。

5. 避坑清单:这套注释代码最容易翻车的五个地方

注释写得再全,跑起来还是会遇到问题,因为注释解释的是「为什么这么写」,不解释「你的环境为什么会炸」。下面五条是这类控制台工程里出现频率最高的,每条按现象、原因、解决三步写,遇到时对照排查。

5.1 输入一个字母程序直接退出或疯狂刷屏

现象:菜单让输入数字,手滑打了个字母,程序抛InputMismatchException退出;或者被外层 try-catch 兜住之后,控制台开始无限打印同一行提示,只能强杀进程。

原因:Scanner.nextInt()遇到非数字不会消费缓冲区,非法字符留在里面,下次读还是它,于是循环空转。同时nextInt不读换行符,和nextLine混用会出现「跳过输入」的现象。

解决:统一用nextLine()读整行,再用Integer.parseInt解析,解析失败就continue重试,缓冲区在nextLine时已经被清干净。全工程的输入入口收敛到InputUtil一处,别在菜单类里散着写scanner.nextInt()。

5.2 中文全变问号,或者编译直接报错

现象:控制台打印「鐢ㄦ埛涓嶅瓨鍦」这种乱码,或者javac报「编码 GBK 的不可映射字符」。

原因:源文件存的是 UTF-8,编译时没加-encoding UTF-8,编译器按系统默认 GBK 去解,中文常量就废了;另一种情况是编译没问题但运行输出乱码,那是控制台编码和 JVM 输出编码不一致。

解决:编译命令固定带-encoding UTF-8,运行加-Dfile.encoding=UTF-8,Windows 控制台先chcp 65001。IDE 里把 File Encodings 的 Global、Project、Default 三处都设成 UTF-8,别只改一处。

5.3 余额出现 0.30000000000000004

现象:充值 0.1 元再充值 0.2 元,查余额显示 0.30000000000000004,账单金额对不上,对账时怎么算都差一分钱。

原因:double是二进制浮点,无法精确表示 0.1 和 0.2,累加必然产生误差。这套代码用 double 存金额,练手阶段看不出问题,一旦做金额比较(余额是否够扣)就会翻车。

解决:金额字段改用BigDecimal,构造时用字符串new BigDecimal("0.1")而不是new BigDecimal(0.1);或者更彻底,内部按「分」存long,展示时除以 100。比较大小用compareTo不要用equals,因为1.0和1.00的 scale 不同,equals返回 false。

5.4 换套餐之后通话分钟数被清零或重复计费

现象:用户月中从 A 套餐换成 B 套餐,本月已用的通话分钟数要么被清成 0(白送额度),要么在 B 套餐里从头再算一遍(多扣钱)。

原因:计费依赖「本月已用分钟」,但这个「本月」的起点没有跟着套餐变更走。原代码按自然月算,切换套餐时没有处理账期边界,历史通话记录归属不清。

解决:在用户表加一个cycleStartTime字段,记录当前计费周期的起点;换套餐时不清历史记录,而是把新套餐的生效时间写入planEffectiveTime,计费时按生效时间分段计算,生效前按旧套餐、生效后按新套餐。练手项目可以简化成「换套餐立即生效、当月按新套餐重算」,但要在界面上明确告知用户,别让规则藏在代码里。

5.5 销户之后还能查到余额、还能充值

现象:用户已经销户,换一个菜单入口去充值,居然成功了,余额还涨了;或者查询已销户号码时抛空指针。

原因:状态校验散落在各个 service 方法里,写的时候漏了一两处。这类问题的特点是测试时容易漏,因为正常路径不会走到。

解决:把状态校验提到统一入口,比如在UserServiceImpl里写一个私有方法loadAndCheck(mobileNo, allowedStatus...),所有业务方法第一步都调它,参数是允许的状态列表。这样新增业务时只要记得调这一个方法,就不会漏判。再狠一点,在 dao 层做逻辑删除,销户用户的记录直接查不到,从数据层面堵住。

6. 进阶用法:把这份练手代码抬到能讲清设计的高度

代码能跑、能改,和「能在面试里讲清楚」之间还差一层。这份源码的价值恰恰在于它足够小,小到你可以把每个类都改造一遍而不至于失控。我的做法是先做三件事,再考虑加功能。

第一件,把 service 抽成接口。给UserService、ChargeService、CallService各写一个XxxService接口,原来的类改名XxxServiceImpl。这一步不产生任何新功能,但会逼你想清楚「这个服务对外承诺什么」,哪些方法是给菜单用的、哪些是内部复用的。改造完在Main.java里用接口类型声明变量,后续想加一个「带日志的装饰器」或者「带缓存的实现」,直接套一层就行,不用动调用方。

第二件,引入单元测试,优先覆盖三个纯计算的方法:calcCallFee、状态迁移校验、余额扣减。不用搭 Spring,JUnit 5 加几行断言就够:

// 套餐含 100 分钟,已用 95 分钟,本次通话 10 分钟,应只对 5 分钟收费 @Test void shouldChargeOnlyOverflowMinutes() { Plan plan = new Plan(1, "基础套餐", 58.0, 100, 0.2); CallService svc = new CallService(mockUserDao(95), mockPlanDao(plan), mockBillDao()); double fee = svc.calcCallFee(userWithPlan(1), 10); assertEquals(1.0, fee, 0.0001); // 5 分钟 * 0.2 元 }

断言里的1.0就是套餐外 5 分钟乘 0.2,写测试的过程会强迫你把「已用 95 分钟」这个前置状态构造出来,边界条件自然就想全了。改代码前先跑一遍测试,改完再跑一遍,绿了才算没破坏原有规则。

第三件,把金额全面换成BigDecimal,并同步改掉比较逻辑。这一步改动面比想象中大,因为==和>都要换成compareTo,0要换成BigDecimal.ZERO,好处是你会真正理解为什么金融系统不用浮点。改完把第 5 章第 3 条那个「0.30000000000000004」的用例补成测试,跑通即毕业。

改造项工作量带来的能力面试可讲点
service 抽接口半天实现可替换、可装饰依赖倒置、开闭原则
加单元测试一天改动有回归保护边界值分析、测试先行
金额换 BigDecimal半天消除精度误差浮点陷阱、不可变对象
存储换 JDBC两天数据可持久、可并发事务、连接池、SQL 注入

做完这四项,这个项目就不再是「跟着注释抄了一遍」,而是一个你能画出分层图、说清每处取舍、被追问时扛得住的小系统。我自己的习惯是每读完一个练手工程,都强制把「金额类型」「状态校验入口」「异常分层」这三处过一遍,因为这三处最能暴露一个人有没有真的写过业务代码。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 8:38:01

改进MOPSO算法在配电网有功无功优化中的应用

1. 项目概述 在配电网运行中&#xff0c;有功功率和无功功率的协调优化是提升系统稳定性和经济性的关键问题。传统优化方法往往难以同时满足多个相互冲突的目标&#xff0c;如电压偏差最小化、网损降低和投资成本控制。这正是多目标优化算法大显身手的领域。 我最近完成了一个…

作者头像 李华
网站建设 2026/10/31 8:37:02

《都市天际线2》画质模组与帧率优化:渲染、显存、LOD与冲突排查

凌晨一点半导出一张 4K 俯视图&#xff0c;天际线从薄雾里透出来&#xff0c;那种满足感能顶一整天的疲惫&#xff1b;可切回实机、拖动视角&#xff0c;帧率读数停在 21&#xff0c;鼠标划过屏幕带着明显拖影。这个循环我在《都市天际线2》里重复了太多次&#xff1a;画质拉满…

作者头像 李华
网站建设 2026/10/31 8:35:26

基于YOLOv8和AirScript的AI手游挂机系统实现

1. 项目背景与核心价值 手游挂机系统一直是游戏辅助领域的刚需&#xff0c;传统方案依赖固定坐标点击或图像模板匹配&#xff0c;存在适配性差、维护成本高的问题。我们这次要实现的AI挂机系统&#xff0c;基于YOLOv8目标检测模型和AirScript自动化引擎&#xff0c;能够智能识别…

作者头像 李华
网站建设 2026/10/31 8:34:49

Office自学教程怎么选怎么学:六模块路线与六周计划

在办公场景里摸爬滚打这么多年&#xff0c;我发现一个挺有意思的现象&#xff1a;真正把 Office 用明白的人&#xff0c;往往不是科班学计算机的&#xff0c;而是那些被工作逼着、自己找 office 自学教程一点点啃下来的普通职场人。问他们怎么学的&#xff0c;答案出奇一致——…

作者头像 李华
网站建设 2026/10/31 8:34:30

RMPC在自动驾驶路径跟踪中的工程实践与优化

1. 项目概述&#xff1a;RMPC在车辆路径跟踪中的核心价值鲁棒模型预测控制&#xff08;Robust Model Predictive Control, RMPC&#xff09;正在重塑自动驾驶车辆的路径跟踪能力。不同于传统PID或LQR控制&#xff0c;RMPC通过滚动优化和反馈校正机制&#xff0c;能够主动处理系…

作者头像 李华
网站建设 2026/10/31 8:32:11

VLAN资源池管理:原理、实现与优化实践

1. VLAN资源池的概念与业务背景 在大型企业网络和云计算环境中&#xff0c;VLAN&#xff08;虚拟局域网&#xff09;作为网络隔离的基础技术手段&#xff0c;其高效管理直接关系到整体网络的稳定性和可扩展性。VLAN资源池本质上是对可用VLAN ID&#xff08;1-4094&#xff09;进…

作者头像 李华