news 2026/9/12 16:38:49

广告墙Java课程设计:从ER建模到定时下架全流程实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
广告墙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图落成表,直接看这组DDL,MySQL 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 ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE, sort_order INT NOT NULL DEFAULT 0 COMMENT '展示排序,小的在前' ) ENGINE=InnoDB 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) ) ENGINE=InnoDB 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) ) ENGINE=InnoDB COMMENT='广告审核记录表';

字符集选 utf8mb4 而不是 utf8,因为广告内容可能带 emoji 或生僻字,utf8 是变长三字节,遇到四字节字符直接报错。idx_status_end (status, end_time)是这张表最关键的联合索引,首页按状态过滤、定时任务按过期时间扫描,走的都是这两个字段,没有它数据量到万级页面就会明显变慢。

status用 TINYINT 而不用 VARCHAR,因为状态集合固定且数量少,数字比较比字符串快,同时 DAO 层配合枚举类型不会写错值。start_timeend_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 List<Ad> 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()) { List<Ad> 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 开始算,所以第一页 currentPage=1 时偏移是(1-1)*pageSize=0,第二页是 10。

为什么必须用 PreparedStatement?因为?占位符会把值当参数传给数据库服务端,驱动在本地对特殊字符做转义;如果图省事用字符串拼接,用户在搜索框输入' OR '1'='1就能把广告表全部拖出来。这个检查在某些学校的验收环节是加分项,在另一些项目的代码审计里直接是“必须改”级别。如果后续换成 MyBatis,SQL 映射文件里写的依然是同样的#{}占位语法,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 prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%-- 广告墙首页,只展示已上架广告 --%> <c:forEach items="${adList}" var="ad"> <div class="ad-card"> <img src="${ad.imageUrl}" alt="${ad.title}" /> <h3><a href="${ad.linkUrl}" target="_blank">${ad.title}</a></h3> <span>到期时间:${ad.endTime}</span> </div> </c:forEach>

Servlet 端把查询结果request.setAttribute("adList", list),转发到ad_wall.jsp,页面上items取到的就是List<Ad>var定义了循环变量名。${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,后台列表 10
offsetSQL偏移量(currentPage - 1) * pageSize
SELECT 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 就够用

广告墙的隐藏需求是:过期广告不能一直占着首页。实现方式有三个档次:TimerScheduledExecutorServiceQuartz。课程设计里选第二个就够用,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 status=3 " + "WHERE status=2 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 status=2 AND end_time < NOW()两个条件完美命中第 2 章建的联合索引,只要广告表量级在合理范围内,这个 UPDATE 很快。注意 catch 里只记录异常不抛出,否则线程池里的任务循环会终止,后续所有下架操作全部静默失效。用 Spring Boot 的话可以直接换@Scheduled注解,底层的调度原理完全一致。

5. 答辩前的自查:用三条命令把广告墙的坑提前踩完

最后一轮检查别光盯着页面点,直接上 SQL 和边界测试,效率更高也更像工程做法。

第一刀切数据库脏数据。在服务器上跑这段 SQL:

mysql -u root -p adwall -e \ "SELECT status, COUNT(*) FROM ad GROUP BY status; \ SELECT COUNT(*) FROM ad WHERE status=2 AND end_time < NOW();"

第一条看各个状态的广告数量是否合理,如果有状态字段落进了 0 到 4 之外的数,那说明某个地方直接写了裸 SQL 且没走枚举;第二条专门盯“该下没下”的过期广告,查询结果为 0 才算正常。如果第二条不是 0,先确认定时任务的监听器有没有注册,再看end_time的时区是不是和数据库一致。

第二刀测非法状态跳转。把一条已下架的广告翻出来重新提交审核,如果系统直接拒绝并提示“下架状态的广告不能重新提交”,说明canTransferTo()在 Service 层生效了;如果状态无脑被改成了待审核,说明压根没做状态校验,这是评审老师最爱追问的隐藏业务逻辑。再顺手测一下驳回后不修改内容直接再提交,看看系统会不会让你钻空子。

第三刀试路径穿越。上传一张图片后,在浏览器地址栏手动拼访问路径,尝试在/upload/后面带../WEB-INF/web.xml../往上跳。如果服务器返回的不是 404 或 403,说明上传目录映射配得过于宽松,要收紧。同时确认上传基目录不在项目源码目录下,这既关系到部署后图片的持久性,也关系到实验报告里“安全性设计”这一小节有没有真东西可写。

最后一个小技巧:把第 2.3 节的枚举状态机升级成一张状态转换表,用Map<AdStatus, Set<AdStatus>>存合法路径,非法跳转统一抛业务异常。这张表只有几十行代码,但可以直接当“java 基础核心类设计”的素材写进实验报告。老师追问怎么扩展新状态时就指着表说:加一个枚举值和一行映射,其余调用方全部不用改。

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

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

TradingAgents-CN 怎么配置股票基础信息每日定时同步任务?

TradingAgents-CN 怎么配置股票基础信息每日定时同步任务&#xff1f; 【免费下载链接】TradingAgents-CN 基于多智能体LLM的中文金融交易框架 - TradingAgents中文增强版 项目地址: https://gitcode.com/GitHub_Trending/tr/TradingAgents-CN 在 TradingAgents-CN 中&a…

作者头像 李华