ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于SpringBoot+Vue的博客创作中心实战:草稿到发布的状态管理

基于SpringBoot+Vue的博客创作中心实战:草稿到发布的状态管理 很多人做博客系统容易把重心全放在“文章列表页”和“详情页”的展示效果上等做到“创作中心”时反而会犹豫这不就是一个富文本编辑器加一个保存按钮吗但实际上创作中心才是一个博客系统里用户停留时间最长、状态逻辑最复杂的模块。在第七十九讲这个节点上项目骨架、用户模块、文章列表、详情页大概率都已经稳定了。之所以单独把“创作中心”拆成一讲是因为这个模块承担的是“内容生产”职责。前台页面再好看作者写得不顺手、流程有断裂系统就少人用。本文会先说明创作中心的功能边界与状态模型再基于 SpringBoot Vue 前后端分离架构给出后端接口、前端页面、编辑器接入、AI 辅助创作占位实现以及联调验证的完整闭环。先说一个明确判断创作中心不是整站里业务最重的模块而是前后端交互最密集的模块。它的难点不在某个单一接口而在“草稿—编辑—发布—再编辑”这条状态链的完整性以及 Markdown / 富文本内容在不同端之间渲染的一致性。这一讲会把这条链路讲透。1. 创作中心在整个博客系统中的定位1.1 为什么这一讲值得认真做很多初学前后端分离的开发者对“创作中心”的理解是左侧一个分类树中间一个编辑器右侧一个设置栏放个标题输入框和发布按钮就行。如果只是做一个课程练习这样确实够了但放到真实项目中创作中心直接影响用户是否愿意持续产出内容。从产品层面看创作中心承担了三件事内容录入支持 Markdown 或富文本编辑。状态管理草稿能保存、能预览、能随时回到上一次编辑。发布控制首次发布、二次编辑后的更新以及下线/归档操作。从技术层面看SpringBoot 后端要保证接口幂等、事务一致和权限隔离Vue 前端要处理好编辑器的组件生命周期、内容回显和路由守卫。两边的边界如果划分不清就会出现修改草稿后忘记更新列表、发布后内容丢失等问题。1.2 与其它博客模块的功能边界我建议先把“创作中心”和“文章管理”从功能上分清楚模块核心用户关注点状态范围前台文章列表 / 详情读者展示效果、阅读体验、SEO只展示已发布文章后台文章管理作者、管理员列表、检索、删除、批量操作全部状态可见创作中心作者新建、编辑、发布、AI辅助草稿 自己发布的文章前台页面通常只查status published的数据文章管理页面像一张管理表创作中心则更像一个“工作台”。实际开发中不要把这三种页面混到一个组件里配多个状态来切换否则组件复杂度会急剧增加。更推荐拆成独立路由、独立页面组件。1.3 技术选型上下文本文默认你已经走完本系列前面的内容后端是基于 SpringBoot 的 Java 项目已实现用户登录和基础权限体系前端是基于 Vue 的单页应用已配置 Vue Router 和 Axios。之所以不重复“搭建第一个 SpringBoot 项目”是因为到第七十九讲时项目骨架已经固定。首次读到这一讲的读者可以把它当作一个独立的“创作中心”模块实战参考。2. 核心功能梳理从“新建文章”到“发布”的状态流转2.1 文章状态定义创作中心最容易踩坑的地方是只用一个字段区分 “草稿” 和 “发布”。真实场景中一篇文章还应该有“发布后更新”的历史过程。这里采用一种相对通用且足够清晰的状态模型status 编码状态含义对读者是否可见0DRAFT草稿用户保存但不公开否1PUBLISHED已发布正式发布到前台是2ARCHIVED已归档作者主动下线原链接保留但提示归档视业务决定3DELETED删除逻辑删除可恢复否状态机约束DRAFT 可以变成 PUBLISHED。PUBLISHED 可以变回 DRAFT转为草稿隐藏页面也可以变成 ARCHIVED。ARCHIVED 可以重新变回 PUBLISHED。删除通常使用逻辑删除不直接从表里物理删除。这个状态机不是越复杂越好而是为了让前端页面有明确的按钮可绑新建文章默认落 DRAFT点击发布走 PUBLISHED点击下线走 ARCHIVED。2.2 创作中心的核心操作流程一个完整的创作流程应该这样走进入“新建文章”前端先创建一份本地草稿或在后端创建 DRAFT 记录。用户在编辑器里写标题、正文、标签和摘要。点击保存内容完整提交到后端后端更新 DRAFT。点击发布后端做字段校验和权限校验后更新为 PUBLISHED。发布后再次进入编辑页拉取最新正文保存变更。如果页面下线状态改为 ARCHIVED但内容仍然保留在草稿箱里可再次编辑。只要把这个流程想清楚后续无论是加“自动保存”还是加“AI 续写”都是在固定状态模型上做增量不会推翻重来。3. 数据库表设计与实体层实现3.1 表结构做到“能写、能存、能还原”文章内容有两种存储形态一种存 Markdown 原文另一种存渲染后的 HTML。这样设计的好处是编辑时用 Markdown 原文前端详情页直接渲染 content_html避免每次请求都临时转换消耗 CPU在全文检索或做摘要生成时也可以直接基于 content_md 处理。这里给出一版可执行的建表 SQL-- 文件路径src/main/resources/db/migration/V79__create_article_table.sql CREATE TABLE article ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, user_id BIGINT UNSIGNED NOT NULL COMMENT 作者ID, title VARCHAR(200) NOT NULL DEFAULT COMMENT 文章标题, summary VARCHAR(500) NOT NULL DEFAULT COMMENT 文章摘要, cover_image VARCHAR(500) NOT NULL DEFAULT COMMENT 封面图URL, content_md MEDIUMTEXT COMMENT Markdown 原文, content_html MEDIUMTEXT COMMENT 渲染后的 HTML, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿 1已发布 2归档 3逻辑删除, tags VARCHAR(500) NOT NULL DEFAULT COMMENT 标签逗号分隔, view_count INT NOT NULL DEFAULT 0 COMMENT 浏览量, like_count INT NOT NULL DEFAULT 0 COMMENT 点赞数, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, published_at DATETIME DEFAULT NULL COMMENT 首次发布时间, PRIMARY KEY (id), KEY idx_user_status (user_id, status), KEY idx_status_published (status, published_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT文章表;这里有两个容易忽略的点user_id和status建联合索引后台“我写的文章”默认都通过这两个条件查询能够避开全表扫描。summary不要允许为 NULL默认空串。这样在做前端回显时不需要写额外的空值判断。3.2 SpringBoot 实体与枚举如果只写一个statusInteger 字段业务代码里很容易到处是魔法数字。更推荐在项目里加一个枚举类// 文件路径src/main/java/com/example/blog/article/model/ArticleStatus.java package com.example.blog.article.model; import com.baomidou.mybatisplus.annotation.EnumValue; import com.fasterxml.jackson.annotation.JsonValue; public enum ArticleStatus { DRAFT(0, 草稿), PUBLISHED(1, 已发布), ARCHIVED(2, 已归档), DELETED(3, 逻辑删除); EnumValue private final int code; JsonValue private final String desc; ArticleStatus(int code, String desc) { this.code code; this.desc desc; } public int getCode() { return code; } public String getDesc() { return desc; } }实体类中直接使用枚举类型MyBatis-Plus 会通过EnumValue完成数值转换Jackson 在返回 JSON 时也能输出可读的 desc// 文件路径src/main/java/com/example/blog/article/model/Article.java package com.example.blog.article.model; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; Data TableName(article) public class Article { TableId(type IdType.AUTO) private Long id; private Long userId; private String title; private String summary; private String coverImage; private String contentMd; private String contentHtml; private ArticleStatus status; private String tags; private Integer viewCount; private Integer likeCount; private LocalDateTime createdAt; private LocalDateTime updatedAt; private LocalDateTime publishedAt; }到这里最底层的表模型与 Java 实体已经对齐。4. 后端接口设计草稿保存与文章发布4.1 接口清单创作中心的后端能力要覆盖完整的编辑链路可以定义成下面这一组接口方法路径功能状态要求POST/api/editor/articles新建空白草稿登录PUT/api/editor/articles/{id}保存草稿或更新文章本人POST/api/editor/articles/{id}/publish发布文章本人GET/api/editor/articles/{id}获取编辑详情本人GET/api/editor/articles?statusDRAFT查询自己的文章列表本人POST/api/editor/upload上传图片登录新建草稿和保存草稿建议分开。新建时只插入一条 status0 的空白记录页面拿到文章 ID 后就知道后续是“更新”而不是“新建”这对前端的路由跳转非常有用。4.2 控制器实现一个清晰的做法是使用一个专门的ArticleEditorController来承载创作中心接口不要和前台文章 Controller 混在一起。以下是关键代码// 文件路径src/main/java/com/example/blog/editor/controller/ArticleEditorController.java package com.example.blog.editor.controller; import com.example.blog.common.api.ApiResponse; import com.example.blog.common.exception.BizException; import com.example.blog.editor.dto.ArticleEditorDTO; import com.example.blog.editor.service.ArticleEditorService; import com.example.blog.security.util.SecurityUtils; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/editor/articles) RequiredArgsConstructor public class ArticleEditorController { private final ArticleEditorService articleEditorService; PostMapping public ApiResponseLong createDraft() { Long userId SecurityUtils.getCurrentUserId(); return ApiResponse.ok(articleEditorService.createDraft(userId)); } PutMapping(/{id}) public ApiResponseVoid updateArticle(PathVariable Long id, RequestBody ArticleEditorDTO dto) { Long userId SecurityUtils.getCurrentUserId(); articleEditorService.updateArticle(id, userId, dto); return ApiResponse.ok(); } PostMapping(/{id}/publish) public ApiResponseVoid publish(PathVariable Long id) { Long userId SecurityUtils.getCurrentUserId(); articleEditorService.publish(id, userId); return ApiResponse.ok(); } GetMapping(/{id}) public ApiResponseArticleEditorDTO getEditorDetail(PathVariable Long id) { Long userId SecurityUtils.getCurrentUserId(); return ApiResponse.ok(articleEditorService.getEditorDetail(id, userId)); } }注意这段代码中的SecurityUtils.getCurrentUserId()是从登录态中获取当前用户而不是信任前端传过来的 userId。这是多用户博客系统不能丢的底线。若你的项目还没有这样的工具类至少要在 Service 层用拦截器注入的用户信息做一次赋值。4.3 保存与发布时的状态流逻辑让 Service 层专注于业务规则。这里省略 DTO 的样板代码核心方法是updateArticle与publish// 文件路径src/main/java/com/example/blog/editor/service/impl/ArticleEditorServiceImpl.java package com.example.blog.editor.service.impl; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.example.blog.article.model.Article; import com.example.blog.article.model.ArticleStatus; import com.example.blog.article.mapper.ArticleMapper; import com.example.blog.common.exception.BizException; import com.example.blog.editor.dto.ArticleEditorDTO; import com.example.blog.editor.service.ArticleEditorService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; Service RequiredArgsConstructor public class ArticleEditorServiceImpl implements ArticleEditorService { private final ArticleMapper articleMapper; Override Transactional(rollbackFor Exception.class) public Long createDraft(Long userId) { Article article new Article(); article.setUserId(userId); article.setTitle(无标题); article.setStatus(ArticleStatus.DRAFT); articleMapper.insert(article); return article.getId(); } Override Transactional(rollbackFor Exception.class) public void updateArticle(Long id, Long userId, ArticleEditorDTO dto) { Article article getOwnArticle(id, userId); LambdaUpdateWrapperArticle updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(Article::getId, id) .eq(Article::getUserId, userId) .set(Article::getTitle, dto.getTitle()) .set(Article::getSummary, dto.getSummary()) .set(Article::getContentMd, dto.getContentMd()) .set(Article::getContentHtml, dto.getContentHtml()) .set(Article::getTags, dto.getTags()) .set(Article::getCoverImage, dto.getCoverImage()) // 只在草稿状态保存时更新updatedAt已发布文章也可以更新但不改变状态 .set(Article::getUpdatedAt, LocalDateTime.now()); if (ArticleStatus.ARCHIVED.equals(article.getStatus())) { throw new BizException(归档文章请先恢复为草稿再编辑); } articleMapper.update(null, updateWrapper); } Override Transactional(rollbackFor Exception.class) public void publish(Long id, Long userId) { Article article getOwnArticle(id, userId); if (article.getContentMd() null || article.getContentMd().trim().isEmpty()) { throw new BizException(正文不能为空); } LambdaUpdateWrapperArticle updateWrapper new LambdaUpdateWrapper(); updateWrapper.eq(Article::getId, id) .eq(Article::getUserId, userId); if (ArticleStatus.PUBLISHED.equals(article.getStatus())) { // 已发布文章再次发布视为更新内容不修改发布时间 updateWrapper.set(Article::getUpdatedAt, LocalDateTime.now()); } else { updateWrapper.set(Article::getStatus, ArticleStatus.PUBLISHED) .set(Article::getPublishedAt, LocalDateTime.now()) .set(Article::getUpdatedAt, LocalDateTime.now()); } articleMapper.update(null, updateWrapper); } }这段代码体现了两个重要原则所有更新操作都要求id userId两个条件同时命中。即使前端被人篡改、接口被绕过也无法改动别人的文章。已发布文章再次点发布不能重新覆盖published_at。首次发布时间应该是一个只增不改的业务字段。5. 前端路由与创作中心页面搭建5.1 路由设计创作中心是独立工作台建议放在/editor路径下编辑页通过/editor/articles/:id访问。代码示意// 文件路径src/router/index.js import EditorLayout from /views/editor/EditorLayout.vue import EditorArticle from /views/editor/EditorArticle.vue const routes [ { path: /editor, component: EditorLayout, meta: { requiresAuth: true }, children: [ { path: , name: EditorHome, component: () import(/views/editor/EditorHome.vue), meta: { title: 创作中心 } }, { path: articles/:id, name: EditorArticle, component: EditorArticle, meta: { title: 编辑文章, activeMenu: /editor } } ] } ]编辑页一定带/editor前缀这样和后端/api/editor/articles语义一致也可以在前端路由守卫里方便地区分“阅读类页面”和“创作类页面”。当你后续想在编辑页离开时弹出“内容未保存”提示只需要对EditorArticle做beforeRouteLeave拦截。5.2 页面布局与编辑器容器编辑页的 UI 布局建议保持一个三段式结构顶部工具栏存放标题输入与发布按钮中间留白给编辑器右侧或底部抽屉放摘要、标签、封面等扩展信息。先看一个页面骨架!-- 文件路径src/views/editor/EditorArticle.vue -- template div classeditor-page div classeditor-toolbar input v-modelform.title classtitle-input placeholder请输入文章标题 maxlength200 / div classtoolbar-actions el-button clickhandleSaveDraft保存草稿/el-button el-button typeprimary clickhandlePublish发布/el-button /div /div div classeditor-body MarkdownEditor v-modelform.contentMd updatehandleContentChange / /div el-drawer v-modelsettingVisible title文章设置 size360px el-form label-width80px el-form-item label摘要 el-input v-modelform.summary typetextarea / /el-form-item el-form-item label标签 el-input v-modelform.tags placeholder多个标签用逗号分隔 / /el-form-item el-form-item label封面 el-input v-modelform.coverImage placeholder图片URL / /el-form-item /el-form /el-drawer /div /template不要把 Markdown 编辑器直接写进页面里建议拆成独立组件。创建文章时先调用后端接口拿到空白草稿 id然后router.replace到/editor/articles/{id}这样浏览器刷新也能稳定停留在当前文章编辑页。5.3 API 封装前端所有请求统一走src/api目录方便拦截器统一注入 token 和统一解析错误码// 文件路径src/api/editor.js import request from /utils/request export function createDraft() { return request({ url: /api/editor/articles, method: post }) } export function updateArticle(id, data) { return request({ url: /api/editor/articles/${id}, method: put, data }) } export function publishArticle(id) { return request({ url: /api/editor/articles/${id}/publish, method: post }) } export function getArticleEditorDetail(id) { return request({ url: /api/editor/articles/${id}, method: get }) }这里把 publish 单独拆成接口是有意为之。发布不是“保存草稿的增强版”它要额外校验内容完整性、要更新发布时间、要触发后续可能的站点地图生成等逻辑。前端的兜底策略是点击发布前如果发现本地有未保存改动先执行保存草稿再执行发布请求。6. 富文本与 Markdown 编辑器接入要点6.1 编辑器选型Markdown 优先个人博客类场景里我更推荐 Markdown 编辑器而不是纯 HTML 富文本编辑器。原因是博客正文需要长期复用和移植用 Markdown 存储原文可以很方便地输出到前台 HTML、生成摘要、接入 AI 辅助润色。如果你在前台上用的是 Vue可以封装一个 Markdown 文本域组件像 wangEditor、tinymce 这类富文本编辑器虽然开箱即用但它们的 HTML 内容做安全清洗的成本更高。一个稳妥的 UI 思路是左侧 Markdown 编辑区右侧预览区或者用带预览的编辑器插件。组件内部可以这样接入 v-model!-- 文件路径src/components/MarkdownEditor/index.vue -- template div classmarkdown-editor textarea classeditor-textarea :valuemodelValue placeholder开始写作支持 Markdown 语法 inputhandleInput /textarea div classpreview-panel v-htmlpreviewHtml/div /div /template script setup import { computed } from vue import { marked } from marked const props defineProps({ modelValue: { type: String, default: } }) const emit defineEmits([update:modelValue]) const previewHtml computed(() { return marked(props.modelValue || ) }) function handleInput(event) { emit(update:modelValue, event.target.value) } /script6.2 内容安全的提醒如果你选择 Markdown 原生渲染需要注意 XSS 问题。marked默认会保留部分 HTML 标签而v-html会直接执行内容中的 HTML。在前台展示详情时不建议只做一次v-html输出建议在后端渲染内容时先对用户提交的 HTML 做白名单过滤或者使用 DOMPurify 之类的前端清洗库。发表的第一道校验也应该在后端完成内容长度、标题长度、标签数量都要限制。7. AI 辅助创作合理接入而不是喧宾夺主7.1 从人工写作到 AI 辅助的边界很多人看到“AI 博客系统”这五个字第一反应是让模型直接自动生成整篇文章。但如果真把整篇生成的内容发布成一个博客文章质量、事实准确性和风格稳定性都很难保障。实际体验更好的做法是把 AI 放在创作中心的“辅助位”帮助作者完成标题拟定、摘要提炼、文案润色这些重复性较强的动作而不是取代作者的核心创作。产品功能上可以先落地几个高频入口AI 生成标题把正文内容发给内部模型服务返回 3 到 5 个候选标题让作者点选一个填入标题输入框。AI 生成摘要根据正文提炼 100 字左右的摘要结果放入摘要字段。AI 润色正文对当前选中段落做润色生成结果放在暂存区作者确认后再替换。这样可以在不引入复杂状态机的情况下形成一个完整的“人机协作”闭环。7.2 后端预留 AI 接口不要把外部大模型的调用逻辑散落在 ArticleService 里。先定义一个统一的 AI 服务接口实现类里无论你是接第三方模型还是接企业内部网关Controller 和业务代码都不用改// 文件路径src/main/java/com/example/blog/editor/assist/AiAssistService.java package com.example.blog.editor.assist; public interface AiAssistService { String generateTitle(String contentMd); String generateSummary(String contentMd); }实际实现时推荐让这个 Service 通过RestTemplate或WebClient调用你内部配置的模型网关。不要在业务代码里出现具体的模型 URL 和 API Key而是放到配置中心或环境变量中# 文件路径src/main/resources/application.yml ai: assist: base-url: ${AI_ASSIST_BASE_URL:http://your-internal-gateway} connect-timeout: 3000 read-timeout: 30000需要特别说明的是这篇文章不会绑定任何特定大模型厂商因为这不是创作中心的技术核心。核心是后端先提供稳定的服务接口前端再提供暂存确认交互等模型服务稳定后再考虑流式输出或复杂 Agent 编排。7.3 前端 AI 面板的交互设计AI 生成的内容不能直接覆盖表单值应该放入一个“AI 结果暂存区”。下面是一个微缩示例!-- AI 辅助弹窗片段 -- script setup import { ref } from vue import { aiGenerateTitle, aiGenerateSummary } from /api/ai const titleSuggestions ref([]) const summarySuggestions ref() async function handleGenerateTitle() { const { data } await aiGenerateTitle(form.value.contentMd) titleSuggestions.value data } function applyTitle(title) { form.value.title title } function handleGenerateSummary() { // 生成摘要结果先展示在确认区域 const { data } await aiGenerateSummary(form.value.contentMd) summarySuggestions.value data } function applySummary() { form.value.summary summarySuggestions.value } /script这样设计背后的原因是模型输出不总是符合用户预期。直接替换正文会导致作者花费大量时间重新编辑而“先生成后点选确认”可以把模型不确定性控制在一个安全边界内也更容易获得作者对内容的信任。8. 前后端联调实战从新建到发布的完整链路8.1 请求顺序如果你照着本章写完接口和页面可以用这样一组步骤验证主链路创建草稿POST /api/editor/articles返回文章 id。保存内容PUT /api/editor/articles/{id}提交 title、summary、contentMd、contentHtml。再次进入编辑GET /api/editor/articles/{id}判断页面能否回填内容。发布文章POST /api/editor/articles/{id}/publish。前台拉取确认文章列表接口只能看到 statusPUBLISHED 的数据。8.2 用 curl 快速验证后端在页面联调之前建议直接用 curl 验证接口链路。下面的命令把当前用户的 token 放在环境变量中方便你在不同环境复用# 1. 创建空白草稿 curl -X POST http://localhost:8080/api/editor/articles \ -H Authorization: Bearer ${YOUR_TOKEN} # 假设返回的 articleId 100保存草稿 curl -X PUT http://localhost:8080/api/editor/articles/100 \ -H Authorization: Bearer ${YOUR_TOKEN} \ -H Content-Type: application/json \ -d { title: 我的第一篇创作中心测试, summary: 这是摘要, contentMd: # 你好SpringBoot Vue, contentHtml: h1你好SpringBoot Vue/h1, tags: SpringBoot,Vue } # 2. 发布文章 curl -X POST http://localhost:8080/api/editor/articles/100/publish \ -H Authorization: Bearer ${YOUR_TOKEN}成功后你可以在数据库里看到这条 article 记录的 status 变成了 1同时 published_at 有值。8.3 验证点清单建议你做一个本地验证表逐项打勾验证动作预期结果未登录访问 /editor路由守卫跳转登录页新建空白草稿返回新的 articleIdstatus0保存草稿后刷新页面编辑页重新拉取内容成功用他人 token 修改文章返回 403 或业务异常不能越权正文为空时发布后端返回“正文不能为空”状态仍为草稿已发布文章再次发布接口成功但 published_at 不变9. 常见问题与排查方法问题现象可能原因排查方式解决方案保存草稿后刷新页面正文丢失调用 PUT 接口失败前端没有做失败提示打开浏览器 Network 面板查看 PUT 请求状态码优先处理后端异常再补前端错误提示发布按钮没反应发布接口因为正文为空被拦截查看后端日志确认是否有 BizException前端在发布前用非空校验给出提示编辑别人的文章也能成功Controller 或 Service 没有按 userId 过滤检查 SQL where 条件是否包含 user_id修改 update 条件为 id userId前台详情页出现 HTML 标签只存了 contentHtml没有对用户输入做白名单清洗查看 DB 中 contentHtml 值引入 HTML 清洗库或后端过滤已发布文章重新编辑后前台没变化更新了草稿记录但没有重新发布检查状态是否还是 PUBLISHED更新已发布文章后需要触发 publish 接口页面显示“未找到文章”查询接口过滤了非当前用户数据查看登录态是否正确接口是否带 token检查路由守卫与请求拦截器第一次发布时 published_at 为空发布逻辑里未更新 published_at查看 publish SQL 语句在首次发布时显式写入当前时间AI 面板返回超时模型服务响应速度慢普通 HTTP 等待时间长查看调用链日志确认耗时先使用异步任务或调大 read-timeout不建议在同步请求里等待过久10. 最佳实践与工程建议10.1 数据一致性与权限安全创作中心是典型的“写多读少”的后台模块建议所有写操作都开启事务并把校验放到事务内部。SpringBoot 里使用Transactional时要注意只在 Service 方法上加注解不要在 Controller 上加否则事务边界不好控制。权限安全方面要注意几个细节不要从请求体里读取 userId 来设置作者必须从登录态中获取。更新语句永远使用id userId双条件。文章列表查询默认带上status条件避免误把删除状态数据返回给前端。如果使用逻辑删除记得在数据库表加 deleted 字段或在状态中处理不要直接物理删除用户内容。10.2 编辑器体验与自动保存创作中心真正的用户体验瓶颈往往是“怕丢失”。建议前端实现自动保存策略用户停止输入 3 到 5 秒后自动触发一次保存草稿请求。如果对服务端压力有顾虑可以用节流函数控制频率。一个简单的节流保存思路// src/utils/debounce.js export function debounce(fn, delay 2000) { let timer null return function (...args) { if (timer) clearTimeout(timer) timer setTimeout(() fn.apply(this, args), delay) } }在 Vue 组件中监听编辑器输入把保存函数包装成 debounce 方法。但要注意自动保存不能替代手动“保存草稿”按钮因为某些用户在写完一个重要段落后会希望立刻落库。自动保存更像是给用户的一层保险而不是唯一的保存手段。10.3 图片上传与批量素材管理创作中心还应该处理图片上传。后端提供一个/api/editor/upload接口限制文件类型与大小并返回图片访问 URL。前端在上传过程中可以先显示本地预览得到 URL 后再插入 Markdown 编辑器。不要直接把图片 base64 塞进 Markdown 原文否则一篇文章可能几十万字符数据库性能会被拖垮。文件上传建议单独走一个对象存储服务或独立静态资源目录不要把图片文件直接存在 SpringBoot 项目运行目录的临时位置这样在重启或部署到云端后容易丢。10.4 扩展方向从创作中心到发布流水线做完当前功能后可以沿着这些方向继续深入草稿历史版本每编辑一次记录一个快照作者可以回滚到指定版本。定时发布将发布按钮扩展为定时任务到点自动把草稿置为 PUBLISHED。内容审核如果你的项目存在管理端可以在草稿与发布之间插入“待审核”状态。AI 能力增强把 AI 生成接口升级为流式输出在创作中心以对话浮窗形态提供更多交互。SEO 联动发布文章后再触发生成站点地图或推送搜索收录这是博客系统商业化后的关键一环。这些扩展方向不需要一开始全做。先把“从新建到发布”的最小链路跑稳把状态模型理解透后续每一项扩展都只是在这个状态机上增加新的处理器。创作中心这一讲的完整度决定了整个博客系统给人的“工具感”。用一个能保草稿、能安全发布、能区分状态、能承接 AI 辅助能力的模块作为 SpringBoot Vue 前后端分离项目的一环你后续再往系统里加其他内容类型比如问答、专栏、短动态都可以复用这套编辑-存储-发布的状态模型。
RELATED READING

延伸阅读

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