SSM框架实战:校园新闻发布系统从零到可运行源码
2026/9/16 14:44:32 网站建设 项目流程

简介:一套基于Java SSM框架的校园新闻发布管理系统源码,面向正在学习SSM整合开发、需要项目实战参考的初学者,覆盖新闻发布、编辑、删除、审核以及用户权限等核心业务模块。整个资源包内含二百九十八个文件,压缩后大小约三点一三兆字节;其中Java源程序文件有二百个,承担后端业务逻辑,另有十九个JavaScript脚本处理前端交互,十六个XML与五个YAML配置负责Spring和MyBatis装配,还有十三个CSS与十二个PNG图片用于界面视觉呈现。目前已有三百四十人学习。通过研读源码,能完整看到从项目搭建、数据库操作到RESTful接口设计的过程,对理解SSM框架分层、DAO层写法、拦截器使用以及前后端分离开发都有实际帮助,适合作为课程设计、毕业设计或企业级Web开发入门的参考。

1. 为什么校园新闻发布系统还在用SSM框架

高校新闻发布这个场景,表面看只是一个CMS,落地时却容易两头拧巴:信息中心要发布流程可管控、权限边界清晰,院系宣传干事要能传图、能排版、发布后立刻看得到效果。用纯Servlet写,字段校验和JDBC样板代码能把人写疲;一上来就上Spring Boot,很多团队又绕不开校园办公网里固定的应用服务器与中间件版本约束。SSM(Spring + Spring MVC + MyBatis)恰好卡在中间:Spring管对象与事务,Spring MVC把请求收敛到注解控制器,MyBatis让SQL主动权留在开发者手里。这套组合在高校信息化环境里扎根很深,后续迁往Spring Boot也顺理成章。这篇按“从零到可运行源码”的路径,讲清实体设计、三个配置文件如何串联、新闻发布的完整代码链路,以及部署阶段最容易翻车的参数与边界。

2. SSM框架的实体层设计与Maven骨架搭建:先定表再写代码

2.1 校园新闻系统的实体关系:三张表能跑通,五张表算完整

拿到这类源码题,我第一步不是写Spring配置,而是把数据库表定下来。新闻发布系统的最小闭环是“谁在哪个栏目下发了什么内容”,因此用户表、分类表、新闻表是必须存在的。需求文档若提到“新闻需要审核”,就要再加审核记录表;需要统计阅读热度,就在新闻主表里加view_count字段。

我通常用InnoDB引擎,字符集选utf8mb4,因为新闻内容里经常出现特殊符号和emoji,utf8mb4才能无损存储。下面是一个学生作品里最常用的五张表结构,我把新闻主表和分类表的关键设计列出:

表名核心字段说明
t_userid, username, password, role, create_timerole区分管理员与普通编辑,密码保存密文
t_categoryid, name, sort, status栏目,例如“校园快讯”“学术活动”
t_newsid, category_id, title, summary, content, cover, status, view_count, create_timestatus控制草稿/待审核/已发布
t_commentid, news_id, user_id, content, create_time评论,按news_id加普通索引
t_audit_logid, news_id, operator, action, remark审核轨迹,方便追溯

这里有一个容易忽略的决定:category_id到底建不建物理外键。实际生产里我不建议加物理外键,校园新闻系统并发不高,硬加外键反而让删除栏目时束手束脚;但开发阶段有外键约束能逼着你先保证数据干净。建表SQL可以直接在Navicat或命令行里执行,这里只贴t_news的关键部分:

CREATE TABLE `t_news` ( `id` int(11) NOT NULL AUTO_INCREMENT, `category_id` int(11) NOT NULL COMMENT '栏目ID', `title` varchar(200) NOT NULL COMMENT '新闻标题', `summary` varchar(500) DEFAULT NULL, `content` text COMMENT '正文', `cover` varchar(255) DEFAULT NULL COMMENT '封面图片路径', `status` tinyint(4) DEFAULT '0' COMMENT '0草稿 1待审核 2已发布', `view_count` int(11) DEFAULT '0', `publish_time` datetime DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

指数说明:idx_category_status是联合索引,支撑“某个栏目下全部已发布新闻”这种高频查询。单独按category_id或status查询也能命中该索引的左前缀,所以这两列的顺序不能反,category_id必须放在第一列。

2.2 Maven依赖列表:版本选对,后续少一半麻烦

SSM的版本搭配有讲究。我常用的组合是Spring 5.1.x、Spring MVC 5.1.x、MyBatis 3.5.x,配合mybatis-spring 2.0.x。这个组合在JDK 8上跑得很稳,也与多数校园服务器上已装好的Tomcat 8.5/9.0兼容。pom.xml核心依赖如下:

<properties> <spring.version>5.1.20.RELEASE</spring.version> <mybatis.version>3.5.6</mybatis.version> </properties> <dependencies> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>${spring.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>${mybatis.version}</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.6</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.1.22</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> </dependencies>

参数说明:连接池我选druid 1.1.22,原因是它在监控页里能直观看到慢SQL,校园环境排查问题比HikariCP直观。驱动用5.1.49而非8.x,因为很多机房MySQL是5.6/5.7,5.1.x驱动经过长期验证;若你的MySQL已是8.0,把驱动版本换成8.0.x即可,注意8.x驱动需要显式设置serverTimezone参数。

2.3 目录结构:为什么按Controller-Service-Mapper三层分

目录结构决定这个源码后期好不好改。我按标准SSM结构拆分:

src/main/java ├── com.campus.news │ ├── controller # Spring MVC 控制器 │ ├── service # 业务接口 │ ├── service.impl # 业务实现 │ ├── mapper # MyBatis 接口 │ ├── entity # 实体类 │ └── interceptor # 登录拦截器 src/main/resources ├── spring │ ├── applicationContext.xml │ └── spring-mvc.xml ├── mybatis │ ├── mybatis-config.xml │ └── mapper │ └── NewsMapper.xml └── jdbc.properties

把mapper接口和Mapper XML放在不同目录,是为了隔离:接口层只声明方法签名,XML里装SQL。Spring的MapperScannerConfigurer会自动扫描mapper包,把接口代理对象注册进容器,Service里只管@Autowired,不用关心SqlSession何时打开、何时关闭。这个设计是MyBatis最省心的地方,也提醒你不要在业务代码里自己new SqlSession,否则事务会失去控制。

3. Spring与MyBatis整合配置:骨架怎么串起来

3.1 applicationContext.xml:数据源、事务、扫描

Spring容器是SSM的骨架。我用一个applicationContext.xml同时管理数据源、事务和Service,Spring MVC的子容器只负责Controller。这样事务代理能够正确包住Service方法,不会发生双容器扫描导致的代理失效。

<context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="maxActive" value="20"/> <property name="initialSize" value="5"/> <property name="maxWait" value="6000"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="configLocation" value="classpath:mybatis/mybatis-config.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.campus.news.mapper"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>

逻辑说明:maxActive设为20,覆盖校园场景的单机并发绰绰有余;maxWait是拿连接的超时时间,如果设太短(低于1000毫秒),高峰期会大量抛出ConnectionUsableException;设太长则会让用户端长时间转圈。事务统一走@Transactional注解,比散落的AOP切面配置少踩很多签名匹配的坑。

3.2 mybatis-config.xml 与 Mapper XML 的映射关系

mybatis-config.xml里我一般不开启二级缓存,因为校园新闻系统的热点相对集中,缓存交给Service层做更容易控制失效时间。下面这些配置是必要的:

<configuration> <settings> <setting name="mapUnderscoreToCamelCase" value="true"/> <setting name="logImpl" value="STDOUT_LOGGING"/> </settings> <typeAliases> <package name="com.campus.news.entity"/> </typeAliases> </configuration>

mapUnderscoreToCamelCase必须打开,这样category_id会自动映射到实体的categoryId属性。logImpl在调试阶段用STDOUT_LOGGING把SQL直接打印到控制台,上线前换成Log4j2减少IO开销。typeAliases包扫描让Mapper XML里写resultType时只需写类名,不用写全限定名,大幅提升可读性。

Mapper XML的典型结构是对照接口写的:

<mapper namespace="com.campus.news.mapper.NewsMapper"> <select id="selectPublishedByCategory" resultType="News"> SELECT id, category_id, title, summary, publish_time FROM t_news WHERE category_id = #{categoryId} AND status = 2 ORDER BY publish_time DESC LIMIT #{offset}, #{pageSize} </select> </mapper>

这里的#{}是预编译参数,MyBatis会转成?占位符,能挡住最基础的SQL注入;表名、排序字段这类不能预编译的内容不能用#{},需要先做白名单校验再拼接,否则就有注入风险。

3.3 web.xml 里的 DispatcherServlet 与乱码过滤器

web.xml是整个MVC的入口。除了配置DispatcherServlet,新手最容易漏掉字符编码过滤器。新闻内容要存中文,编码处理不好就会出现满屏乱码,前端页面拿到的响应也是乱的:

<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring/spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>

forceEncoding设为true的作用是让过滤器同时处理请求体和响应体,否则POST请求能转中文,response返回的中文依然乱码。url-pattern/而不是*.do,这样REST风格路径的请求都能进入DispatcherServlet,静态资源再通过spring-mvc.xml里的mvc:resources放行。

3.4 启动阶段常见报错速查

SSM源码拷到本地启动,报错大部分集中在下面五个位置。我用这个表快速定位:

报错现象最可能原因排查位置
NoSuchBeanDefinitionExceptionService接口没加@Service,或扫描包写错applicationContext.xml的component-scan
Invalid bound statement (not found)Mapper接口和XML的namespace不匹配NewsMapper.xml第一行的namespace
Table doesn't exist建表脚本没执行,或连错库jdbc.properties的url与库名
中文乱码过滤器没配,或页面charset不对web.xml的Filter映射、JSP头部声明
500 + NullPointerExceptionService里Autowired的Mapper为nullMapperScannerConfigurer的basePackage

这张表配合控制台堆栈,能省下大量翻页调试的时间。

4. 新闻发布核心流程实现:从登录到上架一条链路

4.1 登录拦截器与Session权限位

校园新闻系统必须有权限控制,否则任意访客都能发文章,审核流程形同虚设。我用Spring MVC拦截器实现:请求到达Controller之前,先检查Session里有没有登录标记。

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }

注册拦截器时,必须把登录接口和静态资源排除掉:

<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/css/**"/> <mvc:exclude-mapping path="/js/**"/> <mvc:exclude-mapping path="/images/**"/> <bean class="com.campus.news.interceptor.LoginInterceptor"/> </mvc:interceptor> </mvc:interceptors>

exclude-mapping的作用是放行登录页和静态资源,否则CSS和JS加载不出来,页面以裸HTML形式呈现,用户体验很差。拦截器判断的是Session对象存不存在,Controller里就能放心用@SessionAttribute("loginUser")直接拿用户资料,不用重复从request里强转。

4.2 发布接口的Controller-Service-Mapper完整链路

新闻发布的链路是:Controller接收表单参数组装实体,Service做状态机校验,Mapper把数据插入。我把这三层代码完整串起来。

Controller层:

@Controller @RequestMapping("/news") public class NewsController { @Autowired private NewsService newsService; @PostMapping("/publish") public String publish(@ModelAttribute News news, @RequestParam("categoryId") Integer categoryId, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); news.setCategoryId(categoryId); news.setCreatorId(loginUser.getId()); newsService.publish(news); return "redirect:/news/list"; } }

Service层承担业务规则:

@Service public class NewsServiceImpl implements NewsService { @Autowired private NewsMapper newsMapper; @Transactional @Override public void publish(News news) { Date now = new Date(); news.setCreateTime(now); if ("admin".equals(news.getRole()) || "editor".equals(news.getRole())) { news.setStatus(News.STATUS_PUBLISHED); news.setPublishTime(now); } else { news.setStatus(News.STATUS_PENDING); } newsMapper.insert(news); } }

Mapper接口:

public interface NewsMapper { int insert(News news); List<News> selectByPage(@Param("offset") int offset, @Param("pageSize") int pageSize, @Param("categoryId") Integer categoryId, @Param("keyword") String keyword); News selectById(@Param("id") Integer id); int updateStatus(@Param("id") Integer id, @Param("status") Integer status); }

逻辑说明:insert方法单条插入看似不需要事务,但同一接口里往往还有“写入内容关键字索引”“给栏目更新文章计数”等连带操作,所以必须把@Transactional标注在Service方法上,让多条SQL落在同一个事务里。状态机的规则放在Service层,避免Controller直接改status字段,后续增加“撤回”“归档”状态时只需改Service里的一个分支。

4.3 分页查询与标题模糊搜索

列表页是新闻系统访问最频繁的接口,分页和搜索必须在一条SQL里完成,绝不能查出全表再在内存里分页,否则数据和页面都会越写越卡。

<select id="selectByPage" resultType="News"> SELECT id, category_id, title, summary, cover, view_count, status, publish_time, create_time FROM t_news <where> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY publish_time DESC LIMIT #{offset}, #{pageSize} </select>

<where>标签会自动去掉多余的AND,避免keyword为空时拼出语法错误。LIKE用CONCAT拼接,是因为部分驱动处理'%' #{keyword} '%'时参数类型有边界问题,CONCAT的返回类型更可控。offset计算放在Service层:

public PageResult<News> page(int pageNo, int pageSize, Integer categoryId, String keyword) { int offset = (pageNo - 1) * pageSize; List<News> list = newsMapper.selectByPage(offset, pageSize, categoryId, keyword); int total = newsMapper.count(categoryId, keyword); return new PageResult<>(list, total); }

分页参数里offset类型用int足够覆盖校园站点的数据量。count和selectByPage必须分开查,MyBatis不会自动补count,少写count方法会导致前端分页组件拿不到total值,翻页按钮直接失效。

4.4 三个最值得注意的分页与状态参数

第一,LIMIT的参数位置。MySQL的LIMIT offset, pageSize中offset从0开始,页面传入的pageNo如果从1起,Service里必须做(pageNo - 1) * pageSize,否则第一页数据永远被跳过。第二,publish_time定义为DEFAULT NULL时,未发布状态下排序行为因MySQL版本而异,查询里统一用IFNULL(publish_time, create_time)更稳定。第三,列表页查询不要SELECT content大字段,长文本会拖慢列表接口,校园内网千兆环境感知不明显,但部署到公网或者后期接入网关时就很关键。

5. 发布链路调稳:事务边界、本地缓存与链路验证

5.1 事务边界拉在哪一层

源码评审时,我养成了先检查写方法上@Transactional位置的毛病。挂在Controller上是最常见的错误,因为Spring AOP默认只代理public方法,而Controller里的事务注解往往因为代理机制原因静默失效。正确做法是把事务放在Service实现类方法上,且保证事务方法从外部调用,同类里this.xxx()调用另一个事务方法不会让代理介入。新闻发布方法即便只执行一条insert,也值得保留事务注解,因为后续接入内容审核或索引同步时,多表写入的原子性就靠这个注解兜底。

5.2 首页热点新闻的本地缓存

校园新闻首页访问集中在早上8点到晚上10点,同一批热点新闻被反复查询。我在Service实现里加了一层Guava本地缓存,不依赖Redis,部署更轻:

@Service public class NewsServiceImpl implements NewsService { private final LoadingCache<Object, List<News>> hotCache = CacheBuilder.newBuilder() .maximumSize(100) .expireAfterWrite(60, TimeUnit.SECONDS) .build(new CacheLoader<Object, List<News>>() { @Override public List<News> load(Object key) throws Exception { return newsMapper.selectHotList(10); } }); public List<News> getHotNews() { return hotCache.get("hot"); } }

这里的expireAfterWrite设为60秒,让新闻更新后最多一分钟出现在首页,比缓存5分钟的方式更容易被内容审核同事接受,副作用是热点数据可能滞后一分钟,可接受。maximumSize设为100是因为校园站点栏目数量有限,缓存对象数不会失控。如果日后改成Redis,缓存key建议包含分类ID与状态,例如news:hot:category:1:status:2,避免不同栏目串数据。

5.3 验证发布链路是否正常的三个小步骤

源码写完,我一般通过三个手段验证整条链路,而不是只在页面上点一遍。第一步,写一个JUnit测试直接调Service的publish方法,传入带中文的标题,检查入库后的编码是否为utf8mb4,确认代码与JDBC连接串都没有把字符集转回latin1。第二步,用Postman模拟表单POST到/news/publish,不带Session时观察是否被拦截器重定向到/login,带Session时检查返回的list页面是否出现新文章。第三步,执行一次分页查询,用EXPLAIN看联合索引是否命中:

EXPLAIN SELECT id, title, status FROM t_news WHERE category_id = 1 AND status = 2 ORDER BY publish_time DESC;

如果possible_key显示为NULL,说明idx_category_status要么没建,要么建表时category_id没有放在第一列。修复索引后重新EXPLAIN,看到type列为ref、rows明显缩小,再回到页面刷新列表,这条发布链路就算完整跑通。

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

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

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

立即咨询