news 2026/9/5 17:46:05

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SpringBoot+Vue的博客创作中心实战:草稿到发布的状态管理

很多人做博客系统,容易把重心全放在“文章列表页”和“详情页”的展示效果上,等做到“创作中心”时反而会犹豫:这不就是一个富文本编辑器加一个保存按钮吗?但实际上,创作中心才是一个博客系统里用户停留时间最长、状态逻辑最复杂的模块。

在第七十九讲这个节点上,项目骨架、用户模块、文章列表、详情页大概率都已经稳定了。之所以单独把“创作中心”拆成一讲,是因为这个模块承担的是“内容生产”职责。前台页面再好看,作者写得不顺手、流程有断裂,系统就少人用。本文会先说明创作中心的功能边界与状态模型,再基于 SpringBoot + Vue 前后端分离架构给出后端接口、前端页面、编辑器接入、AI 辅助创作占位实现,以及联调验证的完整闭环。

先说一个明确判断:创作中心不是整站里业务最重的模块,而是前后端交互最密集的模块。它的难点不在某个单一接口,而在“草稿—编辑—发布—再编辑”这条状态链的完整性,以及 Markdown / 富文本内容在不同端之间渲染的一致性。这一讲会把这条链路讲透。

1. 创作中心在整个博客系统中的定位

1.1 为什么这一讲值得认真做

很多初学前后端分离的开发者对“创作中心”的理解是:左侧一个分类树,中间一个编辑器,右侧一个设置栏,放个标题输入框和发布按钮就行。如果只是做一个课程练习,这样确实够了;但放到真实项目中,创作中心直接影响用户是否愿意持续产出内容。

从产品层面看,创作中心承担了三件事:

  1. 内容录入:支持 Markdown 或富文本编辑。
  2. 状态管理:草稿能保存、能预览、能随时回到上一次编辑。
  3. 发布控制:首次发布、二次编辑后的更新,以及下线/归档操作。

从技术层面看,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 创作中心的核心操作流程

一个完整的创作流程应该这样走:

  1. 进入“新建文章”,前端先创建一份本地草稿或在后端创建 DRAFT 记录。
  2. 用户在编辑器里写标题、正文、标签和摘要。
  3. 点击保存,内容完整提交到后端,后端更新 DRAFT。
  4. 点击发布,后端做字段校验和权限校验后更新为 PUBLISHED。
  5. 发布后再次进入编辑页,拉取最新正文,保存变更。
  6. 如果页面下线,状态改为 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文章表';

这里有两个容易忽略的点:

  1. user_idstatus建联合索引,后台“我写的文章”默认都通过这两个条件查询,能够避开全表扫描。
  2. 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?status=DRAFT查询自己的文章列表本人
POST/api/editor/upload上传图片登录

新建草稿和保存草稿建议分开。新建时只插入一条 status=0 的空白记录,页面拿到文章 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 ApiResponse<Long> createDraft() { Long userId = SecurityUtils.getCurrentUserId(); return ApiResponse.ok(articleEditorService.createDraft(userId)); } @PutMapping("/{id}") public ApiResponse<Void> updateArticle(@PathVariable Long id, @RequestBody ArticleEditorDTO dto) { Long userId = SecurityUtils.getCurrentUserId(); articleEditorService.updateArticle(id, userId, dto); return ApiResponse.ok(); } @PostMapping("/{id}/publish") public ApiResponse<Void> publish(@PathVariable Long id) { Long userId = SecurityUtils.getCurrentUserId(); articleEditorService.publish(id, userId); return ApiResponse.ok(); } @GetMapping("/{id}") public ApiResponse<ArticleEditorDTO> getEditorDetail(@PathVariable Long id) { Long userId = SecurityUtils.getCurrentUserId(); return ApiResponse.ok(articleEditorService.getEditorDetail(id, userId)); } }

注意这段代码中的SecurityUtils.getCurrentUserId()是从登录态中获取当前用户,而不是信任前端传过来的 userId。这是多用户博客系统不能丢的底线。若你的项目还没有这样的工具类,至少要在 Service 层用拦截器注入的用户信息做一次赋值。

4.3 保存与发布时的状态流逻辑

让 Service 层专注于业务规则。这里省略 DTO 的样板代码,核心方法是updateArticlepublish

// 文件路径: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); LambdaUpdateWrapper<Article> 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("正文不能为空"); } LambdaUpdateWrapper<Article> 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); } }

这段代码体现了两个重要原则:

  1. 所有更新操作都要求id + userId两个条件同时命中。即使前端被人篡改、接口被绕过,也无法改动别人的文章。
  2. 已发布文章再次点发布,不能重新覆盖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语义一致,也可以在前端路由守卫里方便地区分“阅读类页面”和“创作类页面”。当你后续想在编辑页离开时弹出“内容未保存”提示,只需要对EditorArticlebeforeRouteLeave拦截。

5.2 页面布局与编辑器容器

编辑页的 UI 布局建议保持一个三段式结构:顶部工具栏存放标题输入与发布按钮,中间留白给编辑器,右侧或底部抽屉放摘要、标签、封面等扩展信息。先看一个页面骨架:

<!-- 文件路径:src/views/editor/EditorArticle.vue --> <template> <div class="editor-page"> <div class="editor-toolbar"> <input v-model="form.title" class="title-input" placeholder="请输入文章标题" maxlength="200" /> <div class="toolbar-actions"> <el-button @click="handleSaveDraft">保存草稿</el-button> <el-button type="primary" @click="handlePublish">发布</el-button> </div> </div> <div class="editor-body"> <MarkdownEditor v-model="form.contentMd" @update="handleContentChange" /> </div> <el-drawer v-model="settingVisible" title="文章设置" size="360px"> <el-form label-width="80px"> <el-form-item label="摘要"> <el-input v-model="form.summary" type="textarea" /> </el-form-item> <el-form-item label="标签"> <el-input v-model="form.tags" placeholder="多个标签用逗号分隔" /> </el-form-item> <el-form-item label="封面"> <el-input v-model="form.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 class="markdown-editor"> <textarea class="editor-textarea" :value="modelValue" placeholder="开始写作,支持 Markdown 语法" @input="handleInput" ></textarea> <div class="preview-panel" v-html="previewHtml"></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) } </script>

6.2 内容安全的提醒

如果你选择 Markdown 原生渲染,需要注意 XSS 问题。marked默认会保留部分 HTML 标签,而v-html会直接执行内容中的 HTML。在前台展示详情时,不建议只做一次v-html输出,建议在后端渲染内容时先对用户提交的 HTML 做白名单过滤,或者使用 DOMPurify 之类的前端清洗库。发表的第一道校验也应该在后端完成:内容长度、标题长度、标签数量都要限制。

7. AI 辅助创作:合理接入而不是喧宾夺主

7.1 从人工写作到 AI 辅助的边界

很多人看到“AI 博客系统”这五个字,第一反应是让模型直接自动生成整篇文章。但如果真把整篇生成的内容发布成一个博客,文章质量、事实准确性和风格稳定性都很难保障。实际体验更好的做法是:把 AI 放在创作中心的“辅助位”,帮助作者完成标题拟定、摘要提炼、文案润色这些重复性较强的动作,而不是取代作者的核心创作。

产品功能上可以先落地几个高频入口:

  1. AI 生成标题:把正文内容发给内部模型服务,返回 3 到 5 个候选标题,让作者点选一个填入标题输入框。
  2. AI 生成摘要:根据正文提炼 100 字左右的摘要,结果放入摘要字段。
  3. 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 通过RestTemplateWebClient调用你内部配置的模型网关。不要在业务代码里出现具体的模型 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 请求顺序

如果你照着本章写完接口和页面,可以用这样一组步骤验证主链路:

  1. 创建草稿:POST /api/editor/articles,返回文章 id。
  2. 保存内容:PUT /api/editor/articles/{id},提交 title、summary、contentMd、contentHtml。
  3. 再次进入编辑:GET /api/editor/articles/{id},判断页面能否回填内容。
  4. 发布文章:POST /api/editor/articles/{id}/publish
  5. 前台拉取:确认文章列表接口只能看到 status=PUBLISHED 的数据。

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路由守卫跳转登录页
新建空白草稿返回新的 articleId,status=0
保存草稿后刷新页面编辑页重新拉取内容成功
用他人 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 扩展方向:从创作中心到发布流水线

做完当前功能后,可以沿着这些方向继续深入:

  1. 草稿历史版本:每编辑一次记录一个快照,作者可以回滚到指定版本。
  2. 定时发布:将发布按钮扩展为定时任务,到点自动把草稿置为 PUBLISHED。
  3. 内容审核:如果你的项目存在管理端,可以在草稿与发布之间插入“待审核”状态。
  4. AI 能力增强:把 AI 生成接口升级为流式输出,在创作中心以对话浮窗形态提供更多交互。
  5. SEO 联动:发布文章后,再触发生成站点地图或推送搜索收录,这是博客系统商业化后的关键一环。

这些扩展方向不需要一开始全做。先把“从新建到发布”的最小链路跑稳,把状态模型理解透,后续每一项扩展都只是在这个状态机上增加新的处理器。

创作中心这一讲的完整度,决定了整个博客系统给人的“工具感”。用一个能保草稿、能安全发布、能区分状态、能承接 AI 辅助能力的模块,作为 SpringBoot + Vue 前后端分离项目的一环,你后续再往系统里加其他内容类型,比如问答、专栏、短动态,都可以复用这套编辑-存储-发布的状态模型。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 17:45:48

Unity热更新实践:基于HybridCLR的C#热更接入全流程解析

做Unity这么多年&#xff0c;几乎每年都会遇到一次“要不要上热更新”的争论。尤其是线上Bug修复要等包体审核、版本覆盖周期长、玩家一听说又要重新下载几百兆安装包就骂娘的时候&#xff0c;热更新几乎是绕不开的刚需。这两年C#项目的热更方案里&#xff0c;HybridCLR属于热度…

作者头像 李华
网站建设 2026/9/5 17:38:17

纯前端Canvas打字游戏开发实战:从零到上线的完整工程指南

1. 项目概述&#xff1a;从零开始做一款网页游戏1.1 核心需求解析先说结论&#xff1a;我给自己定了一个目标——不用任何游戏引擎&#xff0c;不写一行后端代码&#xff0c;用纯前端技术在两周内做出一款能上线、能让别人打开浏览器就能玩的网页游戏。最后我做出来的是一款打字…

作者头像 李华
网站建设 2026/9/5 17:37:50

从零开发HTML5打砖块游戏:独立开发者的完整实践

1. 一个念头怎么变成一份可执行的需求文档先说一个很多新人容易忽略的事实&#xff1a;做游戏最难的不是写代码&#xff0c;而是把脑子里那个模糊的“好玩”变成一个具体到能动手的东西。我当时的念头特别简单——想做一个不用下载、打开浏览器就能玩的小游戏&#xff0c;能自己…

作者头像 李华
网站建设 2026/9/5 17:23:56

AI游戏开发核心指南:NVIDIA ACE与引擎技术演进全解析

去年年底我帮一个朋友看他做的独立demo&#xff0c;他花了大半年时间搭了一个开放世界的底子&#xff0c;地图、战斗、任务系统都像模像样。我问他NPC做得怎么样了&#xff0c;他苦笑着说了句让我印象特别深的话&#xff1a;“我能让一千个NPC活在地图上&#xff0c;但没法让一…

作者头像 李华