
简介通信资费管理系统是一套基于JSP的Web开发实训项目面向正在学习JavaWeb与项目开发的初学者以通信行业资费管理为业务背景帮助理解动态网页与后台数据库的交互流程。压缩包内含29个文件其中24个HTML页面构成前端界面另有project工程配置文件与多张界面预览图片整体仅415KB结构紧凑、便于快速部署学习。目前已有719人学习参考。项目覆盖登录注册、资费套餐维护、账单查询、用户管理等功能模块代码中涉及JSP脚本元素、Servlet控制层、JDBC数据库操作、session会话跟踪及MVC分层思想适合结合实训手册逐段研读也可作为课程设计或毕业设计的改造基础是一份短小精悍的入门级Web项目范例。1. 一盒 JSP 实训项目拆出的通信资费业务闭环打开“通信资费管理系统.rar”之后很多人第一反应是找readme或者急着导入 Eclipse 点运行结果被一连串.html文件和缺失的 jar 包整懵圈。这个项目其实是典型的 JSP 课程设计技术上用 JSP Servlet JDBC跑在 Tomcat 上业务上覆盖套餐维护、用户注册登录、账单按月按年查询、后台管理员管理。它不算大但胜在结构完整适合正在做“JSP 程序设计与项目实训”的人拿来当主线。下面直接按解压到部署的顺序把目录结构、JSP 页面元素、JDBC 访问数据库、session 权限控制以及最后部署到 Tomcat 的排错技巧逐一过一遍。2. JSP 页面元素与目录职责划分从 listPricing 到 addpricing2.1 解包后的三层目录与页面分组把tongxin目录解压出来后先别急着看代码先把文件清单当成一张地图。这个 rar 包里的页面并不是平铺的而是按业务模块分成了几个目录这就是项目的粗粒度 MVC 划分。tongxin/ ├── user/ # 前台用户模块 │ ├── index.html # 用户首页 │ ├── register.html # 注册 │ ├── login.html # 登录 │ └── userSelf/ # 用户自助服务 │ ├── detail.html # 用户个人详情 │ ├── usermes.html # 用户消息 │ └── userServer.html ├── pricingManage/ # 资费套餐管理 │ ├── listPricing.html # 套餐列表 │ ├── addpricing.html # 添加套餐 │ └── pricingmes.html # 操作结果提示页 ├── reckonManage/ # 账单核算 │ ├── detail.html │ └── listerBilling.html ├── accountManage/ # 用户账单查询 │ ├── detailMonth.html # 按月账单 │ ├── detailYear.html # 按年账单 │ └── listerAccount.html ├── adminManage/ # 后台管理员管理 │ ├── admmes.html │ ├── listManager.html │ ├── addManage.html │ └── self.html ├── css/ img/ frame/ # 静态资源与页面框架 └── main.html # 主框架页这里有一个容易踩的坑目录里的页面文件后缀是.html但内容往往是 JSP 模板。Tomcat 默认的 JspServlet 只拦截*.jsp如果你直接把.html里塞% expression %丢给 Tomcat它会当作静态文件原样输出页面上一堆裸的 Java 代码。常见的处理方式有两种一是部署时把需要动态渲染的文件后缀改成.jsp二是在web.xml里给 JspServlet 补一段 URL 映射让它也解析*.html。后者我在实训项目里见过不少次能跑但答辩时容易说不清为什么这么配所以我更建议规规矩矩用.jsp。2.1.1 为什么 user 和 adminManage 要物理分开细心看目录会发现前台用户页面在user/后台管理页面在adminManage/中间隔着一层pricingManage和reckonManage。这不算什么高深设计但好处很直接权限控制可以在目录级别做文章。比如用 Filter 拦截/adminManage/*只有session里带roleadmin的请求才放行普通用户页面只要登录就能访问不必每个页面都写一遍角色判断。目录主要页面请求方是否需要管理员userregister.html / login.html / userSelf普通用户否pricingManagelistPricing / addpricing / pricingmes后台操作是reckonManagelisterBilling / detail后台操作是accountManagedetailMonth / detailYear / listerAccount登录用户否adminManageadmmes / addManage / listManager管理员是这样一个矩阵列出来后面写权限拦截时拦截路径就非常明确。很多实训项目不是功能难写而是页面一多就分不清哪些地方要挡、哪些地方放行。2.2 page 指令、表达式与脚本段三种元素的实际落点JSP 学习的核心是搞清楚指令、表达式、脚本段怎么写以及它们各自编译成什么。以套餐列表页listPricing.html为例拆出来看就是这三件事引入依赖类、输出数据、循环拼表格。% page contentTypetext/html;charsetUTF-8 languagejava % % page importjava.util.List, com.billing.model.Pricing % % ListPricing pricingList (ListPricing) request.getAttribute(pricingList); % table tr th套餐名称/th th月租/th th通话分钟/th /tr % if (pricingList ! null) { for (Pricing p : pricingList) { % tr td% p.getName() %/td td% p.getMonthlyFee() %/td td% p.getVoiceMinutes() %/td /tr % } } % /table这段代码里% page ... %指令控制页面属性contentType决定响应头里的文本类型和字符集import负责在生成的 Servlet 里加 import 语句。% ... %表达式本质上是out.print()的简写会在编译后的_jspService()方法里以 out.print 的形式输出。脚本段% ... %里的代码会被原样嵌入到_jspService()方法中所以局部变量、for 循环都能写在里面。需要注意脚本段里声明变量用的是局部变量语义不能在里面定义方法。如果你看到%! ... %这种声明标记那是把成员变量或方法写到 Servlet 类里很多教材会把两种混着讲但实训项目里基本用不到。2.3 addpricing 到 pricingmes表单提交与结果页资费管理的入口是addpricing.html它的任务是把套餐信息收集起来提交到后台。典型的表单长这样form actionpricingManager methodpost input typehidden nameaction valueadd / label套餐名称/label input typetext namename required / label月租/label input typetext namemonthlyFee required / label通话分钟/label input typetext namevoiceMinutes required / label流量额度/label input typetext namedataQuota required / button typesubmit提交/button /formaction指向一个 Servlet 或 Struts Action这个项目里可以直接写 Servlet 的 URL 映射。methodpost很关键套餐名称带中文如果用 GET 提交Tomcat 8 之前版本对 URL 编码的处理很容易出乱码POST 配合request.setCharacterEncoding(UTF-8)才能保证中文不乱。表单里埋了一个hidden字段actionadd这是 MVC 里很常见的做法一个 Servlet 接收不同 action 值分别处理增删改查避免为每个操作写一个 Servlet 类。提交后Servlet 里做了PreparedStatement插入成功后转发到pricingmes.html资费操作结果页失败则带错误参数转发回addpricing.html。这个“表单页 → 控制器 → 结果页”的三角关系就是把 JSP 用作 View、Servlet 用作 Controller 的最小闭环。3. JDBC 读写与资费数据模型PreparedStatement 与分月分年账单3.1 从 detailMonth 与 listerAccount 反推数据表JSP 页面本身不产生数据它只是数据的中转站。这个系统里accountManage下有一组按月、按年查询账单的页面反推数据库表结构是一件很好玩的事。至少要有三张表表名关键字段说明usersid, username, password, real_name, phone, pricing_id用户信息关联套餐pricingid, name, monthly_fee, voice_minutes, data_quota, status资费套餐status 控制上下架billing_recordsid, user_id, bill_month, total_fee, call_minutes, data_used, generated_time每月账单记录users.pricing_id指向pricing.id表示用户当前订购的套餐billing_records.user_id指向users.id一个用户每月一条账单。detailMonth.html页面按月份展示这些记录detailYear.html则在billing_records上做聚合查询。这个模型是通信资费系统里最基础的一版没有复杂的折扣规则、没有阶梯计费但对 JSP 实训来说已经足够把增删改查和表关联讲清楚。3.2 用 PreparedStatement 写出查询套餐的 JDBC 代码拿套餐列表这个最常见的查询来说listPricing.html要展示的数据来自pricing表。在 MVC 思路下数据获取应该放在 Model 层由 Servlet 把结果放进 request 再转发给 JSP。JDBC 的标准写法如下String url jdbc:mysql://localhost:3306/tongxin?useUnicodetruecharacterEncodingutf-8useSSLfalse; String dbUser root; String dbPassword 123456; String sql SELECT id, name, monthly_fee, voice_minutes, data_quota FROM pricing WHERE status 1 ORDER BY monthly_fee; try (Connection conn DriverManager.getConnection(url, dbUser, dbPassword); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { ListPricing pricingList new ArrayList(); while (rs.next()) { Pricing p new Pricing(); p.setId(rs.getInt(id)); p.setName(rs.getString(name)); p.setMonthlyFee(rs.getBigDecimal(monthly_fee)); p.setVoiceMinutes(rs.getInt(voice_minutes)); p.setDataQuota(rs.getBigDecimal(data_quota)); pricingList.add(p); } request.setAttribute(pricingList, pricingList); } catch (SQLException e) { log.error(query pricing list failed, e); }DriverManager.getConnection的三个参数依次是 JDBC URL、用户名、密码。URL 里的characterEncodingutf-8负责让中文参数和中文结果在数据库和 Java 之间正确转换useSSLfalse则是为了绕开 MySQL 8 在本地环境默认启用 SSL 的告警。PreparedStatement在这里不只是预编译更重要的是参数化查询如果后面要加WHERE name LIKE ?直接用ps.setString(1, name)传参SQL 注入基本被堵死。try-with-resources会在代码块结束后自动关闭Connection、PreparedStatement、ResultSet这个习惯在实训里很加分因为很多新手只关最外层连接连接池被耗尽才反应过来。3.3 月度账单与年度账单SQL 边界与统计写法detailMonth.html和detailYear.html虽然页面长得像但底层 SQL 差别很大。月度查询要的是某用户某个月份的账单明细SELECT bill_month, total_fee, call_minutes, data_used FROM billing_records WHERE user_id ? AND DATE_FORMAT(bill_month, %Y-%m) ? ORDER BY bill_month DESC;第一个?是用户 ID第二个?是前端传入的月份字符串比如2025-04。DATE_FORMAT(bill_month, %Y-%m)把日期字段格式化成年-月再比较直观但有个隐患它让bill_month字段无法走普通 B 树索引数据量到几十万行之后这条查询会变成全表扫描。年度查询本质上是个 GROUP BY 聚合SELECT YEAR(bill_month) AS bill_year, SUM(total_fee) AS year_fee, SUM(call_minutes) AS year_calls, SUM(data_used) AS year_data FROM billing_records WHERE user_id ? AND YEAR(bill_month) ? GROUP BY YEAR(bill_month);这个写法的问题跟上面一样YEAR(bill_month)函数包裹了字段索引失效。对实训项目数据量小性能问题不明显但答辩时如果能主动说一句“实际生产环境我会写成bill_month BETWEEN 2025-01-01 AND 2025-12-31 23:59:59把函数运算改成范围条件让索引能用上”会比单纯把功能跑通高一个段位。年度聚合的另一个注意点是SUM对total_fee求和时如果表里没有数据结果会是NULL页面展示用c:out或者三元表达式兜底为 0.00不然表格里会出现一个刺眼的空白。4. Servlet 会话管理与 JSP 权限分流login、register、userSelf 的状态链路4.1 从登录成功到 session 建立这个系统涉及两类角色普通用户和管理员。login.html提交后Servlet 要做的事情很简单查库、比对密码、写 session、跳转。String username request.getParameter(username); String password request.getParameter(password); String sql SELECT id, username, role FROM users WHERE username ? AND password ?; try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { HttpSession session request.getSession(true); session.setAttribute(uid, rs.getInt(id)); session.setAttribute(role, rs.getString(role)); response.sendRedirect(user/userSelf/detail.html); } else { response.sendRedirect(user/login.html?error1); } } }request.getSession(true)的意思是如果当前请求没有关联的 session就新建一个如果已经有就返回已有的。判断登录成功之后把用户 ID 和角色写进 session后面所有页面只要从 session 里拿这两个值就知道“当前请求是谁、能不能做某件事”。这里我只存了uid和role没把整个用户对象塞进去原因是 session 默认存在服务器内存里如果用户对象变大并发上来之后内存压力会很明显。实训项目还有一个通病密码直接明文比对。课程设计可以这么写图省事。但如果你想让这个项目在答辩时更有说法可以在register.html提交时用MessageDigest做一次 SHA-256数据库里存摘要登录时同样哈希后再比对。不用引入额外框架十几行工具方法的事。4.2 userSelf 与 usermes同一份页面两个状态userSelf/detail.html展示用户自己的信息它有个特点没登录的人不应该看得到。JSP 里最朴素的拦截就是页面顶部塞一段脚本检查 session。% page contentTypetext/html;charsetUTF-8 % % Integer uid (Integer) session.getAttribute(uid); if (uid null) { response.sendRedirect(../login.html); return; } %这段逻辑放到_jspService()里效果是在输出 HTML 之前先判断未登录直接重定向回登录页return会中断后续所有页面内容的输出。这是最粗暴但最好理解的权限控制也是这个项目里各页面出现大量重复判断的原因。如果页面多了这套做法维护成本会很高所以正规项目一般用 Filter 统一拦截。register.html注册成功之后一般不会自动登录而是跳转到login.html让用户再输一次密码。这看起来多余但其实是给用户一个确认密码的机会也避免把注册和登录两个动作耦合在一起。如果希望注册完直接进入系统同样是在 Servlet 里调用一次request.getSession(true)再setAttribute链路和登录完全一致。4.3 adminManage 下的管理页权限校验后台页面和前台用户页面的区别在于前者不仅要求登录还要求角色是管理员。adminManage/admmes.html这类页面单独校验uid不够还得多校验一个role。% String role (String) session.getAttribute(role); if (!admin.equals(role)) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return; } %admin.equals(role)这种写法把常量放在前面能避免role为 null 时抛空指针。页面用sendError(403)而不是重定向语义更准确你不是没登录你是没有权限。对普通用户访问后台的行为给出 403 比跳回登录页更能避免“登录了怎么还让我登录”的困惑。但这里有个实训项目常见的盲区页面里做权限校验只能挡住直接通过 URL 访问页面的情况。如果请求是先到 Servlet由 Servlet 转发到 JSP那 JSP 里的这段校验逻辑依然有效因为session是同一个。反过来如果 JSP 页面里嵌了jsp:include公共页头被包含的片段里也做了 session 判断那这个判断会被执行多次虽然不影响正确性但日志里会很难看。4.3.1 页面访问控制矩阵与拦截路径设计把前面几张表合在一起可以得到一个完整的拦截规则URL 路径匿名用户普通用户管理员/user/login.html允许允许允许/user/userSelf/*重定向登录允许允许/pricingManage/listPricing.html重定向登录允许允许/pricingManage/addpricing.html403403允许/reckonManage/*403403允许/adminManage/*403403允许套餐列表页listPricing.html允许普通用户看但新增套餐addpricing.html只允许管理员。如果项目里已经引入了 Filter拦截器里按这个矩阵写路径匹配就行如果没引入也至少把所有管理页的 JSP 开头补上角色判断而不是只检查登录。session 超时也是一个容易忽略的边界。Tomcat 默认 session 超时是 30 分钟如果用户在后台填了半个小时的套餐信息点提交时 session 过期整个请求会被重定向到登录页填的内容全部丢失。对这个系统来说可以在web.xml里把超时适当调长session-config session-timeout60/session-timeout /session-configsession-timeout的单位是分钟60 表示一小时。对资费管理这种后台操作60 分钟比默认值更宽容也不至于让无用的 session 在内存里堆太久。这个配置答辩时被问到的概率很高值得背下来。5. 部署、RAR 包核对与排错清单Tomcat 下的启动与账单查询5.1 先核对 RAR 再谈部署文件完整性与结构确认拿到 rar 包先别双击解压就跑先摸清压缩包本身有没有问题。我一般会在 Linux 上跑一句unrar t验证压缩包完整性再跑unrar l看文件列表unrar t tongxin.rar unrar l tongxin.rart是测试文件是否损坏l是列出包内所有文件。重点看两类东西一是WEB-INF/lib下有没有mysql-connector-java.jar二是数据库初始化脚本通常是.sql文件在不在。如果压缩包在下载过程中损坏页面会出现一堆莫名其妙的资源 404这时候优先重新下载不要浪费时间尝试修复 rar 头。Windows 上可以用 WinRAR 或 7-Zip 打开后先执行“测试压缩文件”效果一样。5.2 从解压目录到 WAR手动部署到 Tomcat确认压缩包没问题之后解压出tongxin工程目录接下来有两种常见部署路径。第一种是 Eclipse 导入File - Import - General - Existing Projects into Workspace选择解压目录然后右键项目Run As - Run on Server。第二种是手动打包成 WAR 放到 Tomcatcd tongxin jar -cvf ../tongxin.war . cp ../tongxin.war $CATALINA_HOME/webapps/jar -cvf会把当前目录打包成 war结尾的.表示打包整个目录。把 war 丢进webapps后启动 Tomcat它会自动解压访问路径就是http://localhost:8080/tongxin/。如果端口被占用改conf/server.xml里的Connector port8080 ...。注意如果你的页面文件是.html但里面有 JSP 标签这一步就需要先按第 2 章说的方法处理后缀或者确认web.xml里配过对应映射。5.3 启动后的常见报错与日志定位部署后报错基本集中在四类404、500、ClassNotFoundException、中文乱码。排查顺序有讲究先看日志再看页面不要盲目重启。tail -f $CATALINA_HOME/logs/catalina.out tail -f $CATALINA_HOME/logs/localhost.$(date %Y-%m-%d).log现象大概率原因处理方式访问 /tongxin/ 返回 404上下文路径不对确认 webapps 下是否有 tongxin 目录页面 500 且日志报 SQL 语法错误MySQL 版本兼容问题检查 DATE_FORMAT / YEAR 这类函数是否被目标库支持抛 ClassNotFoundException: com.mysql.jdbc.DriverJDBC 驱动 jar 不在 WEB-INF/lib把 mysql-connector-java.jar 放进 WEB-INF/lib 并重新发布表单提交后中文乱码页面编码与数据库编码不一致统一页面 charset、JDBC URL 的 characterEncoding、数据库表字符集为 utf8mb4JSP 页面源码被原样输出Tomcat 未解析 .html按第 2 章修改后缀或配置 JspServlet 映射日志里看到SLF4J或log4j警告不用慌很多实训项目只是缺日志实现不影响功能。ClassNotFoundException是最值得细看的因为多数情况不是代码问题而是 jar 没发布到WEB-INF/lib或者 Tomcat 缓存了旧的 class 目录需要Clean一下再重启。5.4 账单查询链路验证清单与两个小改造功能跑通之后我建议按业务链路把整条线走一遍不要只测登录页。验证顺序可以参考下面这张表步骤操作预期结果注册register.html 提交新用户数据库 users 表多一条记录登录login.html 填写刚注册的账号跳转 userSelf/detail.html添加套餐adminManage 登录后打开 addpricing.html提交后跳 pricingmes.html列表中可见新套餐生成账单直接往 billing_records 插入目标月份数据数据能查到页面金额与库中一致月账单detailMonth.html 选择目标月份返回该月明细无 NULL 金额年账单detailYear.html 选择目标年份年度合计等于该年各月金额之和走完这条链之后剩下的都是加分项。我会顺手做两个小改造把 JDBC 的连接参数从每个 Servlet 里抽出来放到WEB-INF/classes/db.properties用Properties.load()读取再把 Tomcat 的session-timeout按业务改成 60 分钟。前者在答辩时讲“配置与代码解耦”后者讲“会话生命周期管理”都是这个项目框架内能自圆其说的小亮点比临时去背框架概念自然得多。本文还有配套的精品资源点击获取