ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM框架实战:校园新闻发布系统从零到可运行源码

SSM框架实战:校园新闻发布系统从零到可运行源码 简介一套基于Java SSM框架的校园新闻发布管理系统源码面向正在学习SSM整合开发、需要项目实战参考的初学者覆盖新闻发布、编辑、删除、审核以及用户权限等核心业务模块。整个资源包内含二百九十八个文件压缩后大小约三点一三兆字节其中Java源程序文件有二百个承担后端业务逻辑另有十九个JavaScript脚本处理前端交互十六个XML与五个YAML配置负责Spring和MyBatis装配还有十三个CSS与十二个PNG图片用于界面视觉呈现。目前已有三百四十人学习。通过研读源码能完整看到从项目搭建、数据库操作到RESTful接口设计的过程对理解SSM框架分层、DAO层写法、拦截器使用以及前后端分离开发都有实际帮助适合作为课程设计、毕业设计或企业级Web开发入门的参考。1. 为什么校园新闻发布系统还在用SSM框架高校新闻发布这个场景表面看只是一个CMS落地时却容易两头拧巴信息中心要发布流程可管控、权限边界清晰院系宣传干事要能传图、能排版、发布后立刻看得到效果。用纯Servlet写字段校验和JDBC样板代码能把人写疲一上来就上Spring Boot很多团队又绕不开校园办公网里固定的应用服务器与中间件版本约束。SSMSpring Spring MVC MyBatis恰好卡在中间Spring管对象与事务Spring MVC把请求收敛到注解控制器MyBatis让SQL主动权留在开发者手里。这套组合在高校信息化环境里扎根很深后续迁往Spring Boot也顺理成章。这篇按“从零到可运行源码”的路径讲清实体设计、三个配置文件如何串联、新闻发布的完整代码链路以及部署阶段最容易翻车的参数与边界。2. SSM框架的实体层设计与Maven骨架搭建先定表再写代码2.1 校园新闻系统的实体关系三张表能跑通五张表算完整拿到这类源码题我第一步不是写Spring配置而是把数据库表定下来。新闻发布系统的最小闭环是“谁在哪个栏目下发了什么内容”因此用户表、分类表、新闻表是必须存在的。需求文档若提到“新闻需要审核”就要再加审核记录表需要统计阅读热度就在新闻主表里加view_count字段。我通常用InnoDB引擎字符集选utf8mb4因为新闻内容里经常出现特殊符号和emojiutf8mb4才能无损存储。下面是一个学生作品里最常用的五张表结构我把新闻主表和分类表的关键设计列出表名核心字段说明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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;指数说明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.version5.1.20.RELEASE/spring.version mybatis.version3.5.6/mybatis.version /properties dependencies dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.1.22/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency /dependencies参数说明连接池我选druid 1.1.22原因是它在监控页里能直观看到慢SQL校园环境排查问题比HikariCP直观。驱动用5.1.49而非8.x因为很多机房MySQL是5.6/5.75.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同时管理数据源、事务和ServiceSpring MVC的子容器只负责Controller。这样事务代理能够正确包住Service方法不会发生双容器扫描导致的代理失效。context:property-placeholder locationclasspath:jdbc.properties/ bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver}/ property nameurl value${jdbc.url}/ property nameusername value${jdbc.username}/ property namepassword value${jdbc.password}/ property namemaxActive value20/ property nameinitialSize value5/ property namemaxWait value6000/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis/mybatis-config.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.campus.news.mapper/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven transaction-managertransactionManager/逻辑说明maxActive设为20覆盖校园场景的单机并发绰绰有余maxWait是拿连接的超时时间如果设太短低于1000毫秒高峰期会大量抛出ConnectionUsableException设太长则会让用户端长时间转圈。事务统一走Transactional注解比散落的AOP切面配置少踩很多签名匹配的坑。3.2 mybatis-config.xml 与 Mapper XML 的映射关系mybatis-config.xml里我一般不开启二级缓存因为校园新闻系统的热点相对集中缓存交给Service层做更容易控制失效时间。下面这些配置是必要的configuration settings setting namemapUnderscoreToCamelCase valuetrue/ setting namelogImpl valueSTDOUT_LOGGING/ /settings typeAliases package namecom.campus.news.entity/ /typeAliases /configurationmapUnderscoreToCamelCase必须打开这样category_id会自动映射到实体的categoryId属性。logImpl在调试阶段用STDOUT_LOGGING把SQL直接打印到控制台上线前换成Log4j2减少IO开销。typeAliases包扫描让Mapper XML里写resultType时只需写类名不用写全限定名大幅提升可读性。Mapper XML的典型结构是对照接口写的mapper namespacecom.campus.news.mapper.NewsMapper select idselectPublishedByCategory resultTypeNews 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-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingforceEncoding设为true的作用是让过滤器同时处理请求体和响应体否则POST请求能转中文response返回的中文依然乱码。url-pattern用/而不是*.do这样REST风格路径的请求都能进入DispatcherServlet静态资源再通过spring-mvc.xml里的mvc:resources放行。3.4 启动阶段常见报错速查SSM源码拷到本地启动报错大部分集中在下面五个位置。我用这个表快速定位报错现象最可能原因排查位置NoSuchBeanDefinitionExceptionService接口没加Service或扫描包写错applicationContext.xml的component-scanInvalid bound statement (not found)Mapper接口和XML的namespace不匹配NewsMapper.xml第一行的namespaceTable doesnt 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 classcom.campus.news.interceptor.LoginInterceptor/ /mvc:interceptor /mvc:interceptorsexclude-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); ListNews 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 idselectByPage resultTypeNews SELECT id, category_id, title, summary, cover, view_count, status, publish_time, create_time FROM t_news where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY publish_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉多余的AND避免keyword为空时拼出语法错误。LIKE用CONCAT拼接是因为部分驱动处理% #{keyword} %时参数类型有边界问题CONCAT的返回类型更可控。offset计算放在Service层public PageResultNews page(int pageNo, int pageSize, Integer categoryId, String keyword) { int offset (pageNo - 1) * pageSize; ListNews 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 LoadingCacheObject, ListNews hotCache CacheBuilder.newBuilder() .maximumSize(100) .expireAfterWrite(60, TimeUnit.SECONDS) .build(new CacheLoaderObject, ListNews() { Override public ListNews load(Object key) throws Exception { return newsMapper.selectHotList(10); } }); public ListNews 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明显缩小再回到页面刷新列表这条发布链路就算完整跑通。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进