去年下半年我帮朋友把一个“文学创作社交论坛”从口头需求做成了一套能演示、能部署、能拿去答辩的完整系统,技术栈定在SpringBoot+MyBatis+MySQL+Vue,前后端分离开发。整个过程走下来,最大的体会是:这个项目表面上不复杂,但真正把它做扎实,难点全藏在数据模型设计和几个前后端交互细节里。今天把整个从零到一的实现过程复盘一遍,从功能拆分、数据库设计、后端分层、前端路由到打包部署,尽量把每个决定背后的原因也讲清楚。如果你正在做Java全栈类的毕业设计或者课程项目,想找一个能完整照着落地的实际案例,这篇应该能帮你省下不少自己摸索的时间。
项目本身是一个面向文学爱好者的社区平台,核心是“创作”和“交流”两个词。普通用户可以注册登录,浏览和阅读作品,对作品点赞、收藏、评论;创作者可以发布短篇、长篇连载、诗歌等内容,管理自己的作品和章节;管理员则负责用户管理、作品审核和内容统计。接下来我按实际开发顺序,把设计思路和踩过的坑一条条拆开说。
1. 立项思路:文学论坛的功能清单与边界划分
很多同学拿到这类题目第一反应是“功能越多越好”,结果做到一半发现数据库表怎么都设计不完,联调的时候接口一个接一个改。我的建议恰恰相反,先把必须有的功能列出来,再把看起来很高端但其实会拖垮进度的功能砍掉。
1.1 角色与核心模块拆解
我把系统拆成三个角色,每个角色对应一套功能集合:
- 普通访客:浏览作品列表、查看作品详情、搜索作品、浏览排行榜。
- 登录用户:在访客基础上增加发布作品、管理章节、评论、点赞、收藏、关注作者、查看个人中心。
- 管理员:用户管理、作品审核/下架、评论管理、分类管理、数据统计。
对应到系统模块上,就是下面这张功能清单:
| 模块 | 功能点 | 优先级 |
|---|---|---|
| 用户模块 | 注册、登录、修改资料、头像上传、密码修改 | P0 |
| 作品模块 | 发布作品、新增章节、编辑、删除、发布/下架 | P0 |
| 阅读模块 | 作品详情、章节内容、阅读量统计 | P0 |
| 互动模块 | 点赞、收藏、评论、关注 | P1 |
| 搜索与排行 | 按标题/作者搜索、热度榜、新书榜 | P1 |
| 管理后台 | 用户列表、作品审核、评论清除、基础统计 | P1 |
| 辅助功能 | 公告、分类管理、个人主页 | P2 |
P0是系统能不能跑起来的关键,P1是让系统像社区的关键,P2是锦上添花,时间不够可以直接砍。我把公告和分类管理放到了P2,实际开发中只做了作品分类,公告功能用数据库里一张配置表临时替代,最后效果也完全够用。
1.2 刻意砍掉的功能
这里必须强调一下,砍功能不是偷懒,是为了让核心链路更稳定。我一开始被建议加入“站内私信”“实时在线人数”“敏感词过滤”这些功能,最后都没有做,原因分别是:
- 站内私信:涉及会话管理和已读未读状态,会让数据模型复杂不少,和“文学创作论坛”这个核心定位关系不大。
- 实时在线人数:需要引入WebSocket或者定期轮询,答辩时一般也不会成为打分重点,反而增加部署时的不确定性。
- 敏感词过滤:纯文本敏感词可以用前缀树实现,但覆盖不了图片和变体写法,现实中更多靠管理员人工处理即可。
把需求范围控制住之后,后面所有表结构和接口设计都会清晰很多。宁可先做一个完整闭环的小系统,也不要做一个稀碎的大杂烩。
2. 数据库设计:从业务原型到建表语句的取舍
这个项目的数据库设计是整个系统的地基。我第一版表结构是按“作品表+用户表”两个大表堆出来的,写到评论功能时发现完全走不下去——评论既可能挂在作品下,也可能挂在具体章节下,一个大字段没法覆盖这种多态关系。后来重新梳理了业务对象之间的关系,才定下一套相对稳定的表结构。
2.1 核心表结构与字段含义
最终落地的表一共8张:user(用户表)、category(分类表)、work(作品表)、chapter(章节表)、comment(评论表)、like_record(点赞记录表)、favorite(收藏表)、follow(关注表)。下面挑几张核心表讲字段设计思路。
用户表是最基础的表,字段包括id、username、password(存咸Salt+哈希后的结果)、nickname、avatar、bio、role、status、create_time。role字段用tinyint,0表示普通用户,1表示管理员,方便扩展为多角色。status用于账号禁用,删除用户时不走物理删除,只是改状态,这样用户的历史评论和作品还能保留关联。
作品表字段是设计的重点:
- id:主键。
- title:作品标题。
- author_id:关联user表的id。
- category_id:关联category表的id。
- description:作品简介。
- cover_url:封面图地址,没有封面时前端显示占位图。
- status:0草稿,1审核中,2已发布,3已下架。
- view_count、like_count、favorite_count:三个计数冗余字段。
- create_time、update_time:时间字段。
这里尤其要说一下三个计数冗余字段。如果不做冗余,每次查询作品列表都要对点赞表和收藏表做COUNT聚合,数据量一大了效率很难看。我现在在点赞和收藏发生时同步更新work表里的计数,列表查询直接查这列就行。
章节表单独拆出来,是因为文学创作论坛的作品大多数是连载,作品本身只保存元信息,章节表保存标题、正文、章节序号、字数、创作时间。正文用LongText存,MySQL里最好确认一下表字符集是utf8mb4,否则一些生僻字和特殊符号会直接变成问号。
评论表的设计要考虑“嵌套回复”。我用了parent_id字段,顶级评论为0,回复评论记录父评论id,这样表结构简单,展示层做两级缩进即可,不用无限递归。
2.2 索引设计、字符集与逻辑删除
索引方面,我当时做了三组索引:
- work表:author_id单列索引、status和create_time联合索引、category_id和status联合索引。
- chapter表:work_id单列索引、work_id和chapter_order联合索引。
- comment表:work_id单列索引、user_id单列索引。
搜索作品时最常用的条件是状态+分类+时间排序,所以联合索引比单列索引更合适。点赞表上为了阻止重复点赞,建了user_id和work_id的联合唯一索引,这是实现幂等最关键的一步。
字符集统一用utf8mb4。如果是按默认的utf8建库,文学作品里常见的特殊字符、引号、emoji风格符号都可能触发存储异常,别在这上面省事。
外键我建议不物理创建,逻辑外键就够了。不是否定外键的价值,而是社区类系统经常需要批量更新和软删除,物理外键会带来级联操作的不可控性,并且删除父记录时容易报错。我选择了在业务Service层手动保证关联数据的一致性,配合事务使用,开发上更灵活。
建表语句示例(核心部分):
CREATE TABLE `work` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(100) NOT NULL COMMENT '作品标题', `author_id` bigint(20) NOT NULL COMMENT '作者ID', `category_id` bigint(20) DEFAULT NULL, `description` varchar(500) DEFAULT NULL, `cover_url` varchar(255) DEFAULT NULL, `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0草稿 1审核中 2已发布 3已下架', `view_count` int(11) NOT NULL DEFAULT '0', `like_count` int(11) NOT NULL DEFAULT '0', `favorite_count` int(11) NOT NULL DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_author_id` (`author_id`), KEY `idx_status_create_time` (`status`, `create_time`) ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COMMENT='作品表';这段DDL看着简单,但它承载了后续所有查询和排序逻辑。我的经验是:建表之前先想清楚“列表页会按什么条件查”“详情页会不会做计数器更新”“删除时要不要保留数据”,这三个问题决定字段和索引的取舍。
3. 后端实现:SpringBoot分层与MyBatis动态SQL的实操点
后端我用了标准的SpringBoot三层架构:Controller接收请求、Service处理业务、Mapper访问数据库。项目结构清晰,后续加接口基本是复制粘贴,不容易乱。
3.1 包结构、统一响应与全局异常
工程包结构大致是这样的:
com.example.forum ├── controller ├── service │ └── impl ├── mapper ├── entity ├── dto ├── vo ├── config ├── interceptor └── commonentity是数据库实体类,dto是请求参数封装,vo是返回给前端的数据结构。很多同学不区分dto和vo,结果接口返回时把密码字段也序列化出去了。我这里统一处理,user表对应的vo只包含id、昵称、头像、简介这些安全字段。
统一响应体我用了泛型Result类:
public class Result<T> { private Integer code; private String message; private T data; // 静态方法 success / error }所有Controller都返回Result,前端Axios里统一拦截code,等于约定了一套前后端都能理解的返回规范。code为0表示成功,非0表示业务错误,例如参数错误、权限不足、内容不存在等。
全局异常用@RestControllerAdvice处理,分三类:业务异常、参数校验异常、兜底异常。这样做的好处是Service层不需要到处try-catch,直接在业务判断里抛出异常即可,错误信息统一让前端弹出提示。
3.2 MyBatis配置与动态SQL写法
MyBatis我选择了XML方式写SQL,理由很简单:动态SQL在注解里写起来很痛苦,尤其作品搜索条件这种可选字段较多的场景,XML的where标签能维护得很干净。
application.yml中的关键配置:
mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.forum.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case这个配置必须开,否则数据库里create_time映射不到Java属性createTime,回传的数据会全是null。这个坑我在开发第三天踩到,排查了半天才意识到是驼峰映射没开。
动态SQL举例,作品列表搜索接口:
<select id="searchWorks" resultType="com.example.forum.vo.WorkVO"> SELECT id, title, category_id, author_id, description, cover_url, view_count, like_count, favorite_count, status, create_time FROM work <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR author_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="categoryId != null"> AND category_id = #{categoryId} </if> <if test="status != null"> AND status = #{status} </if> </where> ORDER BY <choose> <when test="sortBy == 'hot'"> view_count + like_count DESC </when> <otherwise> create_time DESC </otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>这里注意两个细节:LIKE拼接用CONCAT函数,而不是直接在XML里写%#{keyword}%,后者在MyBatis预编译时容易产生参数绑定异常;排序字段一定要走choose分支,防止SQL注入,不能直接用${sortBy}拼接字符串。分页我没有引入PageHelper插件,直接手写LIMIT参数,接口少,手写完全够用,也少一个依赖版本冲突的隐患。
另外一个新手容易忽略的点是XML里的特殊字符,比如小于号需要写成<,大于号写成>。SQL中如果直接写create_time > '2024-01-01'还好,但写<就会直接报XML解析错误。
3.3 权限拦截与事务控制的几个细节
登录状态我用JWT实现,登录成功生成token返回前端,前端后续请求在Header中携带token。后端用一个拦截器统一解析token,并把当前用户id放到ThreadLocal里,Service层随时能取到当前操作人。
拦截器需要关注两个容易踩坑的场景:
- OPTIONS预检请求:前后端分离开发中,跨域请求会先发送OPTIONS请求,拦截器如果直接拦掉,浏览器会报CORS错误。需要判断请求方法为OPTIONS时直接放行。
- token过期:不要在拦截器里返回详细堆栈,统一返回“登录状态失效”的错误码,前端Axios收到之后跳转登录页并清理本地token。
事务我重点用在了三个场景:发布章节时更新作品字数、点赞时同时插入点赞表和更新作品like_count、收藏和取消收藏同步计数。以点赞为例:
@Transactional(rollbackFor = Exception.class) public void likeWork(Long userId, Long workId) { LikeRecord record = new LikeRecord(); record.setUserId(userId); record.setWorkId(workId); likeRecordMapper.insert(record); workMapper.increaseLikeCount(workId); }这里依赖like_record表上的联合唯一索引保证幂等。如果用户重复点赞,插入会抛DuplicateKeyException,事务整体回滚,不会出现“点赞记录没插进去但计数加了”的脏数据。
事务用的同时要提醒一点:@Transactional默认只回滚RuntimeException,受检异常不会触发回滚。所以rollbackFor = Exception.class这个参数一定要加上,否则一些IO操作类异常会让事务静默提交,数据就错乱了。
4. 前端Vue部分:从路由到页面到内容的完整链路
前端用的Vue全家桶,搭建页面、维护登录状态、调用接口、打包部署,整条链路跑通差不多花了一半的开发时间。这部分最大的工作量不在页面UI,而是路由权限控制、接口封装和内容编辑器的集成。
4.1 页面结构与路由管理
页面大致分为四组:访客可访问的首页、作品列表、作品详情;登录用户可访问的创作中心、我的书架、个人设置;管理员可访问的后台管理;以及公共的登录注册页。
路由设计中有一个容易被忽视的点,就是路由的meta信息。我在路由表里为每个页面声明了是否需要登录、是否需要管理员权限:
{ path: '/writer', name: 'WriterCenter', component: () => import('@/views/writer/Index.vue'), meta: { requireAuth: true } }, { path: '/admin', name: 'AdminIndex', component: () => import('@/views/admin/Index.vue'), meta: { requireAuth: true, requireAdmin: true } }配合全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requireAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } const user = JSON.parse(localStorage.getItem('userInfo') || '{}') if (to.meta.requireAdmin && user.role !== 1) { next({ path: '/' }) return } next() })这个守卫是整个前端的访问控制核心。实际开发中我还遇到过一个问题:用户token过期后,接口返回401,但页面还在停留,用户不知道发生了什么。我的处理是在Axios拦截器里统一监听401,清除本地登录信息,跳转登录页并带上当前路由作为redirect参数,登录成功后能自动回到原页面。
4.2 请求封装与状态管理
Axios封装我做了三层:实例创建、请求拦截、响应拦截。请求拦截里自动从localStorage取出token塞进Header;响应拦截里统一解包Result,业务错误码直接弹出Message提示,HTTP层面的错误单独处理。
const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) service.interceptors.response.use( res => { const data = res.data if (data.code === 0) return data.data Message.error(data.message) return Promise.reject(new Error(data.message)) }, error => { if (error.response?.status === 401) { localStorage.removeItem('token') localStorage.removeItem('userInfo') router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) } return Promise.reject(error) } )登录状态这里我用的是Vuex(或Pinia,取决于你用Vue2还是Vue3)。原则是:用户基本信息从接口获取一次后存本地并同时存入状态管理器,刷新页面时再从本地恢复,避免每次刷新都要重新拉用户信息,同时也能保证路由守卫里读到的用户角色是准确的。
4.3 内容编辑器、富文本安全与组件拆分
文学创作的核心输入是正文,正文编辑器我用了基于Textarea的轻量方案,没有接很重的富文本编辑器。原因是长篇连载作品更需要的是一套稳定的纯文本编辑和字数统计,而不是一排排版按钮。作品简介部分用了简化的Markdown编辑器,支持标题、加粗、引用、代码块几个常用语法。
用Markdown就必然要面对XSS问题。后端保存的是Markdown源码,前端展示时先通过markdown解析成HTML,再调用DOMPurify做白名单过滤,否则用户提交的内容里包含<script>标签,打开页面就直接执行了。这一步不做,整个系统就约等于裸奔。
组件拆分上,作品卡片、评论列表、分页组件、点赞收藏按钮都抽出来做成复用组件。一个经验是:点赞和收藏按钮的图标状态要由父组件传入的初始状态控制,点击之后乐观更新UI,同时调用接口,失败再回滚状态。这样用户操作响应快,也不会出现双击后状态错乱的情况。
后台管理界面我额外做了一个简单的动态路由示例。管理员登录后在路由表中按角色追加管理页面路由,普通用户永远不可能在代码层面进入管理页。这个设计看起来简单,但把前端权限的粒度做得很清晰。
5. 联调、打包与部署:文档里查不到的坑
这个项目真正磨人的阶段是前后端联调和最后部署。前后端分离开发最大的好处是互不阻塞,但跨域问题、打包产物的归属、数据库连接的参数配置,每一项都可能让开发工作前功尽弃。
5.1 本地开发阶段:跨域与代理配置
本地开发时前端跑在Webpack/Vite的devServer,端口通常和SpringBoot的8080不同,直接请求就会遇到CORS问题。最简单的处理方式是在devServer里配置代理,把指定前缀的请求转发到后端:
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } }这样做的好处是前端请求地址写的都是相对路径,后端不需要开启全局跨域,也就少了一个安全隐患。如果后端非要在开发环境开启CORS,记得一定要限定允许的来源,不能直接配置allow-credentials和open pattern无限制放行。
5.2 生产打包:Vue构建产物如何交给SpringBoot
系统上线时我把前端打包后的dist目录内容直接放进了SpringBoot的src/main/resources/static目录下,这样SpringBoot内置的Tomcat直接托管静态文件,整个系统一个Jar包就能启动,部署成本最低。
实际操作时要注意两点:
- 路由模式尽量用hash模式。如果用了history模式,刷新项目详情页时SpringBoot的静态资源映射找不到对应的前端路由,会出现404。虽然可以写一个转发到index.html的Controller兜底,但hash模式没有任何额外配置,更适合单模块项目。
- 处理静态资源缓存时间。SpringBoot对static下的资源默认有缓存策略,前端更新后用户浏览器可能继续用旧版本。打包时给文件名加上内容哈希值,发布时提示用户强刷一次即可。
5.3 数据库连接与常见报错修复记录
数据库连接配置看着简单,实际上“SSL连接错误”和“时区报错”是出现频率最高的两个问题。MySQL 8.x驱动相当严格,如果连接URL没加额外参数,启动时大概率看到类似SSL connection error的异常。
正确配置方式:
url: jdbc:mysql://localhost:3306/literary_forum?useUnicode=true&characterEncoding=utf8&useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai这个连接串里每个参数都有原因:useUnicode和characterEncoding保证中文存取不乱码,useSSL=false关闭SSL减少连接握手时间,allowPublicKeyRetrieval=true解决MySQL 8.x的caching_sha2_password认证问题,serverTimezone=Asia/Shanghai避免日期时间因时区偏移八小时。
日期格式化也是前后端联调的常客。MySQL返回的datetime类型MyBatis映射后默认是java.util.Date,Jackson序列化出来经常是一串时间戳。我在项目里通过全局配置指定了统一的输出格式:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8这比在每个实体的日期字段上贴@JsonFormat注解省事得多,也避免漏掉某个字段导致前端显示成原始时间戳。
最后一个常见坑是SpringBoot版本和Java版本的匹配问题。学生项目里很多人直接拉最新的SpringBoot版本,结果Java环境还是1.8,编译直接报错。我用的SpringBoot 2.7.x对应Java 8,稳妥且资料最多;如果你一定要用3.x,那就必须配Java 17以上,这个前提条件最好在建项目时就确认清楚。
系统上线后我还补了一项数据准备的工作:往数据库里导了一批公开的短篇散文和诗歌作为演示数据,因为答辩演示时如果只有测试用户没有真实内容,整个系统的价值感会弱很多。同时也保证了搜索、排行榜、分类筛选这些功能都能在演示现场直接看到效果。
最后再说一点个人体会。这个系统从架构上看不算复杂,但把“创作”和“社交”这两条主链路做到能顺畅走通,中间需要关注的细节比预期多不少。尤其是数据库字段的命名规范、统一返回体、MyBatis的驼峰映射、前端路由守卫这几个点,前期多花半小时统一约定,后面联调能省下好几天。如果你也准备做一个类似的项目,建议按“先定功能边界、再设计表结构、后写接口、最后做页面”的顺序推进,每一步都做扎实了,系统自然就出来了。