
去年下半年我帮朋友把一个“文学创作社交论坛”从口头需求做成了一套能演示、能部署、能拿去答辩的完整系统技术栈定在SpringBootMyBatisMySQLVue前后端分离开发。整个过程走下来最大的体会是这个项目表面上不复杂但真正把它做扎实难点全藏在数据模型设计和几个前后端交互细节里。今天把整个从零到一的实现过程复盘一遍从功能拆分、数据库设计、后端分层、前端路由到打包部署尽量把每个决定背后的原因也讲清楚。如果你正在做Java全栈类的毕业设计或者课程项目想找一个能完整照着落地的实际案例这篇应该能帮你省下不少自己摸索的时间。项目本身是一个面向文学爱好者的社区平台核心是“创作”和“交流”两个词。普通用户可以注册登录浏览和阅读作品对作品点赞、收藏、评论创作者可以发布短篇、长篇连载、诗歌等内容管理自己的作品和章节管理员则负责用户管理、作品审核和内容统计。接下来我按实际开发顺序把设计思路和踩过的坑一条条拆开说。1. 立项思路文学论坛的功能清单与边界划分很多同学拿到这类题目第一反应是“功能越多越好”结果做到一半发现数据库表怎么都设计不完联调的时候接口一个接一个改。我的建议恰恰相反先把必须有的功能列出来再把看起来很高端但其实会拖垮进度的功能砍掉。1.1 角色与核心模块拆解我把系统拆成三个角色每个角色对应一套功能集合普通访客浏览作品列表、查看作品详情、搜索作品、浏览排行榜。登录用户在访客基础上增加发布作品、管理章节、评论、点赞、收藏、关注作者、查看个人中心。管理员用户管理、作品审核/下架、评论管理、分类管理、数据统计。对应到系统模块上就是下面这张功能清单模块功能点优先级用户模块注册、登录、修改资料、头像上传、密码修改P0作品模块发布作品、新增章节、编辑、删除、发布/下架P0阅读模块作品详情、章节内容、阅读量统计P0互动模块点赞、收藏、评论、关注P1搜索与排行按标题/作者搜索、热度榜、新书榜P1管理后台用户列表、作品审核、评论清除、基础统计P1辅助功能公告、分类管理、个人主页P2P0是系统能不能跑起来的关键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字段用tinyint0表示普通用户1表示管理员方便扩展为多角色。status用于账号禁用删除用户时不走物理删除只是改状态这样用户的历史评论和作品还能保留关联。作品表字段是设计的重点id主键。title作品标题。author_id关联user表的id。category_id关联category表的id。description作品简介。cover_url封面图地址没有封面时前端显示占位图。status0草稿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) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4 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 ResultT { 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 idsearchWorks resultTypecom.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 testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR author_name LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND category_id #{categoryId} /if if teststatus ! null AND status #{status} /if /where ORDER BY choose when testsortBy 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?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai这个连接串里每个参数都有原因useUnicode和characterEncoding保证中文存取不乱码useSSLfalse关闭SSL减少连接握手时间allowPublicKeyRetrievaltrue解决MySQL 8.x的caching_sha2_password认证问题serverTimezoneAsia/Shanghai避免日期时间因时区偏移八小时。日期格式化也是前后端联调的常客。MySQL返回的datetime类型MyBatis映射后默认是java.util.DateJackson序列化出来经常是一串时间戳。我在项目里通过全局配置指定了统一的输出格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这比在每个实体的日期字段上贴JsonFormat注解省事得多也避免漏掉某个字段导致前端显示成原始时间戳。最后一个常见坑是SpringBoot版本和Java版本的匹配问题。学生项目里很多人直接拉最新的SpringBoot版本结果Java环境还是1.8编译直接报错。我用的SpringBoot 2.7.x对应Java 8稳妥且资料最多如果你一定要用3.x那就必须配Java 17以上这个前提条件最好在建项目时就确认清楚。系统上线后我还补了一项数据准备的工作往数据库里导了一批公开的短篇散文和诗歌作为演示数据因为答辩演示时如果只有测试用户没有真实内容整个系统的价值感会弱很多。同时也保证了搜索、排行榜、分类筛选这些功能都能在演示现场直接看到效果。最后再说一点个人体会。这个系统从架构上看不算复杂但把“创作”和“社交”这两条主链路做到能顺畅走通中间需要关注的细节比预期多不少。尤其是数据库字段的命名规范、统一返回体、MyBatis的驼峰映射、前端路由守卫这几个点前期多花半小时统一约定后面联调能省下好几天。如果你也准备做一个类似的项目建议按“先定功能边界、再设计表结构、后写接口、最后做页面”的顺序推进每一步都做扎实了系统自然就出来了。