做了几个内容社区之后,我越发觉得:市面上大多说"自媒体平台"的毕业设计和项目教程,要么只讲CRUD,要么把Spring Boot和Vue割裂成两套不相干的东西在演示。真正把一个文章发布平台跑起来,从用户注册登录、文章发布审核,到标签分类、浏览量统计、评论互动,再到文件存储、缓存优化、部署上线,中间隔着大量文档里不写、视频里不讲的实际细节。这篇我就拿自己最近重构的一个基于Spring Boot + Vue的前后端分离文章信息发布平台当例子,把从设计到落地的完整链路拆开说清楚,内容包括表结构设计、接口权限控制、富文本与文件上传、缓存策略、Vue端路由与状态管理,以及那些会让你半夜抓狂的坑。
1. 需求拆解与边界划分——这个平台到底在做什么
很多人一看到"自媒体文章信息发布平台"就把思路拉成一个巨大的后台管理系统,用户管理、文章管理、广告位、素材库、数据报表全往上堆。结果项目做到一半发现三个月都毕不了业,或者上线之后一堆功能没人用。做这类系统,第一件事是把"必须做的"和"可以后续迭代的"分开。
1.1 核心角色与闭环流程
自媒体文章平台首先是一个内容发布系统,我这边按实际业务锚定了四种角色:
- 游客:浏览首页文章列表、浏览文章详情、搜索文章
- 注册用户:登录后发布文章、编辑草稿、管理自己发布的内容
- 审核管理员:对用户提交的文章进行审核、通过或驳回、管理分类与标签
- 系统管理员:用户禁用/启用、系统参数配置、审核日志查看
核心流程是一条内容闭环:用户撰写文章 -> 提交审核 -> 管理员审核 -> 审核通过后发布到前台 -> 用户浏览阅读 -> 浏览数/点赞数/评论数更新 -> 作者后台看数据反馈。
这个闭环其实和真实的内容平台流程完全一致,相当于一套简化版头条号后台。明确了闭环之后,所有功能设计都围绕"文章本身的状态流转"展开,不会出现那种为了凑功能而凑的模块。
1.2 功能边界与MVP取舍
我给自己划了两个阶段。第一版MVP只做这几件事:
- 注册登录(JWT令牌认证)
- 文章发布、编辑、删除(带草稿和已发布状态)
- 文章分类 + 标签
- 文章审核流
- 浏览计数与点赞
- 评论
- 个人中心(我的文章、我的评论、基础信息修改)
第二版我再考虑:关注关系、Feed流、专栏、付费阅读、数据大屏、消息通知。为什么要控制边界?因为自媒体平台的核心逻辑是"内容发布与流转",把这一条线走透,技术难点基本都覆盖了。上来就铺大摊子,很容易表结构互相耦合,后面每加一个功能都像拆炸弹。
部署形态上,第一版我直接采用"前后端分离":Spring Boot 3.x提供纯RESTful API,Vue 3 + Vite做单页应用。前端打包成静态文件后由Nginx伺服,后端以jar包方式运行,两者通过/api前缀的请求做数据交换。这样一个架构既贴合现在团队的主流协作方式,也方便后期把某一个模块单独拆出来做成微服务。
1.3 用户故事驱动设计
在设计表之前,我习惯先把用户故事写出来。比如:
- 作为一个自媒体作者,我希望能保存未写完的文章,这样我可以分多次完成一篇长文
- 作为一个作者,我希望能给我发布的文章打上标签,这样读者能根据标签更快找到相关内容
- 作为一个管理员,我希望能在后台看到待审核文章列表,并快速通过或驳回,以便保持平台内容质量
每个用户故事背后都对应具体的页面、API和数据库操作。这样做的好处是,功能不会悬挂在半空中,每个接口都有业务意义。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库模型设计:没有一张多余的建表语句
文章信息发布平台,表设计是整个项目的命脉。表结构一旦定下来,后面再做大的调整,改动成本是指数上涨的。这一节我把最终的模型和演进思路写出来,都是可以直接照用的。
2.1 核心表结构总览
我最终落地的核心表有八张:
| 表名 | 用途 | 关键字段 |
|---|---|---|
| user | 用户表 | id, username, password, nickname, avatar, role, status, create_time |
| category | 文章分类表 | id, name, sort, status |
| tag | 标签表 | id, name |
| article_tag | 文章-标签关联表 | article_id, tag_id |
| article | 文章表 | id, user_id, category_id, title, summary, content, cover_image, status, view_count, like_count, create_time, update_time, publish_time |
| comment | 评论表 | id, article_id, user_id, content, parent_id, create_time |
| audit_log | 审核日志表 | id, article_id, operator_id, action, reason, create_time |
| user_favorite | 收藏表 | id, user_id, article_id, create_time |
article表是整个设计的核心,status字段尤其重要。我在设计时用了整型枚举:0草稿,1待审核,2已发布,3已驳回,4已下架。这个状态机贯穿整个后端Service层,是所有审核流逻辑的地基。
提示:status千万别用字符串随便写,否则后面写枚举判断的时候会把自己搞疯。统一用整型,在Java侧写一个枚举类,前后端约定好数字含义,接口文档里表格列清楚。
2.2 为什么文章内容用独立长文本存储
有个设计细节值得单独说:文章长内容我直接存在了article表的content字段(TEXT类型),而不是拆成单纯的草稿表和发布表两张冗余存两遍。拆开的好处是历史数据好追溯,但坏处是写一个文章要动两张表,事务问题随之而来。
我的做法是:一张article表 + status状态位。内容在草稿和已发布状态下都保留在同一行,用status标记当前文章处于哪个阶段。这样作者在编辑历史文章时,修改的永远是同一行记录;管理员驳回后作者修改,也不用去比对草稿内容。
但这里要注意一个细节:如果后期要做"版本历史"功能,比如作者可以回滚到之前的某一版,那张表结构就得升级了,需要拆一份article_content表,按version存多个版本。MVP阶段不做,所以单表TEXT完全够。
2.3 标签与分类的取舍
分类是一级维度,标签是二级维度。我这边文章表里直接存category_id,因为一篇文章只能属于一个分类。而标签是多对多关系,用关联表article_tag维护。
分类做树形还是扁平?我第一版做的是扁平分类,就是数据库里存几行记录:后端开发、前端开发、产品设计、职场生活。因为文章平台的分类场景相对稳定,树形结构收益不大,反而让接口和前端组件复杂度上升。只有后台管理中给分类做sort排序,前台就按排序展示。
关联表article_tag我建了联合唯一索引(article_id, tag_id),防止同一篇文章打上重复标签。查询某篇文章的标签列表时,用一句联表查询即可:
sql复制SELECT t.name FROM tag t
INNER JOIN article_tag at ON t.id = at.tag_id
WHERE at.article_id = #{articleId}
反过来,通过标签查文章,就扫描article_tag拿到article_id集合,再回表查文章详情。数据量上了百万之后这种查询要优化,但自媒体平台前期日活几千完全没问题,不需要一上来就上Elasticsearch。
2.4 审核日志表设计背后的思考
很多类似项目不做audit_log表。但真实的内容平台运营一段时间就会发现,审核记录必须留痕:谁在什么时间驳回了哪篇文章、理由是什么、作者有没有二次提交。这既是为了作者申诉时有依据,也是为了管理员操作可追溯。
audit_log表记录的是每次操作的快照,action字段区分PASS和REJECT,reason字段记录驳回原因。作者端被驳回的文章可以直接看到管理员填写的理由,知道该往哪个方向改。这个表虽然简单,但对系统的完整度提升非常明显。
3. 后端Service层:状态机、事务与权限的落地细节
表结构确定之后,真正花时间的是后端业务代码怎么写。这一节只聊几个最关键的模块的落地思路,跳过那些教科书里已经写烂的Controller基础CRUD。
3.1 JWT认证与接口权限的粒度控制
登录注册模块我用的方案是:Spring Security + JWT。具体实现是自定义一个JwtAuthenticationFilter,继承OncePerRequestFilter,从请求头Authorization里取Bearer Token,合法就往SecurityContext里塞一个Authentication对象。这样下游接口就能通过注解控制权限:
java复制@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/articles/pending")
public Result<List<ArticleVO>> pendingList() { ... }
权限粒度上我控制了四级:游客接口(匿名可访问)、登录用户接口(已经认证)、管理员接口(ROLE_ADMIN)、文章归属校验(作者本人或管理员)。其中文章归属校验是最容易出权限漏洞的地方。例如用户改自己文章时,update接口必须校验当前登录用户的id和文章user_id是否一致,否则会出现水平越权漏洞,用户能改别人的文章。
我专门写了一个工具方法:
java复制public Article getOwnedArticleOrThrow(Long articleId, Long currentUserId) {
Article article = articleMapper.selectById(articleId);
if (article == null) {
throw new BizException("文章不存在");
}
if (!article.getUserId().equals(currentUserId)) {
throw new BizException("无权操作该文章");
}
return article;
}
这段代码逻辑很简单,但它是整个系统的安全底线。凡是涉及"当前用户操作自己的资源",一律走这个方法校验。后来我排查一个越权问题时发现,很多人会忘了在delete接口里做归属校验,就导致任意登录用户都能删别人的文章。这是实战项目里必须反复确认的点。
3.2 文章状态流转的Service层设计
文章相关的核心Service方法我梳理下来就六个:
- saveDraft:保存草稿
- submitArticle:提交审核
- approveArticle:审核通过
- rejectArticle:审核驳回
- offlineArticle:下架
- deleteArticle:删除(逻辑删除)
每个方法都在事务内执行,并且会写一条audit_log。以submitArticle为例,业务逻辑是这样的:
java复制@Transactional
public void submitArticle(Long articleId, Long userId) {
Article article = getOwnedArticleOrThrow(articleId, userId);
// 草稿或者被驳回的文章才能重新提交
if (article.getStatus() != ArticleStatus.DRAFT.getCode()
&& article.getStatus() != ArticleStatus.REJECTED.getCode()) {
throw new BizException("当前状态的文章不能提交审核");
}
article.setStatus(ArticleStatus.PENDING.getCode());
articleMapper.updateById(article);
auditLogMapper.insert(new AuditLog(articleId, userId, "SUBMIT", "提交审核"));
}
这里有个技巧:驳回后再提交,要允许作者编辑内容后重新进入待审核状态。如果不加REJECTED状态判断,用户被驳回的文章就无法二次提交,整个审核闭环就断了。状态机的每个分支都要问自己:这个状态能不能走到下一个状态?走到下一个状态要什么前提条件?
3.3 浏览量计数:不能每次请求都update数据库
浏览量是一个高频操作,用户每打开一次文章详情页,就要把view_count加1。如果直接update,数据库压力会非常大,尤其是热点文章被同时访问时,会产生严重的行锁竞争。
我用的方案是Redis + 延迟批量落库。具体流程是:
- 打开文章详情时,先
incrementRedis里的计数键(格式article:view_count:{articleId}) - 定时任务每5分钟把Redis里的计数同步回MySQL
- 真正的浏览量展示直接读Redis;Redis没有就走MySQL兜底
大概每5分钟执行一次LRANGE或直接SCAN所有计数键,写个batch update。这样就避免了每次请求点数据库。定时任务用Spring的@Scheduled即可,单机部署完全够用。唯一要注意的是:缓存里读出来的浏览量在服务器重启时有丢失风险,所以我对计数键做了持久化策略,并且同步频率控制在5分钟一档,实际影响很小。
3.4 热门文章列表的缓存策略
首页的热门文章列表,我缓存的是Redis里的一份有序集合ZSet,score直接用浏览量。页面加载时从ZSet里取热度最高的20篇,查不到再从MySQL里查,然后回填缓存。这个方案比每次实时count查询要快很多,也能扛住一定的突发流量。
这里有一个大坑:如果把浏览量字段缓存在Redis,那么文章修改标题后,Redis里的那份数据会过期。解决办法是构建缓存key时带上文章版本号或者更新时间。我实际用的是article:hot:{updateTime}这种带时间戳的key,每当有文章重新编辑,旧缓存自然失效,新列表重新生成。这个设计在一次线上环境里帮我规避了"改完标题前台首页还是旧标题"的严重bug。
3.5 全文检索到底该不该用ES
首页搜索框,用户输入关键词,匹配标题和摘要。初期用MySQL的LIKE完全可以:
sql复制SELECT * FROM article
WHERE status = 2 AND (title LIKE CONCAT('%', #{keyword}, '%') OR summary LIKE CONCAT('%', #{keyword}, '%'))
ORDER BY publish_time DESC LIMIT 20
LIKE的性能问题在数据量很小的阶段完全无感知。真到了几十万、上百万文章的时候,再上Elasticsearch。我在这篇文章里专门提这个,是因为看到很多教程动不动就让初学者上ES,结果还没跑到上线就死在配置文件里。一切优化以实际数据量为前提。
4. 前端Vue 3项目结构、路由与状态管理实战
后端API设计好了之后,前端的工作量其实更大。我用的技术栈是Vue 3 + Vite + Pinia + Vue Router + Element Plus。下面把几个核心模块的落地过程讲清楚。
4.1 项目结构与初始化注意事项
用Vite脚手架创建项目,命令很基础,但我强烈建议在正式开发前搞定三件事:
- 配置路径别名
@指向src目录,避免组件里到处写相对路径 - 统一封装axios实例,设置baseURL为
/api,并加请求/响应拦截器 - 把路由拆成静态路由和动态路由两部分
axios拦截器是我每次项目必做的。响应拦截器里统一处理后端返回的Result包装结构,比如code为401时自动跳转登录页并清除本地token。这块不做好,每个接口都要写一遍错误处理,代码会非常冗余。
4.2 路由设计:静态路由 + 动态路由
前台部分(游客可见)用静态路由就够了:首页/、文章详情/article/:id、分类列表/category/:categoryId、搜索页/search、登录/login、注册/register。
创作者后台和运营后台则采用动态路由:用户登录后,根据角色从后端获取菜单和路由表,用router.addRoute动态挂载。这样做的好处是,游客根本拿不到创作者后台的路由配置,从路由层面就做了一层权限隔离。
比如作者后台的路由表是这样的结构:
javascript复制const creatorRoutes = [
{ path: '/creator', component: CreatorLayout, meta: { role: 'USER', requiresAuth: true },
children: [
{ path: 'articles', component: MyArticles },
{ path: 'article/edit', component: ArticleEdit },
{ path: 'comments', component: MyComments }
]
}
]
动态路由挂在router.beforeEach导航守卫里判断,如果登录用户没有对应角色,直接next到403页。路由守卫的编写是这类项目最考验细心程度的地方。
4.3 Pinia状态管理:user与app模块
Vue 3生态里状态管理我已经全面切到Pinia。项目里我拆了两个store:
useUserStore:保存token、用户信息、角色useAppStore:保存侧边栏折叠状态、全局加载状态、主题配置
useUserStore里最核心的是一个fetchUserInfo方法,每次刷新页面后调用。因为token存在localStorage里,刷新后页面能拿到token,但拿不到用户信息,所以必须在应用初始化时根据token拉取用户信息并填充store。这步不做,刷新页面就会出现"登录了但页面显示未登录"的诡异现象。
用户在刷新后掉登录态的坑,十有八九就是这里没处理。
4.4 富文本编辑器集成:文章发布的体验核心
文章正文编辑我用的组件是WangEditor(Vue 3版本为@wangeditor/editor-for-vue)。选取它的原因:中文文档友好、开箱即用、支持图片自定义上传。
编辑器的核心配置有两块。第一块是工具栏自定义,我保留了标题、加粗、斜体、列表、引用、代码块、图片、链接等常用项,把公式、视频、表格等不常用的去掉了,保持编辑器界面清爽。第二块是图片上传的配置:
javascript复制editorConfig.MENU_CONF = {
uploadImage: {
server: '/api/file/upload/image',
fieldName: 'file',
meta: { type: 'editor' },
headers: {
Authorization: `Bearer ${userStore.token}`
},
// 自定义插入
customInsert(res, insertFn) {
if (res.code === 200) {
insertFn(res.data.url, res.data.url, res.data.url);
} else {
ElMessage.error(res.message);
}
}
}
}
这里有几个细节值得提醒:
- 上传接口返回的格式必须和编辑器插件期望的格式对齐。WangEditor默认期望
{ errno: 0, data: { url: 'xxx' } },但很多人后端返回的是{ code: 200, data: { url } },就导致图片上传成功后编辑器里显示不出图片。处理方式就是上面代码里的customInsert,把后端返回结构手动转换成编辑器需要的数据格式,再插入正文。 - 图片上传需要鉴权,所以上传时要带上token。如果401,编辑器里会出现一张裂图,要引导用户重新登录。
4.5 文章发布表单与图片封面上传
封面上传我单独写了一个组件,用Element Plus的el-upload,同样走/api/file/upload/image接口。上传成功后把返回的URL存入表单的coverImage字段。发布时把标题、分类、标签、摘要、正文、封面一次性提交到后端。
保存草稿和提交审核我拆成两个按钮。保存草稿调用/api/articles/{id}/draft,提交审核调用/api/articles/{id}/submit。这样避免用户随手点一下就把未写完的内容送审了。评论区有人会说自己做的东西不需要这么复杂,但只要是真实面向用户的平台,这一步体验差别巨大。
4.6 m3u8视频与移动端播放的兼容方案
做自媒体平台,迟早会遇到视频内容。我这边文章详情页支持插入视频,视频格式踩过不少坑,这里重点备忘一个:视频转码后生成的m3u8切片,在PC端的Chrome可以用hls.js播放,但在iOS Safari上原生支持比较好。我项目里选的是vue-video-player组件对接video.js,配置上同样区分了PC和移动端两套播放策略。视频源本身由后端做转码后以切片形式提供,播放器按需加载。
这块是一个容易忽略的合规问题:视频内容审核在真实平台是必须的,我的方案是在上传阶段由后端调用内容安全服务做自动审核,审核不通过直接拒收文件。这也是自媒体平台能持续运转的必要保障。
5. 文件存储与MinIO集成:头像、封面、编辑器图片统一管理
图片和视频文件不能存数据库,也不能直接扔服务器本地磁盘。我用的方案是MinIO,一个开源的对象存储服务,S3协议兼容,单机部署十几分钟就能搞定。
5.1 MinIO环境搭建与Bucket规划
我是在一台4核8G的Ubuntu服务器上用Docker部署的MinIO:
bash复制docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-e "MINIO_ROOT_USER=minioadmin" \
-e "MINIO_ROOT_PASSWORD=your-strong-password" \
-v /data/minio:/data \
minio/minio server /data --console-address ":9001"
9000是API端口,9001是控制台端口。我在MinIO里规划了三个Bucket:
avatar-bucket:用户头像cover-bucket:文章封面media-bucket:文章正文图片和视频素材
为什么要分三个而不是共用一个?因为后期如果要对不同目录设置不同权限策略,比如封面对所有人可读、原始素材仅作者可下载,分开Bucket权限管理会清晰很多。Bucket的访问权限我统一设为public-read,也就是文件上传后可公开访问。真实生产环境有安全诉求时可以通过预签名URL实现临时访问,但MVP阶段公开可以接受。
5.2 Spring Boot集成MinIO文件上传
后端集成我用的是io.minio:minio依赖,封装了一个FileStorageService,对外提供上传、删除、生成访问URL等方法。核心上传方法代码如下:
java复制public String uploadFile(MultipartFile file, String bucket, String objectName) {
try {
ObjectWriteResponse response = minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.contentType(file.getContentType())
.stream(file.getInputStream(), file.getSize(), -1)
.build());
return String.format("%s/%s/%s", minioEndpoint, bucket, objectName);
} catch (Exception e) {
throw new BizException("文件上传失败: " + e.getMessage());
}
}
objectName的生成规则我用的是uuid + 原始文件扩展名,比如a1b2c3d4-e5f6-7890-1234-567890abcdef.png。用UUID能避免文件名冲突,并防止中文文件名或非法字符导致的路径问题。nacos和图片压缩的问题这里先不展开,后面踩坑部分会单独讲。
上传接口返回的URL最终以字符串形式存储到数据库对应的coverImage、avatar字段里。前端展示时直接用这个URL做图片src即可。
5.3 上传前的文件校验
只调MinIO的上传接口是不够的,必须在Controller层做严格校验,否则服务器会被垃圾文件打爆。我的校验逻辑:
- 文件大小限制:图片不超过2MB,视频不超过100MB
- 文件类型白名单:
image/jpeg、image/png、image/webp、video/mp4等 - 使用Spring的
MultipartFile做空文件和文件类型判断
图片压缩我引入了thumbnailator库,上传后自动压缩到800px宽度以内,压缩后的大小通常只有原图的十分之一,页面加载速度快很多。对自媒体平台来说,封面图压缩收益极高,一篇文章如果封面图超过1MB,首页加载性能一定会被拖累。
5.4 Nginx代理与静态资源访问
前端打包后的dist目录、上传的图片资源,我是通过Nginx统一管理的。MinIO的文件访问地址可以直接用服务器的9000端口,但更规范的做法是用Nginx做反向代理和路径映射,对外只暴露80/443端口。
Nginx配置关键片段:
nginx复制server {
listen 80;
server_name your-domain.com;
# 前端静态资源
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端API反向代理
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# MinIO文件存储代理
location /minio/ {
proxy_pass http://127.0.0.1:9000/;
proxy_set_header Host $host;
}
}
这里有个大坑:try_files $uri $uri/ /index.html;是Vue路由history模式下的标准配置,它保证前端路由刷新时,Nginx会回退到index.html。如果没有这一行,用户在文章详情页刷新会得到404。这是Vue Router + Nginx部署最经典的坑,我在踩坑实录里会再提一次。
6. 审核、评论与数据统计模块的完整实现
主体功能跑通之后,我开始完善辅助模块。这些模块在MVP里不做会出现一种"空壳平台"的感觉,做了之后才像一个真正能运营的系统。
6.1 审核后台实现
审核功能是内容平台的守门员。我后端提供了两个核心接口:
java复制@GetMapping("/admin/articles/pending") // 待审核列表
@PostMapping("/admin/articles/{id}/review") // 审核操作
待审核列表返回分页数据,每条包含作者昵称、文章标题、摘要、提交时间。审核操作接收action(通过/驳回)和reason(驳回理由)。前端后台页面左侧是待审核列表,右侧是文章内容预览。管理员看一眼内容质量,做出判断,操作完当前项刷新列表。
驳回理由的填写我做了必填校验。如果是人工审核,不填理由直接驳回,作者完全不知道该怎么改,这是体验设计上一个很大的问题。所以我在前端加了一个必填判断,在后端也做了参数校验,双保险。
6.2 评论模块与评论列表
评论模块设计成两级:顶级评论 + 回复评论。数据库里用parent_id区分,顶级评论的parent_id为0,回复评论的parent_id为对应评论的id。查询文章评论列表时,先查顶级评论,再根据parent_id批量查回复,最后在Java层拼装成树形结构返回给前端。
这里有个效率问题:如果用递归查询每条顶级评论的回复,会产生N+1查询。我这里用了一个小技巧:一次查出该文章的所有评论,全部放进内存,以parent_id为key分组成Map,再循环组装。数据量大时效率依然高。数据库索引上,article_id需要建索引,并且带上status过滤条件只查已通过状态(评论同样有敏感词过滤前置)。
评论发布接口要做用户鉴权和频率限制:我用的方式是Redis + 时间窗口,同一个用户60秒内只能评论一次。这样做的原因很直接:防止垃圾评论刷屏。在一个很小的scale下,这个限制不会误伤正常用户,但对防灌水有明显效果。
6.3 个人中心与我的文章
创作者后端的"我的文章列表"查询,不是简简单单查article表按user_id过滤。因为列表页需要显示文章状态、分类名、浏览量、点赞量、审核驳回原因等,这些字段分布在多张表,所以专门写了一个聚合查询的VO类。
我用的实现是,在SQL里做三表联查:
sql复制SELECT a.id, a.title, a.view_count, a.like_count, a.status, a.publish_time,
c.name AS category_name,
(SELECT reason FROM audit_log al WHERE al.article_id = a.id
ORDER BY al.create_time DESC LIMIT 1) AS last_reason
FROM article a
LEFT JOIN category c ON a.category_id = c.id
WHERE a.user_id = #{userId}
ORDER BY a.update_time DESC
注意last_reason子查询,取的是该文章最近一条审核记录的原因。这个字段在文章被驳回时给作者展示"驳回原因"特别有用。一条SQL就把列表页要的数据全带回来了,不用在Java里一层层嵌套查询。
6.4 后台数据看板和日志
真实运营后台一定要有数据看板。我在控制台首页统计了几个核心指标:用户总数、文章总数、今日新增文章、待审核文章数、总浏览量。统计接口同样走Redis缓存和MySQL兜底,避免每次打开后台都全表count。
系统日志我用了Logback,并做了按天滚动和大小限制。日志粒度上:Controller层打印请求参数和耗时,Service层打印关键业务变化,全局异常处理器打印异常堆栈。日志不仅是排查问题的关键,也是后期做数据审计的基础。生产环境我保留了INFO级别,DEBUG上线就关,避免日志量过大把磁盘写满。
7. 性能调优、安全加固与上线部署的完整记录
功能做完不算完,把项目真正部署到服务器上跑稳,这个过程中遇到的坑才是最有分享价值的。
7.1 两级缓存:Caffeine本地缓存 + Redis分布式缓存
首页文章列表、分类列表、热门标签这些数据,访问频繁且变更不频繁。我的方案是两级缓存:
- 一级:Caffeine本地缓存,性能极高,适合单机部署
- 二级:Redis分布式缓存,适合多实例部署
查询时先查Caffeine,未命中再查Redis,再未命中查数据库并回填。更新时先更新数据库,再删Redis键和Caffeine键,保证一致性。
具体代码如下:
java复制@Service
public class CategoryService {
private final Cache<String, List<CategoryVO>> localCache = Caffeine.newBuilder()
.maximumSize(200)
.expireAfterWrite(Duration.ofMinutes(30))
.build();
private static final String REDIS_KEY = "category:list";
public List<CategoryVO> listCategories() {
List<CategoryVO> categories = localCache.getIfPresent(REDIS_KEY);
if (categories != null) {
return categories;
}
String json = redisTemplate.opsForValue().get(REDIS_KEY);
if (StringUtils.hasText(json)) {
categories = JSON.parseArray(json, CategoryVO.class);
localCache.put(REDIS_KEY, categories);
return categories;
}
categories = loadFromDb();
redisTemplate.opsForValue().set(REDIS_KEY, JSON.toJSONString(categories), 30, TimeUnit.MINUTES);
localCache.put(REDIS_KEY, categories);
return categories;
}
}
这里两级缓存的时间窗口可能导致数据短时间不一致,但对分类这种低频修改的数据来说影响可以忽略。热点文章列表更新时,我会主动调用一个evictCache方法把本地和Redis的缓存都清掉。
7.2 Spring Boot 3.5下的虚拟线程开启
我用的Spring Boot版本是3.5(Java 21),这个组合有一个杀手级特性:虚拟线程。开启方式是配置一行:
yaml复制spring:
threads:
virtual:
enabled: true
开启后,Tomcat处理请求时会把每次请求放在虚拟线程里,而不是传统的平台线程。虚拟线程的创建成本极低,可以支撑非常高的并发连接数。像文章详情页这种大量IO操作的接口,虚拟线程的收益非常明显。不需要改任何业务代码,就是用更轻量的线程模型玩同一套接口。
7.3 安全加固的三个方面
安全方面我重点做了三件事:
接口防抖/限流:用一个简单的Redis + Lua脚本实现固定窗口限流。对发送验证码、提交评论、提交审核这类接口,限制每个用户每分钟的调用次数。由于Lua脚本在Redis里原子执行,多实例部署下也有效。
XSS防护:文章正文是富文本,必然包含HTML标签,但不能允许粘贴script标签。我在后端用Jsoup做过滤,白名单模式下只保留p、strong、em、a、img、pre、code等安全标签,其他全部剔除。发布时过滤一遍,展示时再对属性做转义,双保险。不装反XSS的库,光靠前端输入框拦不住,因为攻击者可以直接调接口。
SQL注入防范:坚持使用MyBatis的#{}预编译占位符,严禁字符串拼接SQL。文章搜索关键词处理时要防止%和_通配符注入,我用StringEscapeUtils做了一次转义。
7.4 Docker Compose一键编排部署
整个项目部署我写了一个docker-compose.yml,把MySQL、Redis、MinIO、后端服务、前端Nginx全部编排在一起:
yaml复制version: "3.8"
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root123
MYSQL_DATABASE: media_platform
volumes:
- ./mysql-data:/var/lib/mysql
ports:
- "3306:3306"
redis:
image: redis:7-alpine
ports:
- "6379:6379"
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
volumes:
- ./minio-data:/data
ports:
- "9000:9000"
- "9001:9001"
backend:
build: ./backend
ports:
- "8080:8080"
depends_on:
- mysql
- redis
- minio
frontend:
image: nginx:alpine
volumes:
- ./frontend-dist:/usr/share/nginx/html
- ./nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- "80:80"
depends_on:
- backend
我个人私下部署的时候会把MySQL和Redis的数据目录挂载到宿主机,防止容器重建导致数据丢失。类似数据持久化这种操作,第一版一定要做,不然跑了一个月数据全没,哭都来不及。
7.5 Vue3项目打包与Nginx部署
前端打包:
bash复制npm run build
生成dist目录。把这个目录里的内容放到服务器的/usr/share/nginx/html,再配好Nginx的try_files回退即可。注意Vite打包时环境变量要区分开发和生产,接口地址用一个VITE_API_BASE_URL来控制,生产环境默认空字符串,走Nginx同域代理。
我有一次部署忘了把try_files和/api代理同时写上,结果刷新页面404,调接口403,两个问题同时出现,排查了一个多小时。后来我把三个关键配置项(前端history回退、API反向代理、静态资源缓存)写成了一个模板,每次部署直接用,再没出过问题。
8. 踩坑实录:最值得记录的九个问题与修复过程
这些坑都是我实际踩过并且花了不少时间才定位的,每一个都很有代表性。按"现象、排查过程、根因、修复方案"的结构记录下来,后面你遇到类似问题可以少走弯路。
8.1 现象:图片上传成功,但编辑器里一直显示裂图
排查过程:我先看浏览器Network,上传接口返回200,文件URL也能直接访问,说明上传链路没问题。但编辑器里图片一直不显示。再检查WangEditor的配置,发现customInsert没写,编辑器按默认结构去解析后端返回的JSON,而后端返回的格式和编辑器期望的格式不匹配。
根因:WangEditor默认期望的返回结构是{ errno: 0, data: { url } },我后端返回的是{ code: 200, data: { url } }。格式对不上,编辑器拿到数据后不知道该把哪个字段插进正文。
修复:按照上面4.4节的代码,在customInsert里做数据格式转换。这个坑的核心教训是:用任何前端组件时,一定要先看它期望的数据格式,尤其是第三方上传组件。
8.2 现象:修改文章标题后,首页还是显示旧标题
排查过程:我改完文章标题,去看首页,列表里还是旧标题。刷新浏览器缓存也没用。查数据库,数据其实已经更新了,说明是缓存问题。
根因:热门文章列表存了Redis缓存,缓存里是旧数据。Redis的TTL还没到,所以一直返回旧内容。这暴露了一个严重问题:我更新文章后没有主动失效相关缓存。
修复:在文章更新Service里加一个evictHotArticleCache(articleId)方法,更新成功后主动删除Redis里的对应缓存key。缓存不是"设置了过期时间就万事大吉",写操作触发时主动清缓存,才是保证一致性的关键。
8.3 现象:Vue路由history模式下刷新页面404
排查过程:本地开发环境没有这个问题,因为Vite dev server内部处理了路由回退。部署后刷新文章详情页报404,首页正常。Nginx日志显示404错误来自Nginx本身,不是后端。
根因:Nginx的location /配置里没有try_files回退规则。找不到对应的静态文件路径,就直接404了。
修复:配置try_files $uri $uri/ /index.html;。这一步是Vue Router history模式的标配,记不住的人直接写在Nginx配置模板里。
8.4 现象:数据库连接池不够用,接口时不时超时
排查过程:部署测试期间,并发一高就有部分接口超时。查看日志发现不少"Connection is not available, request timed out"错误。检查HikariCP配置,默认最大连接数是10,这个值在高并发场景下太保守了。
根因:连接池配置过小,线程池等不到连接,直接超时。同时,有些Service方法里事务开的太大,占用了连接却执行了过多IO操作。
修复:调整配置:
yaml复制spring:
datasource:
hikari:
minimum-idle: 5
maximum-pool-size: 50
connection-timeout: 30000
同时检查代码,把不需要事务的方法去掉@Transactional,避免长事务占用连接。这两个操作配合下来,接口超时基本消失。
8.5 现象:部署服务器后上传大文件失败
排查过程:上传视频时报413错误。查Nginx错误日志,发现是"client intended to send too large body"。
根因:Nginx默认请求体大小限制是1MB,上传视频超限直接被拒。
修复:在Nginx的http或server块里加:
nginx复制client_max_body_size 100m;
这个值要根据业务场景调整,视频上传平台可能还要更大,注意后端Spring MVC的spring.servlet.multipart.max-file-size也要同步调大。
8.6 现象:用户头像上传后变成裂图
排查过程:头像能上传成功,浏览器直接访问文件URL也是正常的。但是前端<el-avatar :src="user.avatar">显示裂图。查看请求发现,浏览器把头像URL当成相对路径去请求了,实际路径带了域名前缀。
根因:MinIO返回的URL是http://ip:9000/bucket/xxx.png这样的绝对地址,但我在存储的时候只存了/bucket/xxx.png这种相对路径,前端拼接时出了问题。
修复:统一存储完整URL,前端直接使用。如果以后要换域名或加CDN,再通过全局替换前缀的方式迁移。
8.7 现象:vue打包后图片资源加载404
排查过程:本地开发图片正常,npm run build后部署,部分图片404。打开浏览器看Network,发现图片请求路径带了/src/assets目录,而不是CDN或静态资源目录。
根因:Vite打包后的资源路径默认是相对路径./,但在某些部署场景下需要配置为绝对路径。
修复:在vite.config.js里设置base: './',确保打包后的资源引用是相对路径,这样不管部署在哪个子目录都能找到。
8.8 现象:快速连续点击发布按钮,产生多条重复文章
排查过程:用户反馈有时候一篇文章会变成两篇甚至三篇。看后端日志,发现提交审核接口被连续调用了多次,且每次调用都创建了一条新文章。
根因:前端按钮没有做防重复提交,后端也没做幂等控制。用户手快点了几下按钮,请求全部到达后端。
修复:前端按钮在提交后进入loading状态并禁用;后端在提交审核接口增加一个Redis锁,同一个用户60秒内只能提交一次。双保险之后问题彻底解决。
8.9 现象:日志文件无限增长,磁盘打满
排查过程:服务器磁盘告警,检查发现logback生成的日志文件越来越大,单日日志达到几个GB。
根因:日志没有做滚动策略,也没有大小限制。生产环境还开着DEBUG级别,各种调试信息全打到磁盘上。
修复:logback配置<rollingPolicy>按天滚动,并设置单文件最大50MB,保留7天旧日志。同时生产环境的日志级别调整为INFO。这套配置我用了很久没再出过磁盘问题。
9. 测试与验收:把自己当成最挑剔的用户
项目上线前,我花了一整晚把整个产品从头到尾按真实用户流程走了一遍。这个习惯帮我发现了不少"代码看起来没问题但实际体验很糟糕"的地方。
9.1 核心流程回归用例
我列了一张手动回归清单:
- 注册新用户 -> 登录 -> 进入创作者后台
- 创建一篇带图片和代码块的草稿 -> 保存草稿 -> 退出 -> 重新登录 -> 确认草稿还在
- 提交审核 -> 登录管理员账号 -> 待审核列表能看到 -> 驳回并填写理由
- 普通用户登录 -> 看到被驳回的文章 -> 编辑修改 -> 重新提交
- 前台发布文章详情页 -> 点击浏览 -> 浏览量+1 -> 评论 -> 点赞
- 个人中心 -> 我的文章列表 -> 状态显示正确
- 管理员后台 -> 数据看板数字正确
每个用例我标注了"通过/失败"和"发现的问题"。一路走下来,发现并修复了一个严重bug:作者在编辑被驳回的文章时,如果直接改了标题而不重新提交,前台展示的依然是旧标题。根因是前台查询文章时没有校验status为已发布。修复方案是前台查询统一加status = 2的条件。
9.2 接口压测与Jmeter简单验证
我用Jmeter对几个核心接口做了简单并发压测:首页文章列表、文章详情、提交评论。单机4核8G环境下,接口吞吐稳定在200 QPS左右,响应时间P95在300ms以内。这个成绩对MVP足够用了。压测过程中发现的热点文章接口性能问题,正好促成了上面的Redis缓存策略。
压测产物是一份Jmeter测试计划,直接放进项目仓库,后续每次发版都跑一遍回归。
10. 最后补充几点个人体会
做这类前后端分离的内容平台,技术本身不是最大的瓶颈,最大的瓶颈是"业务闭环是否完整"和"细节是否有守护"。我在多个版本的迭代中体会最深的三点:
第一,状态机是内容系统的灵魂。文章从草稿到发布再到下架,每一步的流转条件和边界都必须清晰。代码写错了可以改,状态机的逻辑错了,会让作者和管理员对系统失去信任。
第二,缓存策略是性能和一致性的平衡。不要畏惧加缓存,但一定记住"写操作主动失效缓存"这条铁律。只靠TTL过期的缓存方案,终有一天会在线上给你上一课。
第三,上线前一定先跑一遍真实流程。坐在那里写代码,和以用户身份走一遍产品,看到的东西完全不一样。我几乎每次都能在回归测试里抓到漏网之鱼。
这个项目整体技术栈不复杂,Spring Boot + Vue的组合已经很成熟,网上资料也很多。真正值钱的是我把每一条线走通之后留下来的这些细节经验,希望对你做类似项目有实质帮助。
