广告墙Java课程设计:从ER建模到定时下架全流程实现 简介面向Java课程实验与课程设计的「广告墙」项目源码包适用于计算机、人工智能、通信工程等专业的在校生、教师及初级开发者旨在帮助读者快速掌握广告信息管理、用户注册登录、管理员后台等典型业务模块的实现思路并可直接用于课程设计、毕设演示或项目初期立项。压缩包共138个文件以82个Java源文件为核心另含44个class编译文件、6个XML界面与配置资源、SQL数据库初始化脚本及README说明文档整体仅1.05MB轻量易部署。从内容预览来看项目覆盖广告增删改查、用户与管理员登录、密码修改、个人广告查询等功能界面类与逻辑类分离适合按模块逐段学习。目前已有375人学习下载代码均通过运行测试答辩平均分达94.5分可放心使用基础较好的读者还可基于现有代码扩展审核、分类或统计等新功能进一步提升综合实践能力。1. 广告墙课程设计第一个难点不是写代码答辩老师问一句“广告墙和新闻列表到底差在哪”很多抱着“能跑就行”的同学当场卡住。广告墙是Java课程设计里很典型的一道题它本质是一条带生命周期的广告数据用户填写内容、管理员审核、到期自动下架首页展示的永远只是“已上架”的那些。整个项目绕不开ER图设计、JDBC操作、Servlet/JSP分层、定时任务这几块正好覆盖java基础到java项目阶段最常用的技能。这篇文章按验收路径来讲先数据建模再实现发布、审核、分页、上传、定时下架最后给答辩前的自查清单适合正在写实验报告的你照着落地。2. 先画ER图还是先写代码把广告墙的实体和状态钉进数据库2.1 广告墙的实体关系用户、广告、分类、审核记录课程设计第一稿不用急着建表先画ER图。广告墙真正有业务价值的实体有四个用户、广告、分类、审核记录。用户和广告是一对多一个用户可以发布多条广告分类和广告也是多对多不对这里是一对多一条广告只能归到一个分类一个分类下可以挂多条广告广告和审核记录的关系要单独拆出来因为一条广告可能被审核多次——草稿提交一次被驳回改完再提交再审一次。为什么审核记录一定要独立成表因为答辩时评委大概率会问“你如何证明广告是经过审核才上架的”把审核人、原状态、新状态、备注放进一张审计表几个字段就答清楚了。这也是java面试里常被拎出来考的点ER图转关系模型时一对多靠父子表外键多对多要拆出中间表审计类数据跟主数据分离。现在把关系写清楚后面建表、写DAO、画页面都是顺着走不会出现表结构边写边改的情况。2.2 用SQL建四张表字符集、联合索引和逻辑外键一次说清ER图落成表直接看这组DDLMySQL 8.0 下可直接执行CREATE DATABASE adwall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE adwall; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT 密码摘要不存明文, role TINYINT NOT NULL DEFAULT 1 COMMENT 0管理员 1普通用户, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB COMMENT用户表; CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE, sort_order INT NOT NULL DEFAULT 0 COMMENT 展示排序小的在前 ) ENGINEInnoDB COMMENT广告分类表; CREATE TABLE ad ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, category_id BIGINT NOT NULL, title VARCHAR(120) NOT NULL, content TEXT, image_url VARCHAR(255) COMMENT 广告图存相对路径, link_url VARCHAR(255) COMMENT 点击跳转地址, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1待审核 2上架 3下架 4驳回, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, end_time), KEY idx_status_end (status, end_time) ) ENGINEInnoDB COMMENT广告表; CREATE TABLE audit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ad_id BIGINT NOT NULL, from_status TINYINT NOT NULL, to_status TINYINT NOT NULL, operator_id BIGINT NOT NULL, remark VARCHAR(255) COMMENT 驳回原因, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_ad (ad_id) ) ENGINEInnoDB COMMENT广告审核记录表;字符集选 utf8mb4 而不是 utf8因为广告内容可能带 emoji 或生僻字utf8 是变长三字节遇到四字节字符直接报错。idx_status_end (status, end_time)是这张表最关键的联合索引首页按状态过滤、定时任务按过期时间扫描走的都是这两个字段没有它数据量到万级页面就会明显变慢。status用 TINYINT 而不用 VARCHAR因为状态集合固定且数量少数字比较比字符串快同时 DAO 层配合枚举类型不会写错值。start_time和end_time用 DATETIME 而不是 DATE定时下架精确到分钟比较合理DATE 类型会丢掉同一天内的时效差异。这里故意不写物理外键。用户、分类都可能被业务改动波及物理外键在删父表记录时会互相卡住课程设计阶段用逻辑外键保持灵活性答辩时如果能主动解释一句“为什么不用 FOREIGN KEY”是明显的加分项。2.3 状态机藏在status字段里用枚举类包住非法跳转广告墙的核心业务全押在状态上状态转换关系是评审必问的隐藏需求操作原状态新状态备注提交审核草稿待审核用户可以多次提交审核通过待审核上架写入audit_log审核驳回待审核驳回remark必填手动下架上架下架卖家主动撤下自动下架上架下架定时任务触发重新提交驳回待审核修改后再次申请非法跳转管不管直接区分这次实验是“做了”还是“做透了”。比如“下架后直接改成上架”“驳回后跳到草稿”都应该被业务层拦截。把状态包成一个枚举类public enum AdStatus { DRAFT(0, 草稿), PENDING(1, 待审核), ONLINE(2, 上架), OFFLINE(3, 下架), REJECTED(4, 驳回); private final int code; private final String desc; AdStatus(int code, String desc) { this.code code; this.desc desc; } public int code() { return code; } public String desc() { return desc; } public boolean canTransferTo(AdStatus target) { switch (this) { case DRAFT: return target PENDING || target OFFLINE; case PENDING: return target ONLINE || target REJECTED || target OFFLINE; case ONLINE: return target OFFLINE; case REJECTED: return target PENDING || target DRAFT; default: return false; } } }所有业务代码只依赖这个枚举DAO 落库时调code()页面回显时调desc()状态流经各层都走同一个类型不会出现魔法数字散落在 Servlet 里没人认识的情况。这处设计属于典型的“课程设计隐分点”练过 java 基础、看过 java 面试八股文相关内容的人一眼就能看出你这套代码不是临时凑出来的。3. 分层落地Servlet JDBC 把发布和审核写成可答辩的代码3.1 model、dao、service、web 四层包结构怎么切课程设计被批得最多的一个坏味道是一个 Servlet 里既拼 SQL 又拼 HTML。广告墙的模块边界很清楚包结构按职责切成四层adwall/src/main/java/com/example/adwall ├── model # Ad.java User.java AdStatus.java 纯字段 ├── dao # AdDao.java AuditLogDao.java 只负责SQL ├── service # AdService.java 负责发布、审核、事务边界 ├── web # AdServlet.java UploadServlet.java 只解析参数 ├── filter # EncodingFilter.java LoginFilter.java └── util # DBUtil.java FileUploadUtil.java 通用工具model里的类除了 getter/setter 不写业务逻辑dao层方法签名里能看见Connection参数连接由上层传入便于同一事务复用连接service层组织业务流程事务开关也放这里web层的 Servlet 不写 SQL、不连数据库只做参数解析和页面转发。我在代码评审里见过大量把Class.forName(com.mysql.cj.jdbc.Driver)写在每个 Servlet 里的写法这不是能跑不能跑的问题是连接数不可控。建议 DBUtil 用一个静态的 Druid 或 HikariCP 连接池初始化一次之后所有 DAO 都从池子里取连接。没有连接池的话并发一上来连接数直接打满页面卡死。3.2 PreparedStatement 的非拼接写法SQL注入检查这一关得过广告墙列表是高频查询首页按状态取、后台按审核状态取都逃不过WHERE status ?这个条件。用 JDBC 写分页查询的标准姿势如下public ListAd findPageByStatus(Connection conn, int status, int currentPage, int pageSize) throws SQLException { String sql SELECT id, user_id, title, image_url, link_url, end_time, status FROM ad WHERE status ? ORDER BY id DESC LIMIT ?, ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, status); ps.setInt(2, (currentPage - 1) * pageSize); ps.setInt(3, pageSize); try (ResultSet rs ps.executeQuery()) { ListAd list new ArrayList(); while (rs.next()) { Ad ad new Ad(); ad.setId(rs.getLong(id)); ad.setTitle(rs.getString(title)); // 剩余字段略 list.add(ad); } return list; } } }三个参数的说明第一个?绑定状态值首页广告墙只传AdStatus.ONLINE.code()后台则传各个待查询状态第二、三个?是分页的偏移量和每页条数LIMIT ?, ?左边的偏移从 0 开始算所以第一页 currentPage1 时偏移是(1-1)*pageSize0第二页是 10。为什么必须用 PreparedStatement因为?占位符会把值当参数传给数据库服务端驱动在本地对特殊字符做转义如果图省事用字符串拼接用户在搜索框输入 OR 11就能把广告表全部拖出来。这个检查在某些学校的验收环节是加分项在另一些项目的代码审计里直接是“必须改”级别。如果后续换成 MyBatisSQL 映射文件里写的依然是同样的#{}占位语法MyBatis 的 Mapper 接口实现是靠 JDK 动态代理在运行时生成的这两个知识点可以放在实验报告“技术选型对比”里一起写。3.3 Service层审核事务改状态和写审计日志要绑在同一个Connection审核广告由两件事组成更新ad.status同时向audit_log插入一条记录。两次写操作必须是一个事务否则会出现“状态改成了上架日志却没记”的中间状态。Service 层代码public void audit(Long adId, Long operatorId, boolean pass, String remark) { String update UPDATE ad SET status? WHERE id?; String insert INSERT INTO audit_log(ad_id, from_status, to_status, operator_id, remark) VALUES (?,?,?,?,?); try (Connection conn DBUtil.getConnection()) { conn.setAutoCommit(false); int from findStatus(adId, conn); // 辅助方法查原状态 int to pass ? AdStatus.ONLINE.code() : AdStatus.REJECTED.code(); try (PreparedStatement ps conn.prepareStatement(update)) { ps.setInt(1, to); ps.setLong(2, adId); ps.executeUpdate(); } try (PreparedStatement ps conn.prepareStatement(insert)) { ps.setLong(1, adId); ps.setInt(2, from); ps.setInt(3, to); ps.setLong(4, operatorId); ps.setString(5, pass ? 审核通过 : remark); ps.executeUpdate(); } conn.commit(); } catch (SQLException e) { throw new RuntimeException(审核失败请确认广告存在且状态为待审核, e); } finally { // 连接归还连接池前必须恢复自动提交 DBUtil.setAutoCommit(conn, true); } }事务的提交点在两条 SQL 都成功之后任何一条失败都整体回滚。from读的是原状态写进审计记录后在页面上能看到“待审核→上架”“待审核→驳回”的完整链路驳回时remark必须是非空字符串这是审核功能的基本要求。注意finally里恢复自动提交这一步很多人漏掉。连接从池子里取出来时如果还停留在上次的手动提交状态后续拿到这条连接的其他代码会特别难排查。课程设计阶段不要求高并发但事务边界和连接归还这两个意识写到实验报告的“遇到的问题”里比写功能点更有分量。3.4 JSP页面只做渲染EL表达式和JSTL取广告列表广告墙前端页面用 JSP 渲染但 Java 服务端页面里不应该出现 Java 代码片段也就是% %这种写法。取列表交给 Servlet转发到 JSP 后用 EL 和 JSTL 输出% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % %-- 广告墙首页只展示已上架广告 --% c:forEach items${adList} varad div classad-card img src${ad.imageUrl} alt${ad.title} / h3a href${ad.linkUrl} target_blank${ad.title}/a/h3 span到期时间${ad.endTime}/span /div /c:forEachServlet 端把查询结果request.setAttribute(adList, list)转发到ad_wall.jsp页面上items取到的就是ListAdvar定义了循环变量名。${ad.imageUrl}会自动调用Ad的 getter不需要在页面里写任何强制类型转换。Java server pages 这门课的核心本来就是模板渲染把 JDBC 连接、ResultSet 搬进 JSP 里属于典型的职责混乱。页面直接连接数据库会在每次刷新时新建连接、占用数据库资源而且一旦 SQL 报错整页的报错堆栈直接甩给浏览器既不好看也不好调。4. 分页查询、图片上传和定时下架广告墙的硬骨头都在这里4.1 分页参数怎么设currentPage、pageSize、offset 三步算清广告墙首页只展示上架广告但后台管理要翻所有状态的广告分页是绕不开的。分页的参数就三个currentPage当前页码、pageSize每页条数、offset传给 SQL 的偏移量。参数含义建议取值currentPage当前页码从1开始页面请求参数传入pageSize每页条数前台墙 20后台列表 10offsetSQL偏移量(currentPage - 1) * pageSizeSELECT COUNT(*) AS total FROM ad WHERE status 2; SELECT id, title, image_url, link_url, end_time FROM ad WHERE status 2 ORDER BY id DESC LIMIT 0, 10;第一条 SQL 查总数用来算总页数第二条是当前页数据LIMIT 0, 10表示从第 0 行开始取 10 条。两条 SQL 的 WHERE 条件必须一致否则会出现页数和数据对不上的情况。页面传上来的currentPage要先做非法值校验小于 1 归一成 1大于总页数归一成最大页否则用户手动改 URL 会看到空白页。深分页问题在这里先不展开几十万条数据时LIMIT 100000, 10会先扫 10 万行再丢弃这是课程设计阶段的已知边界答辩被问到就答“当前方案在万级数据量可用进一步优化可以改走游标或记录上一页最后一条ID”。4.2 图片上传存本地目录还是存BLOB列路径安全这么处理广告墙的核心元素是图片。图片到底存哪里是一个选型问题。存数据库 BLOB 列的好处是备份简单但坏处更明显查询列表时要把图片二进制一起取出数据库 IO 和网络 IO 一起被拉高数据量大时很吃力。最常见的做法还是存文件系统数据库只保存相对路径存文件的工具录方法长这样public static String saveImage(Part filePart, String baseDir) throws IOException { // 只取文件名部分避免传入 a/b/c.png 这种带路径的参数 String originalName Paths.get(filePart.getSubmittedFileName()).getFileName().toString(); String ext originalName.substring(originalName.lastIndexOf(.)).toLowerCase(); if (!Arrays.asList(.jpg, .jpeg, .png, .gif, .webp).contains(ext)) { throw new IllegalArgumentException(不支持的图片格式: ext); } String newName UUID.randomUUID().toString().replace(-, ) ext; String fullPath baseDir File.separator newName; try (InputStream in filePart.getInputStream()) { Files.copy(in, Paths.get(fullPath), StandardCopyOption.REPLACE_EXISTING); } return /upload/ newName; }filePart.getSubmittedFileName()拿到的是浏览器传的原始文件名不能直接拿来拼路径原因有两个一是重名会覆盖二是恶意参数里可以带../穿越目录。这里用Paths.get(...).getFileName()把路径部分剥掉再用 UUID 重命名从源头解决这两个问题。baseDir建议放在 Web 应用部署目录之外例如 Linux 下的/data/adwall/upload然后通过 Tomcat 的虚拟目录映射到/upload/**访问。如果直接存进项目源码目录项目重新部署时图片会全部丢失。返回值存的/upload/xxx.jpg是相对路径页面展示时加域名前缀即可以后迁移存储位置也不受影响。4.3 定时任务把过期广告自动下架ScheduledExecutorService 就够用广告墙的隐藏需求是过期广告不能一直占着首页。实现方式有三个档次Timer、ScheduledExecutorService、Quartz。课程设计里选第二个就够用Timer单线程任务里抛一个异常整个调度就停了Quartz功能强但要引入外部依赖实验报告里还得额外写一段集成说明。ScheduledExecutorService 是 JDK 自带的线程池调度代码量少还稳public class AdExpireTask implements ServletContextListener { private ScheduledExecutorService scheduler; Override public void contextInitialized(ServletContextEvent sce) { // 单线程调度任务本身是轻量UPDATE不需要多线程 scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleWithFixedDelay(() - { String sql UPDATE ad SET status3 WHERE status2 AND end_time NOW(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { int updated ps.executeUpdate(); // 此处记录日志本次自动下架 n 条过期广告 } catch (SQLException e) { // 只记日志不抛出保证调度线程存活 } }, 1, 10, TimeUnit.MINUTES); } Override public void contextDestroyed(ServletContextEvent sce) { if (scheduler ! null) { scheduler.shutdownNow(); } } }参数说明scheduleWithFixedDelay的第二个参数 1 表示 Tomcat 启动 1 分钟后首次执行第三个参数 10 表示之后每隔 10 分钟执行一次。为什么不改成 1 分钟一次业务上广告过期差几分钟影响不大而任务会走idx_status_end联合索引扫描全表太频繁了没必要。SQL 里WHERE status2 AND end_time NOW()两个条件完美命中第 2 章建的联合索引只要广告表量级在合理范围内这个 UPDATE 很快。注意 catch 里只记录异常不抛出否则线程池里的任务循环会终止后续所有下架操作全部静默失效。用 Spring Boot 的话可以直接换Scheduled注解底层的调度原理完全一致。5. 答辩前的自查用三条命令把广告墙的坑提前踩完最后一轮检查别光盯着页面点直接上 SQL 和边界测试效率更高也更像工程做法。第一刀切数据库脏数据。在服务器上跑这段 SQLmysql -u root -p adwall -e \ SELECT status, COUNT(*) FROM ad GROUP BY status; \ SELECT COUNT(*) FROM ad WHERE status2 AND end_time NOW();第一条看各个状态的广告数量是否合理如果有状态字段落进了 0 到 4 之外的数那说明某个地方直接写了裸 SQL 且没走枚举第二条专门盯“该下没下”的过期广告查询结果为 0 才算正常。如果第二条不是 0先确认定时任务的监听器有没有注册再看end_time的时区是不是和数据库一致。第二刀测非法状态跳转。把一条已下架的广告翻出来重新提交审核如果系统直接拒绝并提示“下架状态的广告不能重新提交”说明canTransferTo()在 Service 层生效了如果状态无脑被改成了待审核说明压根没做状态校验这是评审老师最爱追问的隐藏业务逻辑。再顺手测一下驳回后不修改内容直接再提交看看系统会不会让你钻空子。第三刀试路径穿越。上传一张图片后在浏览器地址栏手动拼访问路径尝试在/upload/后面带../WEB-INF/web.xml或../往上跳。如果服务器返回的不是 404 或 403说明上传目录映射配得过于宽松要收紧。同时确认上传基目录不在项目源码目录下这既关系到部署后图片的持久性也关系到实验报告里“安全性设计”这一小节有没有真东西可写。最后一个小技巧把第 2.3 节的枚举状态机升级成一张状态转换表用MapAdStatus, SetAdStatus存合法路径非法跳转统一抛业务异常。这张表只有几十行代码但可以直接当“java 基础核心类设计”的素材写进实验报告。老师追问怎么扩展新状态时就指着表说加一个枚举值和一行映射其余调用方全部不用改。本文还有配套的精品资源点击获取