每年三四月,总有学弟学妹私信我:“有没有一套能直接跑、能答辩、源码数据库文档齐全的基于 Spring Boot 的项目?”问得多了,我干脆把手头这个《中华诗词文化交流平台》整理成完整交付物。它不是那种只堆了一个前端页面的空壳,而是从诗词数据管理、用户注册登录、评论点赞收藏,到后台审核都有完整链路的单体项目。整个平台基于 Spring Boot 2.7 搭建,配合 MySQL 5.7 和 MyBatis-Plus,前端用 Thymeleaf 加 Bootstrap,一套源码带你走完“数据库设计 → 后端接口 → 页面渲染 → 部署上线”的全流程。如果你正在准备毕业设计、Java 课程设计,或者单纯想找一套看得懂、能读懂、又能扩展的 Spring Boot 例子,这篇文章拆解出来的东西应该够你“抄作业”,也能让你答得上老师问的为什么。
1. 这个平台的潜在需求不是“诗词网站”,而是“能答辩的全栈闭环”
拿到“中华诗词文化交流平台”这个题目后,我第一次思考的不是写代码,而是“这个项目做完要回答哪些问题”。答辩老师一般不会关心你某个按钮是怎么实现的,但一定会问:你的系统给谁用?解决了什么实际问题?数据库为什么这样设计?技术选型为什么是这些?想清楚这几点,项目才不会做成“为了 CRUD 而 CRUD”。
1.1 从需求到故事:三种角色怎么撑起整个项目
这个平台最终被我定义成三层角色、两条内容主线。游客是平台的访客,能看诗词、按朝代和作者搜索、浏览每日推荐,算是内容消费者;注册用户可以深度参与交流,不光能对诗词评论、点赞、收藏,还能发布自己的原创作品,审核通过后展示出来;管理员负责整个内容生态的秩序,包括维护诗人、诗词数据,审核用户投稿,管理用户账号。
两条内容主线也很清晰:一条是“读诗词”,面向已有的经典诗词库,按朝代、作者、标签、关键词检索;另一条是“写诗词”,面向用户原创内容,通过投稿和审核机制让普通人也能在平台上分享自己的作品。这样设计有个好处,它把“文化传承”和“UGC 社区”放在了一个系统里,学生的 CRUD 能力能体现,社交互动也能体现,比单纯做一个诗词展示站要有内容得多。
我记得在给数据库建表之前,先写了一个用户故事文档,大概是这样:
- 管理员小王登录后台,导入一批唐诗数据,录入李白、杜甫的诗人简介;
- 普通用户小李注册后搜索“明月”,看到多首包含明月的诗,点进《静夜思》详情页,点了收藏,又在评论区写下“小时候背的诗,现在读出了另一层意思”;
- 用户小张写了一首原创五绝,提交后状态是“待审核”,管理员审核通过后,小张的作品出现在“原创诗词”列表中。
当这些用户故事能串通时,功能边界就不会模糊了。后面所有表结构、接口、页面都是围绕这三个故事展开的。
1.2 选型思路与交付物清单
技术选型上我坚持“不炫技、能落地”。后端用 Spring Boot,版本我选的是 2.7.18,这是 2.x 的最后一个收尾版本,稳定且资料多,毕业设计用足够了。ORM 用 MyBatis-Plus,它能把单表 CRUD 省掉大半,动态条件查询用它的 LambdaQueryWrapper 也很顺手,比纯 MyBatis 写一堆 XML 要快很多。数据库是 MySQL 5.7,原因也很简单,部署环境最常见,资料多,开箱即用。前端没有做前后端分离,而是用 Thymeleaf 模板引擎加 Bootstrap 5、jQuery,这样项目的复杂度控制在合理范围,一个 Spring Boot 工程就能启动整个系统,不用再起一个 Node 服务,对新手友好得多。
整个交付物分三块:源码工程、SQL 初始化脚本、项目文档。文档包含需求分析、数据库设计说明、核心接口说明和部署文档。很多人做毕设时会把文档放到最后匆忙补,我建议反过来,先把需求文档和数据库设计文档写出来再写代码,后面代码只是把这套设计翻译成实现而已。
2. 数据库表设计是项目的“定盘星”:字段冗余一下,代码省一大截
这个平台的数据库设计直接决定了后端代码的复杂度。我第一版设计时按三范式把作者表、朝代表、诗歌表拆得非常干净,真正写查询时发现,查个列表要 join 三张表,还要考虑搜索关键字怎么关联作者姓名,非常麻烦。后来做了一次“面向需求的冗余”,才真正跑顺。
2.1 用户侧与内容侧表:每张表解决什么问题
核心表我拆成了八张:用户表、朝代表、诗人表、诗词表、评论表、收藏表、点赞表、原创作品表。每一张都要回答“它存在的理由是什么”。
用户表字段不复杂,但密码一定要用加密存储。我用了 Spring Security 里常用的 BCrypt 加密工具类,而不是简单的 MD5。除了常规的用户名、昵称、头像、邮箱,还加了 role 字段区分普通用户和管理员,status 字段控制账号是否被禁用。头像我开始只存了一个字符串路径,后续想扩展还方便。
诗人表和朝代表是比较容易理解的多对一关系:一个朝代有多个诗人,一个诗人有多首诗。诗人表里冗余一个 dynasty_name 字段,用空间换性能,列表页展示时少一次 join。诗词表是最核心的一张表,字段包括:title、poet_id、poet_name、dynasty_id、dynasty_name、content、translation、appreciation、tags。其中 poet_name、dynasty_name 都是冗余字段,因为诗词列表页、详情页、搜索页几乎每次都同时展示诗名、作者、朝代,如果全靠关联查询,SQL 会绕,索引也没法好好利用。
互动类表设计时我特别注意唯一约束。收藏表 favorite 有 user_id + poem_id 联合唯一索引,点赞表 like_record 也是 user_id + poem_id 联合唯一索引。这样用户在业务层就不用担心重复点赞的问题,数据库层已经把最底层防线做好了。评论表加了 parent_id 字段支持楼中楼回复,但没有做多级嵌套,原因很简单:毕设阶段做两层足够体现设计能力,做深了代码量会成倍上升。
原创作品表 work 是社区分享功能的载体,除了用户 ID、标题、正文、创作背景,一定不要忘了 status 字段。我设计的取值是待审核 0、通过 1、拒绝 2。用户提交原创内容后默认待审核,管理员后台通过后才能在前台列表展示,这个流程也体现在管理员的审核页面上。
2.2 索引、逻辑外键和初始化脚本的实战建议
索引策略上,诗词表我建立了三组核心索引。第一组是 title、content 的普通索引,支撑标题和内容关键字搜索;第二组是 dynasty_id + poet_id 的联合索引,支撑列表页的高级筛选;第三组是 status + create_time,用于后台管理查询待审核诗词的排序。前期没有一次性把索引建全,而是在压测列表页慢查询时逐步加的,这也提醒我:索引要根据真实查询条件来设计,不是越多越好。
外键我建议用逻辑外键,也就是不加物理外键约束,只保留逻辑关联字段和索引。理由很简单:物理外键在批量导入数据时会带来很多约束校验,删除诗词时还可能被评论表卡住,毕业设计项目里这种强一致性完全没必要用数据库层保证,业务层处理好就行。
初始化脚本我用的是 UTF-8 的 SQL 文件,里面包含了朝代、诗人、诗词、管理员账号和测试用户账号。诗词数据量我导入了一百五十首左右,覆盖唐诗、宋诗、宋词等,确保搜索“明月”、搜索“离别”这类常见关键词能出结果。导入时最容易踩的坑是 MySQL 命令行执行中文乱码,后面我会单独讲。
3. Spring Boot 后端:实体、拦截器、动态查询三件套
后端代码的核心不是“会写 Controller”,而是“分层清晰,接口可解释”。我把整个工程按 com.poetry 拆成 controller、service、mapper、entity、common、config 几个包,每个包只负责一件事。这样答辩时老师让你讲项目结构,你也能讲出点名堂。
3.1 包结构、公共类与统一返回结果
先看 common 包,里面有 Result、ResultCode、GlobalExceptionHandler 三个类。Result 是所有接口的统一返回包装,结构固定为 code、message、data 三个字段。前端 Ajax 拿到 code 之后就能判断成功失败,不需要关心 HTTP 状态码的细节。GlobalExceptionHandler 用 @RestControllerAdvice 捕获业务异常和系统异常,避免了“代码抛异常时前端拿到一堆看不懂的堆栈信息”。
entity 包里每个表对应一个实体类,用 Lombok 的 @Data 注解省去 getter/setter。MyBatis-Plus 的 BaseMapper 和 IService 能提供单表 CRUD,我自定义了一个 BaseEntity 把所有公共字段 id、createTime、updateTime、deleted 都放进去,子实体只需要写专属字段。这个做法也是从“重复代码太多”的痛苦里总结出来的。
controller 层我只负责接收参数和返回视图或 JSON,真正的业务逻辑全在 service 层。比如 AdminController 的待审核列表,Controller 里只有三行代码:调用 service 方法,接收分页条件,返回 Result。业务判断如果都堆在 Controller,后期一个方法会膨胀到很难读。
3.2 登录鉴权用拦截器就够,别一上来就上框架
这个平台我没有引入 Spring Security 或 Shiro,而是用自定义拦截器加 Session 做权限控制。原因有两点:一是 Spring Security 的默认过滤链对新手不友好,配置写错会出现各种奇怪的“没有权限”问题,排查成本高;二是这个项目只有普通用户和管理员两种角色,用拦截器区分登录态和 role 已经完全够用。
我写了一个 LoginInterceptor,在 preHandle 方法里判断 Session 中是否有 loginUser。如果是 ajax 请求,没有登录就返回 code=401 的 JSON;如果是页面请求,直接重定向到登录页。再写一个 AdminInterceptor 继承类似的逻辑,但额外检查 loginUser.role 是否等于 ADMIN。
注册登录接口用 Session 是最自然的:登录成功把用户信息放入 Session,用 Session 的失效时间去控制登录态。如果未来要扩展成真正的高并发平台,再考虑换成 Token + Redis,但那是后话,没必要在毕设阶段把复杂度推上去。
3.3 诗词检索接口的 LambdaQueryWrapper 写法
检索接口是诗词平台最关键的后端接口。参数我设计成 keyword、dynastyId、poetId、tag、pageNum、pageSize 六个。keyword 匹配 title、content、tags 三个字段;dynastyId 和 poetId 用等值条件;tag 因为存的是逗号分隔字符串,用 LIKE 匹配;分页用 MyBatis-Plus 的 Page。
核心代码大概长这样:
LambdaQueryWrapper<Poem> wrapper = Wrappers.lambdaQuery(); wrapper.eq(StringUtils.isNotBlank(keyword), Poem::getTitle, keyword) .or(StringUtils.isNotBlank(keyword), w -> w.like(Poem::getContent, keyword)) .eq(dynastyId != null && dynastyId > 0, Poem::getDynastyId, dynastyId) .eq(poetId != null && poetId > 0, Poem::getPoetId, poetId) .like(StringUtils.isNotBlank(tag), Poem::getTags, tag) .eq(Poem::getStatus, 1) .orderByDesc(Poem::getCreateTime); Page<Poem> page = poemMapper.selectPage(new Page<>(pageNum, pageSize), wrapper);这里有个细节:keyword 同时匹配 title、content 时,直接写两个 like 条件中间用 and 连接会出现冲突,要使用带 lambda 的 or 子条件把两个匹配包在一起,否则 SQL 逻辑会变得很奇怪。这种问题在答辩时很容易被问“你的多条件查询是怎么写的”,所以最好自己手动试过几种写法,别背代码。
还有个性能小坑:列表页需要展示诗人姓名、朝代名称,我是直接读取冗余字段 poetName、dynastyName,没有做连表查询。如果一开始审题时没有设计冗余字段,这个接口就会被迫改成封装 VO,代码量会增加很多“抗屎文档里的引用”。所以说数据库设计就是定盘星。
4. 页面与接口的联动:检索列表、详情页、每日一诗
后端接口设计得再好,页面没法让人顺畅地用,整个项目还是要被扣分。我前端主要做了五个页面,对应游客和用户的几条核心路径,页面之间靠 Thymeleaf 的模板片段复用导航栏和底部,尽量少写重复 HTML。
4.1 列表页如何把筛选条件拼进 URL
诗词列表页我采用的是 GET 请求加参数拼接,没有用 Ajax。访客勾选“唐代”、输入“明月”、点搜索后,页面 URL 变成/poem/list?keyword=明月&dynastyId=2&pageNum=1。这样做最大的好处是页面刷新或分享给朋友,筛选结果还在,不用像 Ajax 页面那样单独对付“浏览器后退失效”的问题。
实现方法很简单:表单里的 input 和 select 的 name 和 Controller 接收参数名保持一致,提交时用 GET 方法自动拼参数。后端 Controller 返回字符串视图,MyBatis-Plus 分页结果通过 Model 传到模板,Thymeleaf 用th:each循环输出列表。分页器我用的是自己写的一个简单工具方法,当前页、总页数、上一页、下一页都拼到原来的 URL 参数上,保持筛选条件不丢。
这个页面还加了左侧筛选栏,朝代、诗人按出现次数排序,用超链接直接替换对应的 URL 参数。整个交互不复杂,但“条件组合筛选”的完整流程是跑通了的。
4.2 详情页的评论区与点赞/收藏交互
诗词详情页是内容消费的核心。页面上方展示诗词正文、翻译、赏析和标签,下方是评论区。正文区我是直接展示数据库里的换行内容,前端加了 white-space: pre-line 的 CSS,保证诗词原有的换行格式不被浏览器吞掉。
点赞、收藏、评论这三个动作我用 jQuery 发 Ajax 请求,这样不会因为点个小按钮就整页刷新。点赞按钮点击后,接口返回当前是“已赞”还是“取消赞”,前端再把按钮的 active 样式切换一下。收藏同理。评论提交时,通过 form 序列化成 JSON 发送,成功后把新评论追加到评论列表顶部;评论楼层支持一级回复,点击“回复”按钮会把评论框放到对应楼层下方,提交评论时带上 parentId。后端评论接口做了两个防止乱来的校验:评论内容不能为空、评论长度不能超过五百字,超长直接返回业务错误码。
详情页的浏览量我也做了。在 PoemController 的 detail 方法里调用poemService.incrementViews(id),使用 MyBatis-Plus 的 update 语句给 views 字段加一。这种非核心计数不需要把性能优化到极致,及时几百并发也不会崩。
4.3 每日一诗的接口设计:随机与可控
“每日一诗”是给平台加文化味的功能。最早我用ORDER BY RAND() LIMIT 1直接随机取一首,数据量小的时候没问题,但总感觉每次刷新都在变,不像“每日一诗”,更像“每刷新一诗”。后来改成按日期种子取模:先把平台上所有已发布的诗词总数查出来,然后根据当天日期的简单哈希结果取模,得到一个 poemId 区间,再查那条记录。这样同一天内所有用户看到的都是同一首诗,第二天自动切换,而且不需要额外的定时任务。
这个设计我给很多同学讲过,大家最关心的都是“除以总数会不会因为删除了某些诗导致取模结果越界”。我的处理是先按 id 范围查询所有状态为正常的诗词 id 列表,再从列表里取模。诗词量几万以内,这个性能开销完全可以接受,而且逻辑清楚,答辩时也好讲。
5. 从“能跑”到“跑得稳”:配置文件里那些莫名其妙的问题
很多刚学 Spring Boot 的同学都会遇到这种场景:代码照着敲完了,启动却报错,或者页面中文全乱码,或者登录后刷新一下就掉线。这些问题大多不是业务代码的锅,而是配置文件没调好。我把这个项目里踩过的坑集中写出来,希望你看完能少走一半弯路。
5.1 datasource、时区、字符集,一个都不能少
Spring Boot 的 application.yml,第一版最容易漏掉,却又必须写对的是数据库连接串。MySQL 的 URL 如果只写jdbc:mysql://localhost:3306/poetry,大概率会遇到时区报错或者中文乱码。我最终的配置是这样的:
spring: datasource: url: jdbc:mysql://localhost:3306/poetry?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 5有几个点特别容易被忽略。serverTimezone=Asia/Shanghai不写,本地 MySQL 8.0 会直接报 CST 时区错误;characterEncoding=UTF-8不写,前端和数据库都是中文时,接口返回偶尔会出现乱码;allowPublicKeyRetrieval=true是 MySQL 8.0 连接时偶尔会碰到的公钥检索问题,加上更省心。
还有 MyBatis-Plus 的驼峰映射,默认是开启的,但很多人因为建表字段用了下划线却忘了在实体类加 @TableName,结果项目启动后一直报“无效的表名”。遇到这种情况先检查实体类注解,别急着删代码。
5.2 拦截器放行静态资源与 Session 失效问题
自定义拦截器后最容易遇到的问题就是页面样式全丢了。原因很简单:你在拦截器里写了excludePathPatterns("/login", "/register"),但漏掉了/js/**、/css/**、/img/**,所有静态资源请求都被拦截了。Spring Boot 里 Thymeleaf 页面的静态资源默认是放在classpath:/static/下的,拦截器拦截的是包含/static的请求,但你 excludePathPatterns 必须写相对 URL,例如/assets/**,不能写文件系统路径。
Session 失效问题则更隐蔽。我一开始把server.servlet.session.timeout设置成了 24h,但页面一刷新就登录态丢失。排查后发现是项目启动在 8080 端口,后来改成了 8081,Session Cookie 的默认 path 变化导致之前的 Cookie 没带上。解决办法是显式设置:
server: servlet: session: timeout: 8h cookie: path: /另外如果你用的是前后端分离,Session 天然会遇到跨域 Cookie 问题,但我们是同源单体应用,暂时不用考虑。
5.3 本地启动、打包部署和 SQL 导入的完整顺序
一套项目给别人前,我会写一个部署文档,把操作顺序列得清清楚楚。首先要保证 MySQL 已启动,用命令行创建数据库:
mysql -uroot -p123456 create database poetry default character set utf8mb4 collate utf8mb4_general_ci;注意我建库时直接指定了 utf8mb4,因为古诗词里有生僻字,utf8 三个字节可能不够。然后导入 SQL 文件:
mysql -uroot -p123456 --default-character-set=utf8mb4 poetry < poetry.sql有人喜欢直接在 Navicat 里运行,也可以,但一定要检查连接属性里选了 utf8mb4,不然中文注释会乱码。
源码工程本身用 Maven 管理,本地运行只需要执行mvn spring-boot:run,或者在 IDEA 中直接点启动类。要打成 jar 包就运行mvn clean package -DskipTests,产物在 target 目录下。服务器上如果只有 JRE,可以java -jar poetry-platform.jar,但记得在服务器上把数据库连接配置改成生产环境对应的值,不要再用本地账号密码。
6. 做完这个项目后,我建议你这样继续扩展
这套项目是完整能交差的,但如果你想在答辩或面试里获得更高分数,或者真想把它变成一个能运营的交流平台,可以在现有代码基础上做几个方向的扩展。方向的选择取决于你手里有多少时间、多少精力。
6.1 从单体到前后端分离:什么时候值得改
如果只是交毕设,Thymeleaf 模板引擎足够。但如果你发现页面里要出现大量动态交互,比如实时通知、富文本编辑器管理诗词赏析、看板娘这类定制界面,前后端分离会是更好的选择。那时可以把后端改成纯 REST API,前端用 Vue 或 React 重写,并通过 Nginx 做反向代理。要注意的是,后端接口做了跨域配置后才能被前端调用,你需要在 Spring Boot 的 WebMvcConfigurer 里加 CorsFilter,否则浏览器会拦截。
我个人的建议是:如果时间少于一周,不要动这个大手术;如果时间充足,把列表页改造成 Vue 单页对理解前后端数据流会很有帮助。
6.2 加入 Redis、ES、对象存储的扩展路径
这个平台的数据量级短期内单机 MySQL 完全能扛住,但你依然可以在扩展方案里加入 Redis 和 ES。Redis 适合缓存首页推荐、每日一诗、统计计数,这样详情页浏览量不会每次都打数据库;Elasticsearch 适合做诗词全文检索,利用 IK 分词器能把“床前明月光”按语义分词,比 MySQL 的 LIKE 在数据量大时优秀很多。如果要把用户头像和诗词图片传上来,可以用 MinIO 搭一个私有对象存储服务,本地地址存数据库,访问时靠静态资源映射。
这些扩展不需要全部实现,在回答“未来如何优化”的问题时,你可以说“我已经在工程里为此预留了接口位”,然后举一个具体例子,比如浏览量计数从update改为异步队列。这种表达会让老师觉得你对项目演进有思考。
6.3 答辩/面试时怎么表达这个项目的亮点
最后说一点更“务虚”但很重要的经验。同样是跑通的 Spring Boot 项目,高分答辩的关键在于你能不能把亮点讲得结构化。这个中华诗词文化交流平台的亮点不应该是“我实现了增删改查”,而应该是:
- 数据库表设计上使用了面向业务的字段冗余,让诗词列表查询少做 join,检索性能更好;
- 权限控制通过拦截器细分为登录态和管理员态,既没有引入重量级安全框架,又完成了角色隔离;
- 每日一诗没有用随机查询,而是采用按日期取模的策略,保证当天全站内容统一,数据量大后也方便借助缓存;
- 内容生态同时包含后台审核和前台展示,是一个有运营逻辑的社区闭环。
我一直觉得,毕设项目的价值不在于功能多花哨,而在于你能否把它讲清楚。这套 Spring Boot 项目用的技术都是老技术,但组合起来刚好覆盖了开发一个完整系统的全流程。个人建议,拿到源码后先别看业务代码,从数据库脚本开始读,顺着表结构去理解接口,再回到页面看数据流,一周左右你就能摸清整个项目的脉络,再遇到类似题目也能自己搭出一套框架来。