十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SpringBoot+Vue博客创作中心:草稿发布与AI摘要实践

SpringBoot+Vue博客创作中心:草稿发布与AI摘要实践 在实际的 SpringBoot Vue 前后端分离博客项目中创作中心往往不等于“一个写文章的页面”。它需要同时处理草稿、发布、标签、封面、AI 辅助摘要以及创作者登录后的身份隔离。到了博客系统的“创作中心”这一讲最容易出现的状况是前端页面能打开但草稿保存不了、文章列表状态错乱、编辑器提交的数据到后端就丢换行。这篇文章会围绕创作中心从数据模型、后端接口、前端页面到联调验证拆开讲面向已经完成博客登录认证和前台文章展示的开发者目标是让读者能自己实现一个“保存草稿、发布文章、调用 AI 辅助能力”的最小创作中心闭环并知道上线前还要补哪些工程化能力。1. 创作中心不是“写文章的页面”而是内容生产状态机1.1 先分清前台门户与创作中心的边界很多博客项目早期会把“发表文章”直接写在前台列表页旁边用户点了就跳到一个表单页。前台只有两个动作新增和保存。这种方式在展示系统里能跑通一旦引入草稿、审核、定时发布、创作者权限代码就会变得很混乱。创作中心与前台门户的边界必须先从职责上分开。前台门户负责“读”核心接口是公开文章详情、公开分类列表、公开标签列表数据状态通常已经是“已发布”。创作中心负责“写”核心操作者是登录用户或博主数据状态包括草稿和已发布后续还可能扩展到待审核、定时发布、下线等。文章表可以只有一张但 API 入口和业务逻辑不能混在同一个 Controller 里否则以后加一个认证判断就可能影响前台文章访问。在模型上这个边界可以理解为门户模块只查询状态为“已发布”的数据并做缓存、SEO、阅读量等优化创作中心模块维护当前用户自己创建的文章支持查询、编辑、删除并在这些操作中绑定用户 ID。用户 A 的文章不能让用户 B 改这就是创作中心的隔离底线。1.2 文章状态机草稿、待审核与已发布应该怎样流转创作中心里文章最核心的字段不是标题也不是正文而是状态。状态决定了文章能否被门户页面查到也决定了列表页展示“草稿”还是“已发布”。如果不把状态流转讲清楚前端就会反复用 if else 写显示逻辑后端也会出现“状态为 0 的文章也被门户接口查出来”的问题。在常见博客系统中文章状态建议至少包含这几个阶段status 值状态含义门户口径创作中心操作0草稿不可见可编辑、可删除、可提交发布1待审核可选不可见只读等待审核结果可撤回2已发布可见可下线可再次编辑为草稿或更新后保持发布3已下线不可见可重新发布如果简化开发可以只保留 0 和 2 两个状态提交保存时前端把 status 置为 0 就创建草稿点击发布按钮就把 status 置为 2。但考虑到真实博客平台往往有内容审核环节我的建议是一开始就预留“待审核”状态。这里要注意的是不要把审核状态塞进文章保存接口里让前台同步等待文章发布后如果要走审核应该把状态改成待审核并投递一条异步审核任务。保存文章和审核文章是两条链路。1.3 本讲功能范围与技术主线基于前面课程已经搭建出来的 SpringBoot 后端和 Vue 前端本讲要完成的是这样一条技术主线后端提供一套/api/creator/articles开头的 REST 接口支持当前用户查看自己的文章列表、按状态筛选、保存草稿、发布文章、删除文章以及一个可选的 AI 摘要生成能力。前端增加一个创作中心模块包括文章管理列表和文章编辑器页面。在这条主线里需要重点解释四个工程问题为什么 status 用数字而不是字符串。为什么创建文章接口要区分“保存草稿”和“直接发布”参数。为什么编辑器内容需要保存原始 Markdown 和渲染后的 HTML 两份数据。为什么 AI 辅助生成摘要不能同步阻塞文章保存接口。后面的章节会围绕这几点逐步展开。你不需要一次把权限、审核、定时发布全部做出来但通过完整实现“草稿到发布”这条链路后续扩展就会容易得多。2. 数据模型一张文章表撑起创作中心先说清楚每个字段2.1 在已有文章表上扩展字段而不是新建内容表如果整个博客系统已经有一张文章表blog_article创建中心不一定要再建一张“创作文章表”。更合理的做法是在原表上补齐创作中心需要的字段。这能避免前台详情页和创作中心操作同一篇文章时要跨表映射。创作中心相比简单博客文章表至少会多出这些信息草稿状态、AI 摘要生成状态、AI 生成摘要标记、发布时间、最后更新时间。草稿状态下发布时间可能为空发布完成后才写入发布时间。这些字段表示的是“文章在内容生产管线中的位置”不是文章本身的内容。2.2 建表 DDL 与索引下面这段 DDL 可以在 MySQL 8.0 中直接执行用来表达创作中心的文章表结构。字段名称可以按团队命名规范调整但核心含义要保持一致。CREATE TABLE blog_article ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, user_id BIGINT NOT NULL COMMENT 创作者用户ID, title VARCHAR(200) NOT NULL DEFAULT COMMENT 标题, summary VARCHAR(500) NOT NULL DEFAULT COMMENT 摘要空字符串表示暂未填写, markdown_content MEDIUMTEXT COMMENT Markdown 原始内容编辑器写入, html_content MEDIUMTEXT COMMENT 渲染后的 HTML前台展示与门户缓存使用, cover_url VARCHAR(500) NOT NULL DEFAULT COMMENT 封面图地址, category_id BIGINT DEFAULT NULL COMMENT 分类ID, tags VARCHAR(500) NOT NULL DEFAULT COMMENT 标签多个标签用逗号分隔, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿1待审核2已发布3已下线, ai_generated_summary TINYINT NOT NULL DEFAULT 0 COMMENT 摘要是否由AI生成0否1是, ai_generate_status TINYINT NOT NULL DEFAULT 0 COMMENT AI辅助状态0未开始1生成中2生成成功3生成失败, published_at DATETIME DEFAULT NULL COMMENT 发布时间保存为草稿时为空, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_user_status_created (user_id, status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci COMMENT博客文章表;这里要说明三个细节。第一markdown_content和html_content为什么要存两份。编辑器每次修改都基于原始 Markdown前台展示时如果只存 HTML后续重新编辑会丢失源格式如果只存 Markdown每次打开详情页都重新渲染一遍性能不理想。实际项目里比较常见的是双写编辑器提交原始内容后端负责按规则渲染成 HTML门户查询直接返回 HTML。也可以由前端完成渲染后再提交 HTML但后端要做好清理和防 XSS 处理。第二MEDIUMTEXT能存大约 16MB 文本对博客文章足够。超大文章虽然少见但如果编辑器支持本地图片转 base64 插入内容可能很快膨胀。生产环境一般会做“图片上传到对象存储正文里只保存外链”的约束避免把大量 base64 数据写进数据库。第三索引选择(user_id, status, created_at)是因为创作中心最常见查询是“某个用户、某个状态下按时间倒序的文章列表”。如果查询条件还会加 categoryId可以再补一个联合索引或交给应用层控制不必一开始建太多索引。2.3 status 用整型而不是字符串的原因状态字段建议用TINYINT而不是VARCHAR。整型的好处不止是省空间更重要的是避免“草稿”“DRAFT”“draft”这类写法不一致造成的问题。用数字存储时前后端约定一套数字映射即可。前端列表页根据数字展示文字标签后端查询直接用等值匹配排序、统计也方便。对应的映射关系可以通过常量类或枚举类维护在同一处避免散落在 SQL 字符串里。对应的 Java 常量类可以这样写public final class ArticleStatus { private ArticleStatus() { } public static final int DRAFT 0; public static final int PENDING 1; public static final int PUBLISHED 2; public static final int OFFLINE 3; }这里的“草稿”是一个业务状态不是数据库里某一列是否为空就能表达清楚的。有些团队会把草稿设计成published_at is null这样看起来可行但一旦加入待审核、定时发布判断逻辑会越来越复杂。状态单独一列负责流程发布时间单独一列负责时间语义职责更清晰。3. 后端 SpringBoot把创作中心拆成清楚的三层3.1 工程包结构和 Mapper 约定后端代码包结构建议按“接口入口、业务处理、数据访问、对象模型”分层。不要在一个 Controller 里塞大量 SQL 逻辑否则后面加 AI 能力、加审核状态会非常痛苦。下面是一个适合本项目的包结构示例com.example.blog ├── controller │ └── creator │ ├── ArticleCreatorController.java │ └── ArticleAiAssistController.java ├── service │ ├── ArticleCreatorService.java │ └── AiAssistService.java ├── mapper │ └── ArticleMapper.java ├── model │ ├── entity │ │ └── Article.java │ ├── dto │ │ ├── ArticleSaveCommand.java │ │ ├── ArticlePageQuery.java │ │ └── ArticleVO.java │ └── enums │ └── ArticleStatus.javaMapper 层可以使用 MyBatis-Plus 的BaseMapper也可以使用原生 MyBatis。关键是把 SQL 和业务解耦。下面示例以 MyBatis-Plus 风格出现但不代表必须使用具体以项目现有持久层框架为准。public interface ArticleMapper extends BaseMapperArticle { }在已有博客系统中原来可能已经有ArticleMapper并承担前台查询。创作中心复用同一个 Mapper 没有问题但接口里的方法要尽量语义清晰。例如selectPageByUserId、selectByIdAndUserId不只是按 id 查而是“按当前用户 id 查”这是创作中心权限控制的第一道防线。3.2 Controller RESTful 接口定义创作中心相关的接口统一放在/api/creator/articles路径下避免与门户文章的/api/articles路径混淆。接口设计建议如下方法路径用途参数GET/api/creator/articles当前用户文章分页userId 由 token 解析query 带 status、page、pageSizeGET/api/creator/articles/{id}当前用户文章详情path 带文章IDPOST/api/creator/articles新建文章或保存草稿body 传 ArticleSaveCommandPUT/api/creator/articles/{id}更新文章path 带文章IDbody 传 ArticleSaveCommandPOST/api/creator/articles/{id}/publish发布文章path 带文章IDDELETE/api/creator/articles/{id}删除文章path 带文章IDPOST/api/creator/articles/{id}/ai/summary生成 AI 摘要body 传标题和正文参考这里的关键是创建和更新可以共用一个ArticleSaveCommand但保存的目的需要靠额外标识区分。可以让前端传status0表示保存草稿点发布按钮时单独调用/publish接口。不要把“保存并发布”做在同一个参数里的模糊判断里。Controller 接口示例RestController RequestMapping(/api/creator/articles) public class ArticleCreatorController { private final ArticleCreatorService articleCreatorService; public ArticleCreatorController(ArticleCreatorService articleCreatorService) { this.articleCreatorService articleCreatorService; } GetMapping public PageResultArticleListItemVO page(ArticlePageQuery query, RequestAttribute(loginUserId) Long userId) { return articleCreatorService.page(query, userId); } GetMapping(/{id}) public ArticleVO detail(PathVariable Long id, RequestAttribute(loginUserId) Long userId) { return articleCreatorService.detail(id, userId); } PostMapping public Long saveDraft(RequestBody ArticleSaveCommand command, RequestAttribute(loginUserId) Long userId) { return articleCreatorService.saveDraft(command, userId); } PostMapping(/{id}/publish) public void publish(PathVariable Long id, RequestAttribute(loginUserId) Long userId) { articleCreatorService.publish(id, userId); } DeleteMapping(/{id}) public void delete(PathVariable Long id, RequestAttribute(loginUserId) Long userId) { articleCreatorService.delete(id, userId); } }登录用户 ID 这里通过RequestAttribute获取。在真实的 SpringBoot 项目中一般由一个认证拦截器解析 token 后把 userId 放入 request attributeController 不需要关心 token 的解析细节。这样既保持了接口干净也方便在测试里直接构造 request attribute。3.3 Service 层保存草稿与发布ArticleCreatorService是本讲后端部分的核心。它要解决的问题有两类一类是普通 CRUD另一类是业务规则。例如 detail 时必须校验“这篇文章是不是当前用户的”publish 时必须校验标题不能为空、正文不能为空、文章状态是否是草稿或待审核delete 时要判断文章是否属于当前用户。保存草稿的流程可以拆成四步根据当前登录用户和 command 中的 id查询已有文章判断是否存在。判断是否允许修改草稿可改已发布的文章如果允许再次编辑也要明确是生成新版本还是直接覆盖。教学项目可从直接覆盖草稿和已发布文章开始。补全字段htmlContent 由 markdownContent 渲染tags 用逗号拼接status 置为 0。执行更新或新增。下面是 Service 的示例实现Service public class ArticleCreatorService { private final ArticleMapper articleMapper; public ArticleCreatorService(ArticleMapper articleMapper) { this.articleMapper articleMapper; } Transactional(rollbackFor Exception.class) public Long saveDraft(ArticleSaveCommand command, Long userId) { Article article; if (command.getId() ! null) { article articleMapper.selectOne( new LambdaQueryWrapperArticle() .eq(Article::getId, command.getId()) .eq(Article::getUserId, userId)); if (article null) { throw new BizException(文章不存在或无权操作); } } else { article new Article(); article.setUserId(userId); article.setStatus(ArticleStatus.DRAFT); } article.setTitle(command.getTitle()); article.setSummary(command.getSummary()); article.setMarkdownContent(command.getMarkdownContent()); article.setHtmlContent(renderMarkdown(command.getMarkdownContent())); article.setCoverUrl(command.getCoverUrl()); article.setCategoryId(command.getCategoryId()); article.setTags(String.join(,, command.getTagList())); article.setAiGeneratedSummary(command.getAiGeneratedSummary()); if (command.getId() null) { articleMapper.insert(article); } else { articleMapper.updateById(article); } return article.getId(); } Transactional(rollbackFor Exception.class) public void publish(Long id, Long userId) { Article article getOwnedArticle(id, userId); if (article.getTitle() null || article.getTitle().trim().isEmpty()) { throw new BizException(标题不能为空); } if (article.getMarkdownContent() null || article.getMarkdownContent().trim().isEmpty()) { throw new BizException(正文内容不能为空); } article.setStatus(ArticleStatus.PUBLISHED); article.setPublishedAt(new Date()); articleMapper.updateById(article); } private Article getOwnedArticle(Long id, Long userId) { Article article articleMapper.selectOne( new LambdaQueryWrapperArticle() .eq(Article::getId, id) .eq(Article::getUserId, userId)); if (article null) { throw new BizException(文章不存在或无权操作); } return article; } private String renderMarkdown(String markdown) { // 这里用本项目的 Markdown 渲染工具实现 // 如果编辑器直接提交了 HTML则这里只做清理不重复渲染 return markdownProcessor.toHtml(markdown); } }这段代码看起来简单但有几个关键判断值得注意。第一个是为什么saveDraft里要一遍遍通过 userId 去过滤查询。因为如果不加.eq(Article::getUserId, userId)用户只要知道别人的文章 ID就可能通过修改接口覆盖别人的文章。这是常见的越权漏洞不能只在前端隐藏入口。第二个是renderMarkdown不能直接拼字符串返回到前台。Markdown 转 HTML 之后必须做内容安全处理例如删除script、onerror事件属性等。否则用户发布一篇文章时插入一段恶意脚本所有访问者都会受影响。这个在后面第 7 节还会展开。第三个是publish时要不要判断当前状态。如果从草稿发布直接把 status 改成发布即可。如果从已发布状态再次编辑后发布可以走同样的逻辑但应更新发布时间还是保留原发布时间需要根据业务约定。通常“重新发布”会更新时间或者另加一个updatedAt记录修改时间避免误判发布时间。3.4 AI 辅助接口如何设计才不拖慢主流程AI 创作中心里常见的需求是根据用户标题和正文生成摘要或者推荐文章标签。这部分能力很容易踩坑比如让用户点“自动生成摘要”后前端一直等待 AI 接口返回如果模型服务超时用户连文章都保存不了。更稳妥的做法是把 AI 辅助请求从“保存文章链路”中拆出来。核心思想是AI 摘要生成是一种异步增强能力后端收到请求后立即把ai_generate_status置为 1生成中然后放入线程池或消息队列。模型服务返回结果后再更新摘要字段和生成状态。前端可以通过轮询查询文章详情中的ai_generate_status来判断是否生成完成。如果课程还没有接入消息队列可以先使用一个有界线程池模拟异步调用。但要注意线程池的队列长度和拒绝策略生产环境后续可以换成消息队列。定义模型服务客户端接口时不要直接绑定到某个具体模型产品。这里建议抽象一层public interface AiAssistClient { AiSummaryResult generateSummary(AiSummaryRequest request); }本地演示时可以使用一个只返回固定内容的 Mock 实现Component public class MockAiAssistClient implements AiAssistClient { Override public AiSummaryResult generateSummary(AiSummaryRequest request) { // 模拟模型处理耗时避免前端以为是同步阻塞 try { Thread.sleep(800); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return new AiSummaryResult(这是一篇关于 request.getTitle() 的文章 主要讨论创作中心的数据模型、接口设计和前后端联调要点。); } }对应 AI 辅助 Service 可以这样写Service public class AiAssistService { private final ArticleMapper articleMapper; private final AiAssistClient aiAssistClient; private final Executor aiExecutor; public AiAssistService(ArticleMapper articleMapper, AiAssistClient aiAssistClient) { this.articleMapper articleMapper; this.aiAssistClient aiAssistClient; this.aiExecutor new ThreadPoolExecutor( 2, 4, 30, TimeUnit.SECONDS, new ArrayBlockingQueue(20), new ThreadPoolExecutor.AbortPolicy()); } public void asyncGenerateSummary(GenerateSummaryCommand command, Long userId) { Article article articleMapper.selectOne( new LambdaQueryWrapperArticle() .eq(Article::getId, command.getArticleId()) .eq(Article::getUserId, userId)); if (article null) { throw new BizException(文章不存在或无权操作); } article.setAiGenerateStatus(1); articleMapper.updateById(article); aiExecutor.execute(() - { try { AiSummaryResult result aiAssistClient.generateSummary( new AiSummaryRequest(article.getTitle(), article.getMarkdownContent())); article.setSummary(result.getSummary()); article.setAiGeneratedSummary(1); article.setAiGenerateStatus(2); articleMapper.updateById(article); } catch (Exception e) { article.setAiGenerateStatus(3); articleMapper.updateById(article); } }); } }这样设计的好处是保存草稿和发布文章不依赖 AI 服务的可用性。即使模型接口超时用户仍能先把文章保存下来AI 摘要状态是独立的不会影响已经完成的创作。3.5 参数校验与错误码创作中心涉及用户输入较多接口需要做基础校验。前端校验只是体验优化后端校验才是底线。至少校验这些字段title 不能为空建议限制最长 200 个字符。markdownContent 可以为空吗保存草稿时可以为空发布时必须不为空。tags 数量控制避免用户写入几百个标签。coverUrl 如果是对象存储地址校验 http/https 前缀。page 和 pageSize 需要限制范围pageSize 不建议超过 100。错误码可以通过统一结果包装类返回。示例public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT result new Result(); result.code 0; result.message success; result.data data; return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.code code; result.message message; return result; } }业务异常通过全局异常处理器转换建议不要把 SQL 异常和空指针堆栈直接返回给前端。常见的异常提示保留给用户完整堆栈写进日志服务。4. 前端 Vue从列表到编辑器再到 AI 操作的完整交互4.1 前端模块与路由创作中心在 Vue 项目中应当作为一个独立模块而不是把写文章页面挂到首页路由下。对应目录结构可以规划为src └── views └── creator ├── article │ ├── ArticleList.vue │ └── ArticleEditor.vue └── ai └── AiAssistPanel.vueVue Router 配置示例const routes [ { path: /creator/articles, name: ArticleList, component: () import(/views/creator/article/ArticleList.vue), meta: { requiresAuth: true } }, { path: /creator/articles/new, name: ArticleCreate, component: () import(/views/creator/article/ArticleEditor.vue), meta: { requiresAuth: true } }, { path: /creator/articles/:id/edit, name: ArticleEdit, component: () import(/views/creator/article/ArticleEditor.vue), props: true, meta: { requiresAuth: true } } ]路由上的requiresAuth可以在全局前置守卫里判断。如果前端还没有登录状态调到登录页后端接口也需要再次校验 token不能依赖前端隐藏页面来保证安全。4.2 axios 请求封装和登录态创作中心的每个请求都必须携带用户 token。统一封装 axios 实例避免在每个组件里手写Authorization。import axios from axios const apiClient axios.create({ baseURL: /api, timeout: 15000 }) apiClient.interceptors.request.use(config { const token localStorage.getItem(blog_token) if (token) { config.headers.Authorization Bearer ${token} } return config }) apiClient.interceptors.response.use( response { const result response.data if (result.code ! 0) { return Promise.reject(new Error(result.message)) } return result.data }, error { if (error.response error.response.status 401) { // 登录失效处理清空本地 token 并跳转登录页 localStorage.removeItem(blog_token) window.location.href /login } return Promise.reject(error) } )这里体现了 BFF 接口的统一格式code 为 0 表示成功非 0 表示业务异常。401 由 HTTP 状态码区分因为认证过滤器通常直接在 Spring Security 层返回 401不会走到业务 Result 包装。4.3 文章列表页文章列表页的作用不是展示全部文章而是展示当前用户自己的创作内容。需要筛选状态全部、草稿、已发布、已下线。列表项要包含标题、摘要、状态标签、更新时间、发布时间和操作按钮。列表页请求可以写成这样const loadArticles async (page 1) { loading.value true try { const data await apiClient.get(/creator/articles, { params: { status: query.status, page: page, pageSize: 10 } }) articleList.value data.records total.value data.total } finally { loading.value false } }如果用户选择“草稿”筛选后端返回 status 为 0 的文章选择“已发布”返回值 status 为 2。页面上的“编辑”按钮要根据 status 决定跳转文案草稿显示“继续编辑”已发布显示“编辑文章”。删除操作需要二次确认。注意删除后要刷新本地列表避免页面留下已经不存在的数据。4.4 编辑器组件左侧编辑右侧预览编辑器页面是创作中心里交互最复杂的部分。一个相对容易维护的形态是左侧编辑、右侧预览。为了演示数据流先用 textarea 编辑 Markdown右侧用 marked 解析预览。template div classeditor-container div classeditor-left input v-model.trimform.title classtitle-input maxlength200 placeholder请输入文章标题 / textarea v-modelform.markdownContent classcontent-input placeholder请输入 Markdown 正文/textarea /div div classeditor-right h1{{ form.title || 预览标题 }}/h1 article v-htmlrenderedContent/article /div /div /template script setup import { computed, ref } from vue import { marked } from marked import { useRoute, useRouter } from vue-router import apiClient from /api/client const route useRoute() const router useRouter() const form ref({ id: null, title: , summary: , markdownContent: , coverUrl: , categoryId: null, tagList: [] }) const renderedContent computed(() { // 预览区存在 XSS 风险真实项目必须使用 DOMPurify.sanitize return marked.parse(form.value.markdownContent || ) }) /script这段代码解决了三个问题编辑的是 markdownContent预览在展示时把 markdownContent 解析成 HTML。保存时后端可以继续收到 markdownContent并让后端重新生成 htmlContent。编辑器内容变化后前端预览自动刷新开发体验比较流畅。如果这里不想引入 marked也可以直接把 textarea 里输入的换行文本按空格和换行渲染成普通文本预览但生产博客系统要支持标题、代码块、链接等样式仍然建议使用成熟 Markdown 库。4.5 AI 摘要生成交互在编辑器页增加一个“生成摘要”按钮点击后调用后端异步创建任务。由于后端已经设计成异步页面不能一直转圈等待需要用轮询方式查询状态。最简单的实现是调用接口后定时查询一次文章详情检查ai_generate_status。const handleAiSummary async () { if (!form.value.id) { await saveDraftOnly() } aiLoading.value true await apiClient.post(/creator/articles/${form.value.id}/ai/summary, { articleId: form.value.id, title: form.value.title, content: form.value.markdownContent }) pollAiStatus() } const pollAiStatus async () { let timer null const check async () { const data await apiClient.get(/creator/articles/${form.value.id}) if (data.aiGenerateStatus 2) { form.value.summary data.summary aiLoading.value false clearInterval(timer) } else if (data.aiGenerateStatus 3) { aiLoading.value false clearInterval(timer) alert(AI 摘要生成失败请稍后重试) } } timer setInterval(check, 1000) }这里有一个重要原则AI 生成的摘要不能直接被当作最终摘要保存用户需要能修改。生成结果回填到表单 summary 字段后用户仍然可以手动编辑最后点击保存时再一并提交。这个设计比“AI 生成后立即入库”更贴合创作者习惯。5. 联调验证保存一篇草稿并发布的最小闭环5.1 联调前环境检查动手联调前按下面清单检查环境能省去很多无意义的排错时间检查项推荐状态MySQL 是否启动数据库可连接表结构已执行Redis 是否启动若登录认证使用 Redis需要先启动后端项目是否启动SpringBoot 启动日志无 ERROR端口 8080前端依赖是否安装node_modules 存在Vite 或 Vue CLI 能启动本地是否已登录token 已写入 localStorage数据库账号密码application.yml 中配置正确如果后端的 SpringBoot 版本与自己本机 JDK 版本不匹配启动时会看到类似UnsupportedClassVersionError。此时不要急着改代码先确认spring-boot-starter-parent版本和 JDK 版本的兼容关系。例如高版本 SpringBoot 要求 JDK 17 以上老项目还在用 JDK 8 就会出现问题。这不是创作中心独有但在联调时很容易被误判成代码问题。5.2 后端接口验证curl不打开前端页面先用 curl 验证后端接口是否能正常工作。这样可以先隔离“后端问题”和“Vue 页面问题”。第一步获取登录 token。具体获取方式取决于前面课程的登录接口这里只示意后续请求都带 tokencurl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:demo,password:demo123}第二步创建一个标题为“创作中心测试文章”的草稿curl -X POST http://localhost:8080/api/creator/articles \ -H Content-Type: application/json \ -H Authorization: Bearer token \ -d { title: 创作中心测试文章, markdownContent: # 测试\n\n这是正文内容, tagList: [SpringBoot, Vue], status: 0 }第三步查询当前用户文章列表确认草稿已经出现在列表中curl -X GET http://localhost:8080/api/creator/articles?page1pageSize10status0 \ -H Authorization: Bearer token第四步发布这篇文章curl -X POST http://localhost:8080/api/creator/articles/1/publish \ -H Authorization: Bearer token这一步发布后再去访问门户首页的文章详情接口应该能查到这篇文章。如果门户列表查询不到说明门户接口 SQL 中很可能漏了status2条件或者发布接口没有正确更新状态。5.3 页面操作闭环后端 curl 验证通过后再打开前端创作中心页面。走一遍完整操作路径登录系统进入“创作中心”。点击“新建文章”进入编辑器页。输入标题和正文不点发布先点“保存草稿”。返回文章列表确认列表出现一条状态为“草稿”的文章。再次进入编辑页补充摘要、标签点击“发布”。返回列表确认状态变为“已发布”。打开博客前台首页确认文章已出现。再回到创作中心把文章下线或删除观察前台是否同步变化。操作过程中要重点观察浏览器的 Network 面板每个操作对应哪一个接口请求参数和响应是否和预期一致。5.4 验证点清单联调结束前用这张清单做最终确认验证点预期结果未登录访问创作中心接口返回 401前端跳转登录页登录用户 A 访问用户 B 文章详情返回文章不存在或无权操作保存空白文章草稿可以保存成功因为草稿允许为空直接发布空白文章被拦截返回标题或正文不能为空发布成功后访问门户详情能正常展示AI 摘要生成过程中刷新页面状态显示为生成中不阻塞文章编辑删除文章后再次访问详情返回不存在或无权操作6. 创作中心常见问题排查6.1 不同场景下的 401 和 403现象前端打开创作中心页列表请求返回 401或者某个用户能访问别的用户文章接口返回 403。排查思路分两步。先看 401。401 通常出现在 token 失效、token 没传、token 解析失败三种情况。打开浏览器 Network 面板点击文章列表请求Headers 里看 Authorization 是否存在再打开后端日志确认 token 解析时报的是什么错。如果 token 是登录时存到 localStorage 的还要确认 axios 拦截器是否在每次请求前把 token 加到请求头。403 则更偏授权。Spring Security 配置中只有带ROLE_ADMIN或ROLE_AUTHOR的角色才能访问/api/creator/**。如果权限不足需要检查用户的角色分配。另一个可能是 Controller 没有按用户隔离例如用户 B 请求/api/creator/articles/1后端只按 id1 查询、没有拼接 userId导致返回了别人的数据。这种越权不会提示 403而是返回原文章比 403 更危险。6.2 保存成功但列表不显示现象前端保存草稿提示成功返回列表却看不到刚才保存的文章。可能原因通常是前端列表筛选条件和后端保存状态不一致。比如保存成功后前端把 status 置为 0列表请求却传了 status2或者后端插入时没有显式设置 status0而数据库默认值是 0虽然能插入但有些框架插入后不回填实体前端拿到的返回值里没有 id后续编辑无法定位。检查方式先看数据库表blog_article中是否真的有数据user_id是否为当前登录用户再看列表请求 URL 的参数是不是status0。解决方案后端新增文章时显式设置状态字段不要依赖数据库默认值前端新建成功后将返回 id 写入路由或本地状态而不是从旧列表中再去找。6.3 文章正文换行丢失或 HTML 异常现象编辑器里输入的内容有很多换行保存发布后前台详情页里所有换行都消失了或样式乱掉。这通常不是保存接口丢数据而是展示层没有正确处理内容格式。如果编辑器是 Markdown前台必须使用 Markdown 渲染库。如果用 textarea 里的纯文本直接放在div里展示HTML 会把换行符当作普通空白就不会显示成段落。另外如果前端把渲染后的 HTML 提交给后端却忘记对 HTML 做清理保存时script标签可能直接被写入数据库。排查时先查看数据库中的markdown_content和html_content确认原内容是否完整。若原内容完整但前台显示异常问题出在渲染层若数据库内容缺少换行问题出在保存层常见于框架把\n当作参数传递时被错误截断或者 String 类型参数嵌入了某些标签导致序列化异常。6.4 AI 摘要生成超时现象点击“生成摘要”后一直转圈最后报错。可能原因有三类。第一类是后端同步调用了模型接口模型服务响应慢前端请求时间超过了 axios 的 15 秒超时。处理方式是像第 3 节那样改为异步任务加轮询。第二类是线程池的队列和拒绝策略设计不合理。当任务量超过核心线程数和队列容量后AbortPolicy会抛出RejectedExecutionException用户看到的提示很不友好。实际项目里至少要把异常捕获并记录提示用户稍后重试。第三类是模型服务返回内容格式不符合预期后端反序列化失败。排查时看后端日志里 AI 辅助任务是否抛异常以及异常发生在调用前还是调用后。生产环境建议把模型调用放入独立的服务并且用消息队列削峰避免用户并发操作影响博客主服务。6.5 前端能访问、后端却报跨域现象前端页面在http://localhost:5173后端跑在http://localhost:8080浏览器控制台出现跨域错误。如果后端没有配置跨域axios 请求就无法读取响应。配置方式可以是后端全局配置CorsFilter也可以使用 Spring 的CrossOrigin注解。从维护角度统一使用全局跨域配置更合适。Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(http://localhost:*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }上面示例限定了本地 origin生产环境不要直接写*加allowCredentials的组合很多浏览器会拒绝携带 Cookie 的跨域请求。更常用的是通过 Nginx 做同源代理让前端的/api请求转发到后端这样就不依赖后端跨域配置了。7. 从教学实现走向生产环境要补的功课7.1 全文内容安全与 XSS创作中心有一个高风险点文章正文可以由用户输入并且会被渲染成 HTML 展示给所有读者。如果不对正文做清理任何一个创作者都能发布一段包含script标签的代码影响全部访问者。处理 XSS 不能只依赖后端对某些关键词的简单替换因为攻击方式有很多变体。更稳妥的方案是使用白名单过滤库例如在 Java 后端的Jsoup中启用Safelist配置或者在前端渲染前使用 DOMPurify 清理。核心原则是不要信任编辑器输入渲染前必须消毒。同时正文内容要控制展示方式。Markdown 编辑器可以把代码块渲染在precode中但仍然需要处理javascript:链接、img onerror事件等。这往往是教学博客不会涉及、但生产博客必须解决的问题。7.2 AI 辅助能力需要隔离与审核博客系统接入 AI 辅助生成摘要后要把“生成内容”和“生产发布”拆开看。AI 生成结果只是候选内容创作者必须经过确认后才能保存。如果系统对所有用户开放 AI 功能还要考虑接口鉴权、调用频控、内容审计。这是为了控制成本也是为了保证平台内容合法合规。具体落地时可以这样做阶段措施请求阶段登录后才能调用 AI 接口限制每个用户每分钟调用次数异步处理阶段通过消息队列消费生成任务失败后记录日志并支持重试结果阶段记录 AI 生成内容的关键信息保存到文章摘要时设置ai_generated_summary1发布阶段发布前走内容审核状态由审核回调更新文章状态整个流程要让使用这个功能的开发者清楚AI 只是辅助创作生产的一个能力不能替代创作者对内容负责。7.3 发布前检查清单课程示例项目可以跑通不代表可以直接上线。进入生产环境之前建议把下面这些点再过一遍文章表索引是否覆盖了门户的公开列表和创作中心的个人列表查询。门户接口是否明确过滤status2创作中心接口是否每个查询都绑定当前登录用户。富文本或 Markdown 内容是否经过白名单消毒。图片是否限制为外链或对象存储地址避免 base64 图片把文章表撑大。登录 token 是否在 Redis 有超时机制刷新页面后是否还能保持登录。AI 辅助接口是否有超时、限流、日志和失败重试方案。Controller 返回的数据是否包含内部字段例如后端实体直接转 JSON 时是否泄露出用户手机号等敏感字段。删除文章是物理删除还是逻辑删除历史文章是否要保留到回收站。是否有操作日志和审计能追溯是谁在什么时间修改了哪篇文章。创作中心的完整实现并不是写一个编辑器就结束。它真正的价值在于把内容数据的进入、校验、状态流转、展示发布做到一套清晰可维护的闭环里。学到这一讲后最值得继续练习的方向是给文章表加入定时发布字段扩展状态机或把 AI 辅助调用从线程池换成消息队列或补充列表按标签和分类的组合筛选。先跑通最小闭环再逐步增加这些能力创作中心就会成为整个博客系统里扩展性最好的部分。
返回列表