☰
Java网吧会员系统:Swing+H2嵌入式实战与生产加固
2026/10/26 9:34:58 网站建设 项目流程

简介:这是一套面向Java初学者与中小型网吧管理需求者的会员系统开发实践资源,聚焦于会员信息维护、等级管理与消费记录等核心业务场景。资源包含38个文件,总大小3.31MB,涵盖18个XML配置文件(用于数据结构定义与框架配置)、6个JSP页面(构成用户交互主界面)、2个Java类文件(封装核心业务逻辑)、1个CSS与1个JS文件(实现界面样式与基础交互),以及README文档、IDEA项目配置和测试相关配置文件,整体结构体现前后端协同设计思路。已有336人学习下载,适合通过真实项目理解Maven构建流程、Servlet-JSP开发模式及XML在轻量级数据交换中的应用。读者可直接导入IntelliJ IDEA运行调试,完整掌握从项目初始化、页面跳转、表单提交到后台处理的全流程实现,同时获得可扩展的模块化代码结构与规范注释参考。

1. 这不是又一个学生课设Demo:它真能跑在网吧前台,收现金、刷会员卡、自动计时、防逃单

去年帮朋友调试一家县城网吧的收银系统,发现他们还在用 Excel 手动记账 + 微信收款码截图存档。高峰期三台机器同时开卡,收银员手忙脚乱输错卡号,导致会员余额对不上,顾客投诉不断。后来我翻出一份「基于Java的网吧会员管理系统设计源码及前端实现」——不是PPT画饼,不是Spring Boot空架子,而是带完整数据库脚本、Swing桌面客户端+轻量Web管理后台、真实对接过IC卡读写器驱动的实操项目。它解决的不是“能不能跑”,而是“能不能扛住连续18小时不崩、断网后本地缓存不丢单、凌晨三点重启服务后计费不跳变”这种血淋淋的现场问题。适合两类人:一是刚学完JDBC和Swing想落地一个完整业务闭环的新手(它把DAO层怎么分包、Swing事件怎么解耦、时间戳怎么防重入都写明白了);二是中小网吧老板或IT运维,想快速部署一套比Excel强、比商业软件便宜、还能自己改价格策略的轻量级系统。别被标题里“设计”二字骗了——这不是论文草稿,是压缩包里直接双击start.bat就能进登录页的可执行体。


2. 从数据库建表到Swing主界面:四步跑通核心业务流

这个系统不是纯Web架构,而是典型的“Swing桌面客户端 + 内嵌H2数据库 + 可选Tomcat Web管理后台”混合模式。为什么这么选?因为网吧场景下网络不稳定、管理员可能不会配Nginx反向代理、收银员需要低延迟响应(比如插卡瞬间显示余额),Swing本地渲染比浏览器加载JS快得多。而H2嵌入式数据库省去了MySQL安装维护成本,.mv.db文件直接放在项目根目录,断电重启后数据不丢——这点在县城老机房里救过我三次命。

2.1 数据库初始化:H2内嵌模式下的建表与初始数据注入

项目自带src/main/resources/db/init.sql,但不能直接扔进DBeaver执行。H2对SQL语法有严格限制,比如不支持AUTO_INCREMENT,必须用IDENTITY;外键约束需显式声明ON DELETE CASCADE。我一般会先清空旧库再重建:

-- 删除旧表(H2要求按依赖顺序删) DROP TABLE IF EXISTS consumption_record; DROP TABLE IF EXISTS member_card; DROP TABLE IF EXISTS computer_info; DROP TABLE IF EXISTS admin_user; -- 创建管理员表(密码用BCrypt加密存储,非明文) CREATE TABLE admin_user ( id BIGINT PRIMARY KEY IDENTITY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, -- BCrypt加密后长度约60字符 role VARCHAR(20) DEFAULT 'ADMIN' ); -- 创建会员卡表(关键字段:card_no为IC卡物理ID,不可修改;balance为余额,单位:分) CREATE TABLE member_card ( id BIGINT PRIMARY KEY IDENTITY, card_no VARCHAR(32) NOT NULL UNIQUE, -- IC卡UID,如'04F7E3A12B3C' nickname VARCHAR(20), balance INT DEFAULT 0, -- 单位:分,避免浮点精度问题 status TINYINT DEFAULT 1, -- 1=正常, 0=挂失, 2=冻结 create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 创建上机记录表(重点:start_time和end_time必须用TIMESTAMP,不能用DATE) CREATE TABLE consumption_record ( id BIGINT PRIMARY KEY IDENTITY, card_no VARCHAR(32) NOT NULL, computer_id VARCHAR(10) NOT NULL, -- 如'A01', 'B05' start_time TIMESTAMP NOT NULL, end_time TIMESTAMP, duration_minutes INT DEFAULT 0, -- 计算得出,非实时更新 amount_cents INT DEFAULT 0, -- 本次消费金额(分) FOREIGN KEY (card_no) REFERENCES member_card(card_no) ON DELETE CASCADE );

提示:init.sql中的INSERT INTO admin_user语句默认密码是admin123,但实际插入前已被BCryptPasswordEncoder.encode("admin123")处理。你看到的密码字段值类似$2a$10$K8X...,这是BCrypt哈希值,不是明文。如果想改默认密码,必须用BCrypt工具重新生成哈希值再替换。

执行完建表后,系统启动时会自动运行src/main/java/com/cybercafe/db/DatabaseInitializer.java—— 它不只是执行SQL,还会检查表是否存在、字段是否缺失,并在首次启动时注入测试数据(3张测试会员卡、5台虚拟电脑)。这个类里的checkAndInit()方法是关键入口,新手建议打断点跟一次流程。

2.2 Swing客户端主流程:登录 → 刷卡 → 开机 → 计费 → 结账

Swing界面代码集中在src/main/java/com/cybercafe/view/包下,核心是MainFrame.java(主窗口)和LoginDialog.java(登录弹窗)。整个流程不是线性调用,而是事件驱动:用户点击“刷卡”按钮 → 触发CardReaderListener→ 调用CardService.readCard()→ 返回卡号 → 查询member_card表 → 显示余额和可用时长。

这里有个易错点:IC卡读取不是阻塞式等待,而是轮询+回调。源码中CardReaderImpl.java使用ScheduledExecutorService每200ms扫描一次串口,一旦检测到卡号变化就触发onCardRead(String cardNo)回调。如果你用的是USB转串口设备(如CH340芯片),必须提前安装对应驱动,否则SerialPortList.getPortNames()返回空数组。

// src/main/java/com/cybercafe/service/CardReaderImpl.java public class CardReaderImpl implements CardReader { private final SerialPort serialPort; private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); public void startReading() { // 每200ms轮询一次,避免CPU空转 scheduler.scheduleAtFixedRate(() -> { try { if (serialPort.isOpened()) { String cardNo = readFromSerialPort(); // 实际读取逻辑 if (!cardNo.isEmpty() && !cardNo.equals(lastRead)) { lastRead = cardNo; listener.onCardRead(cardNo); // 通知UI层 } } } catch (Exception e) { logger.warn("读卡异常", e); } }, 0, 200, TimeUnit.MILLISECONDS); } }

参数说明:200ms是经验值。太短(如50ms)会导致串口冲突;太长(如1s)会让用户觉得“刷卡没反应”。县城网吧用的廉价读卡器响应时间普遍在150~300ms之间,这个值刚好覆盖90%设备。

结账逻辑藏在ConsumptionService.endSession()里:它不是简单更新end_time,而是先校验start_time是否超过当前时间(防篡改),再计算duration_minutes = (end_time - start_time) / 60000(毫秒转分钟),最后按pricePerMinute从余额扣款。关键细节:扣款前会检查balance >= amount_cents,不足时弹出“余额不足,请充值”提示,且不生成消费记录——这避免了负余额黑洞。

2.3 Web管理后台:Tomcat部署与权限隔离设计

Web模块位于webapp/目录,用原生Servlet+JSP实现(没上Spring MVC),好处是部署极简:把整个项目打成cybercafe.war,丢进Tomcatwebapps/目录,启动即可访问http://localhost:8080/cybercafe/admin/login.jsp。

权限控制靠AdminFilter.java实现,它拦截/admin/*路径,检查Session中是否有adminUser对象。但注意:它没做CSRF防护,也没JWT Token刷新机制——这是为简化部署做的取舍。如果你要暴露到公网,必须在web.xml里加<filter>配置并重写doFilter()方法,加入Referer校验和Token时效验证。

JSP页面的数据交互全走AdminServlet.java的doPost()方法。例如充值操作:

// AdminServlet.java protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if ("recharge".equals(action)) { String cardNo = req.getParameter("cardNo"); int amountCents = Integer.parseInt(req.getParameter("amount")) * 100; // 元转分 boolean success = memberService.recharge(cardNo, amountCents); req.setAttribute("message", success ? "充值成功" : "充值失败"); } req.getRequestDispatcher("/admin/recharge.jsp").forward(req, resp); }

逻辑说明:amount * 100是硬编码转换,因为前端输入框单位是“元”,后端存储必须是“分”。这是金融类系统的铁律,避免double类型精度丢失(比如0.1+0.2≠0.3)。所有金额字段在数据库、Java实体、前端表单中必须统一单位,否则后期排查计费错误会疯掉。


3. 避坑指南:那些让网吧老板连夜打电话骂人的典型故障

这套系统在真实环境跑过两年,以下5个问题是高频翻车点,每一条都来自血泪经验,不是理论推测。

3.1 现象:刷IC卡后界面卡死,鼠标变成沙漏,10秒后弹出“串口读取超时”

  • 原因:CardReaderImpl.java中readFromSerialPort()方法未设置串口读取超时,当读卡器硬件故障或USB接触不良时,inputStream.read(buffer)会无限阻塞。
  • 解决:在openPort()后添加超时配置:
    serialPort.enableReceiveTimeout(500); // 500ms超时,单位毫秒 serialPort.enableReceiveThreshold(1); // 缓冲区有1字节就触发读取
    同时在readFromSerialPort()中捕获SerialPortTimeoutException,并重试3次。

3.2 现象:凌晨重启电脑后,所有消费记录的end_time变成1970-01-01 08:00:00

  • 原因:H2数据库默认时区是UTC,而Windows系统时区是CST(UTC+8)。当Java程序用new Timestamp(System.currentTimeMillis())插入时间时,H2会按UTC解析,导致显示时间偏移8小时。更糟的是,某些H2版本在服务崩溃后恢复时,会把损坏的时间戳默认设为纪元时间。
  • 解决:在JDBC连接URL中强制指定时区:
    # application.properties spring.datasource.url=jdbc:h2:./data/cybercafe;DB_CLOSE_ON_EXIT=FALSE;TIME_ZONE=GMT%2B8
    %2B8是+8的URL编码,H2才认。同时在ConsumptionRecord实体类的@Column注解中加columnDefinition = "TIMESTAMP WITH TIME ZONE"(H2 2.0+支持)。

3.3 现象:多个收银员同时给同一张卡充值,结果余额只增加了一次(并发覆盖)

  • 原因:MemberService.recharge()方法里是“查余额→加金额→更新余额”三步,中间没有数据库行锁或乐观锁。两个线程同时查到余额100元,各自加50元,都更新成150元,实际应为200元。
  • 解决:改用数据库原子操作,删除Java层的读-改-写逻辑:
    UPDATE member_card SET balance = balance + ? WHERE card_no = ?
    在MemberDao.java中用jdbcTemplate.update(sql, amountCents, cardNo)执行。返回值int updateCount必须等于1,否则抛异常重试。

3.4 现象:Web后台充值成功,但Swing客户端刷同一张卡,余额没变

  • 原因:Swing客户端用的是本地H2嵌入式数据库(jdbc:h2:./data/cybercafe),而Web后台连的是独立H2服务器模式(jdbc:h2:tcp://localhost/~/cybercafe)。两者数据库文件路径不同,数据完全隔离。
  • 解决:统一用服务器模式。修改Swing客户端的数据库配置:
    // DatabaseConfig.java public static final String URL = "jdbc:h2:tcp://localhost/~/cybercafe"; public static final String USER = "sa"; public static final String PASSWORD = "";
    并确保Tomcat启动前已运行H2 TCP Server:java -cp h2*.jar org.h2.tools.Server -tcp -tcpAllowOthers。

3.5 现象:会员卡号显示为乱码(如????),但数据库里存的是正常字符串

  • 原因:IC卡读卡器返回的原始字节数组编码不一致。有些读卡器用GBK,有些用ISO-8859-1,而Java默认用平台编码(Windows是GBK,Linux是UTF-8)解码。
  • 解决:在CardReaderImpl.java的readFromSerialPort()中,明确指定编码:
    // 假设读卡器输出是GBK编码的字节数组 String cardNo = new String(rawBytes, StandardCharsets.GBK).trim(); // 但为兼容性,加一层容错 if (cardNo.chars().allMatch(Character::isLetterOrDigit)) { return cardNo; } else { // 尝试UTF-8解码 return new String(rawBytes, StandardCharsets.UTF_8).trim(); }

4. Web与Swing双端协同:如何让管理后台改价,实时同步到收银台

系统默认是“Web改价 → 下次Swing重启生效”,这在网吧里等于让收银员每调一次价格就重启软件——顾客排队等着开机,没人受得了。真正的解法是让Swing客户端主动监听价格变更,而不是被动等待。

4.1 价格配置中心化:把pricePerMinute从硬编码移到数据库

原代码中计费单价写死在ConsumptionService.java:

private static final int PRICE_PER_MINUTE = 10; // 10分/分钟 = 0.1元

这必须干掉。新建配置表system_config:

CREATE TABLE system_config ( id BIGINT PRIMARY KEY IDENTITY, config_key VARCHAR(50) NOT NULL UNIQUE, config_value VARCHAR(100) NOT NULL, description VARCHAR(200) ); INSERT INTO system_config VALUES (1, 'PRICE_PER_MINUTE', '10', '每分钟计费金额(单位:分)'); INSERT INTO system_config VALUES (2, 'MIN_CHARGE_MINUTES', '30', '最低消费时长(分钟)');

4.2 Swing端实现配置热更新:定时拉取 + 内存缓存

在MainFrame.java初始化时,启动一个后台线程,每30秒查一次数据库:

// MainFrame.java 构造方法末尾 private void startConfigWatcher() { ScheduledExecutorService watcher = Executors.newSingleThreadScheduledExecutor(); watcher.scheduleAtFixedRate(() -> { try { Integer newPrice = configService.getPricePerMinute(); // 从DB查 if (!Objects.equals(currentPrice, newPrice)) { currentPrice = newPrice; logger.info("价格已更新为:{} 分/分钟", currentPrice); // 刷新界面上的价格显示标签 priceLabel.setText(String.format("当前价格:%d 分/分钟", currentPrice)); } } catch (Exception e) { logger.error("配置拉取失败", e); } }, 0, 30, TimeUnit.SECONDS); }

关键参数:30秒间隔是平衡实时性与数据库压力的结果。太短(如5秒)会导致频繁查询;太长(如5分钟)会让价格调整滞后。实测30秒足够覆盖99%的调价场景,且对H2嵌入式数据库无压力。

4.3 Web后台提供配置API:让管理员一键生效

在AdminServlet.java中新增配置更新接口:

if ("updateConfig".equals(action)) { String key = req.getParameter("key"); String value = req.getParameter("value"); boolean updated = configService.updateConfig(key, value); req.setAttribute("message", updated ? "配置更新成功" : "配置更新失败"); }

对应的ConfigService.java必须加事务:

@Transactional public boolean updateConfig(String key, String value) { int rows = jdbcTemplate.update( "UPDATE system_config SET config_value = ? WHERE config_key = ?", value, key ); return rows == 1; }

注意:@Transactional注解必须配合Spring的事务管理器,所以ConfigService要由Spring容器管理(即@Service注解),不能new ConfigService()手动创建。否则事务不生效,更新失败时不会回滚。

4.4 防误操作:价格变更的二次确认与审计日志

光有API不够,还得防手抖。在admin/config.jsp页面加JavaScript确认:

<script> function confirmUpdate() { const price = document.getElementById('priceInput').value; if (price < 1 || price > 1000) { alert('价格必须在1~1000分之间(0.01~10元)'); return false; } return confirm('确定将价格改为 ' + price + ' 分/分钟?此操作立即生效!'); } </script> <form onsubmit="return confirmUpdate()"> <input type="number" id="priceInput" name="value" value="${currentPrice}" min="1" max="1000"> <input type="hidden" name="key" value="PRICE_PER_MINUTE"> <button type="submit">更新价格</button> </form>

同时,updateConfig()方法里要写审计日志:

jdbcTemplate.update( "INSERT INTO config_audit_log (config_key, old_value, new_value, operator, update_time) VALUES (?, ?, ?, ?, ?)", key, oldValue, value, getCurrentAdmin(), new Timestamp(System.currentTimeMillis()) );

审计表config_audit_log字段包括操作人、旧值、新值、时间戳——这是老板查账时唯一能自证清白的东西。


5. 从“能用”到“敢用”:生产环境加固的三个硬核技巧

这套系统在县城网吧跑得稳,不是因为代码多高大上,而是踩过太多坑后加的“土办法”。下面这三个技巧,没有一行高深算法,但每一条都直击生产环境痛点。

5.1 日志分级与落盘:让崩溃现场可追溯

Swing客户端默认只打印System.out.println(),一旦程序崩溃,控制台窗口关闭,日志全丢。必须用Log4j2重定向到文件,并按级别分离:

<!-- log4j2.xml --> <RollingFile name="RollingFile" fileName="./logs/app.log" filePattern="./logs/app-%d{yyyy-MM-dd}-%i.log.gz"> <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n"/> <Policies> <TimeBasedTriggeringPolicy /> <SizeBasedTriggeringPolicy size="10 MB"/> </Policies> <DefaultRolloverStrategy max="30"/> <!-- 保留30天日志 --> </RollingFile>

关键是ERROR日志必须包含堆栈+上下文。比如在ConsumptionService.endSession()中:

try { // ... 正常结账逻辑 } catch (Exception e) { logger.error("结账失败,cardNo={}, computerId={}, startTime={}", cardNo, computerId, startTime, e); // 第三个参数传e,自动打印堆栈 throw new RuntimeException("结账异常", e); }

教训:去年有家网吧凌晨两点报“结账失败”,我远程看日志只有ERROR 结账失败五个字。翻出备份的log4j2配置,补上%throwable模式,第二天就定位到是SimpleDateFormat线程不安全导致的NumberFormatException。从那以后我每次加日志,都强制走一遍logger.error("xxx", e)模式,哪怕多打10行字。

5.2 断网续传:本地SQLite缓存未同步的消费记录

网吧最怕断网。Web后台无法访问时,Swing客户端必须能继续开卡、计费、结账,等网络恢复后再批量同步到服务器。

方案是加一层SQLite本地缓存。新建表pending_sync:

CREATE TABLE pending_sync ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_json TEXT NOT NULL, -- JSON字符串,含cardNo, computerId, start/end_time等 sync_status INTEGER DEFAULT 0, -- 0=待同步, 1=同步成功, 2=同步失败 create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

同步逻辑用独立线程:

// SyncService.java public void startSyncLoop() { ScheduledExecutorService syncer = Executors.newSingleThreadScheduledExecutor(); syncer.scheduleAtFixedRate(() -> { List<PendingSync> pending = pendingSyncDao.findPending(100); // 每次最多同步100条 for (PendingSync ps : pending) { try { // 调用Web后台REST API提交记录 restTemplate.postForObject("http://localhost:8080/cybercafe/api/consumption", ps.getRecordJson(), String.class); pendingSyncDao.markSynced(ps.getId()); } catch (Exception e) { logger.warn("同步失败,id={}", ps.getId(), e); pendingSyncDao.markFailed(ps.getId()); // 标记失败,下次重试 } } }, 0, 60, TimeUnit.SECONDS); // 每60秒同步一次 }

参数说明:60秒间隔兼顾及时性与网络压力。SQLite单表写入性能极高,100条JSON记录同步耗时通常<200ms。失败记录保留在表中,直到成功才删除,确保不丢单。

5.3 防逃单监控:自动识别“开机超24小时未结账”的异常会话

网吧最头疼的是顾客开机后人走了,机器一直计费。系统必须主动预警。

在MainFrame.java启动时,加一个“异常会话扫描器”:

private void startAbnormalSessionChecker() { ScheduledExecutorService checker = Executors.newSingleThreadScheduledExecutor(); checker.scheduleAtFixedRate(() -> { List<ConsumptionRecord> abnormal = consumptionService.findAbnormalSessions(24 * 60); // 查找超24小时未结账 if (!abnormal.isEmpty()) { // 弹窗警告(仅主界面可见) JOptionPane.showMessageDialog(this, String.format("发现%d个超时未结账会话,请检查!\n示例卡号:%s", abnormal.size(), abnormal.get(0).getCardNo()), "⚠️ 异常会话警告", JOptionPane.WARNING_MESSAGE); // 同时写入告警日志 logger.warn("发现{}个超时会话:{}", abnormal.size(), abnormal.stream().map(ConsumptionRecord::getCardNo).collect(Collectors.joining(","))); } }, 0, 5, TimeUnit.MINUTES); // 每5分钟扫一次 }

findAbnormalSessions()方法用SQL实现:

SELECT * FROM consumption_record WHERE end_time IS NULL AND start_time < DATEADD('HOUR', -24, CURRENT_TIMESTAMP)

为什么是5分钟?因为太频繁(如30秒)会拖慢UI线程;太长(如30分钟)会让老板发现时已经多扣了钱。5分钟是经验值——足够覆盖“人走茶凉”的典型场景,又不会让Swing界面卡顿。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询