在这里插入图片描述

前言

上一篇我们已经完成了发布系统的前半段:创建草稿、OSS 预签名直传和内容确认。

今天我们来继续整理后半段:更新标题、标签、图片列表、可见性、摘要,并接入 DeepSeek AI 实现一键生成文章摘要,最后将草稿正式发布。

这部分的流程是:

上传确认完成 -> 更新元数据 -> AI 生成摘要 -> 保存摘要 -> 发布草稿

一、元数据更新功能

1. 功能需求

正文上传到 OSS 之后,还需要补充这些信息:

  • 标题
  • 分类 ID
  • 标签列表
  • 图片 URL 列表
  • 可见性
  • 是否置顶
  • 摘要描述

接口路径:

PATCH /api/v1/knowposts/{id}

请求 DTO:

public record KnowPostPatchRequest(
        String title,
        Long tagId,
        @Size(max = 20) List<String> tags,
        @Size(max = 20) List<String> imgUrls,
        String visible,
        Boolean isTop,
        String description
) {}

这里的 tagsimgUrls 最多 20 个,避免一次提交过大的数组。

二、更新元数据实现

Controller 层:

@PatchMapping("/{id}")
public ResponseEntity<Void> patchMetadata(@PathVariable("id") long id,
                                          @Valid @RequestBody KnowPostPatchRequest request,
                                          @AuthenticationPrincipal Jwt jwt) {
    long userId = jwtService.extractUserId(jwt);

    service.updateMetadata(
            userId,
            id,
            request.title(),
            request.tagId(),
            request.tags(),
            request.imgUrls(),
            request.visible(),
            request.isTop(),
            request.description()
    );

    return ResponseEntity.noContent().build();
}

Service 层:

@Transactional
public void updateMetadata(long creatorId,
                           long id,
                           String title,
                           Long tagId,
                           List<String> tags,
                           List<String> imgUrls,
                           String visible,
                           Boolean isTop,
                           String description) {
    invalidateCache(id);

    KnowPost post = KnowPost.builder()
            .id(id)
            .creatorId(creatorId)
            .title(title)
            .tagId(tagId)
            .tags(toJsonOrNull(tags))
            .imgUrls(toJsonOrNull(imgUrls))
            .visible(visible)
            .isTop(isTop)
            .description(description)
            .type("image_text")
            .updateTime(Instant.now())
            .build();

    int updated = mapper.updateMetadata(post);

    if (updated == 0) {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
    }

    try {
        long outId = idGen.nextId();
        String payload = objectMapper.writeValueAsString(
                Map.of("entity", "knowpost", "op", "upsert", "id", id)
        );
        outboxMapper.insert(outId, "knowpost", id, "KnowPostMetadataUpdated", payload);
    } catch (Exception e) {
        log.warn("Outbox event after metadata update failed, post {}: {}", id, e.getMessage());
    }

    invalidateCache(id);
}

这里有两个重点。

第一,tagsimgUrls 会转成 JSON 字符串:

private String toJsonOrNull(List<String> list) {
    if (list == null) {
        return null;
    }

    try {
        return objectMapper.writeValueAsString(list);
    } catch (JsonProcessingException e) {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "JSON 处理失败");
    }
}

第二,元数据更新后会写入 Outbox 事件,后续可以驱动搜索索引更新。

三、Mapper 动态 SQL

<update id="updateMetadata" parameterType="com.tongji.knowpost.model.KnowPost">
    UPDATE know_posts
    <set>
        <if test="title != null">title = #{title},</if>
        <if test="tagId != null">tag_id = #{tagId},</if>
        <if test="tags != null">tags = #{tags},</if>
        <if test="imgUrls != null">img_urls = #{imgUrls},</if>
        <if test="visible != null">visible = #{visible},</if>
        <if test="isTop != null">is_top = #{isTop},</if>
        <if test="description != null">description = #{description},</if>
        <if test="type != null">type = #{type},</if>
        update_time = #{updateTime}
    </set>
    WHERE id = #{id} AND creator_id = #{creatorId}
</update>

这里使用 MyBatis 动态 SQL,非常适合草稿编辑。

前端传什么字段,就更新什么字段;没传的字段保持不变。

四、DeepSeek AI 生成文章摘要

1. 功能需求

摘要生成接口路径:

POST /api/v1/knowposts/description/suggest

请求体:

{
  "content": "这里是 Markdown 正文内容..."
}

响应:

{
  "description": "生成的不超过50字的中文描述"
}

这个接口只负责生成摘要,不直接保存数据库。前端拿到摘要后,再通过元数据更新接口保存到 description 字段。

2. Controller 层实现

@RestController
@RequestMapping(path = "/api/v1/knowposts", produces = MediaType.APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
public class KnowPostAiController {

    private final KnowPostDescriptionService descriptionService;

    @PostMapping(path = "/description/suggest",
                 consumes = MediaType.APPLICATION_JSON_VALUE)
    public DescriptionSuggestResponse suggest(
            @Valid @RequestBody DescriptionSuggestRequest req) {
        String desc = descriptionService.generateDescription(req.content());
        return new DescriptionSuggestResponse(desc);
    }
}

请求 DTO:

public record DescriptionSuggestRequest(
        @NotBlank(message = "content 不能为空") String content
) {}

五、Spring AI 接入 DeepSeek

项目通过 Spring AI 接入 DeepSeek:

@Configuration
public class LlmConfig {
    @Bean
    public ChatClient chatClient(
            @Qualifier("deepSeekChatModel") ChatModel chatModel) {
        return ChatClient.builder(chatModel).build();
    }
}

依赖如下:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-deepseek</artifactId>
</dependency>

摘要生成 Service:

@Service
@RequiredArgsConstructor
public class KnowPostDescriptionServiceImpl implements KnowPostDescriptionService {

    private final ChatClient chatClient;

    public String generateDescription(String content) {
        if (content == null || content.trim().isEmpty()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "正文内容不能为空");
        }

        String system = "你是中文文案编辑。请基于用户提供的知文正文,生成一个中文描述,简洁有吸引力,且不超过50个汉字。不输出解释或多段,只输出结果。";
        String user = "正文如下:\n\n" + content + "\n\n请直接给出不超过50字的中文描述。";

        try {
            String result = chatClient
                    .prompt()
                    .system(system)
                    .user(user)
                    .options(DeepSeekChatOptions.builder()
                            .model("deepseek-chat")
                            .temperature(0.8)
                            .maxTokens(120)
                            .build())
                    .call()
                    .content();

            return postProcess(result);
        } catch (Exception e) {
            throw new BusinessException(
                    ErrorCode.INTERNAL_ERROR,
                    "大模型调用失败: " + e.getMessage()
            );
        }
    }
}

这里使用 system 约束模型输出风格,使用 user 提供正文内容,最终调用 deepseek-chat 生成摘要。

六、摘要后处理

private String postProcess(String text) {
    if (text == null) {
        return "";
    }

    String t = Normalizer.normalize(text, Normalizer.Form.NFKC)
            .replaceAll("\r\n|\r|\n", " ")
            .replaceAll("\\s+", " ")
            .trim();

    t = t.replaceAll("^[\"'“”‘’]+|[\"'“”‘’]+$", "")
         .replaceAll("[。!!??;;、]+$", "");

    int limit = 50;
    int count = t.codePointCount(0, t.length());

    if (count <= limit) {
        return t;
    }

    StringBuilder sb = new StringBuilder();
    int i = 0, added = 0;

    while (i < t.length() && added < limit) {
        int cp = t.codePointAt(i);
        sb.appendCodePoint(cp);
        i += Character.charCount(cp);
        added++;
    }

    return sb.toString();
}

大模型输出不是强约束结果,所以后端必须做兜底处理。

这里做了这些事情:

  • 去掉换行
  • 合并空格
  • 去掉前后引号
  • 去掉结尾多余标点
  • 截断到 50 字以内

这一步可以保证最终写入 description VARCHAR(50) 的内容更加稳定。

七、正式发布草稿

1. 功能需求

当正文、图片、标题、标签、摘要都准备好后,调用发布接口:

POST /api/v1/knowposts/{id}/publish

2. Controller 层实现

@PostMapping("/{id}/publish")
public ResponseEntity<Void> publish(@PathVariable("id") long id,
                                    @AuthenticationPrincipal Jwt jwt) {
    long userId = jwtService.extractUserId(jwt);
    service.publish(userId, id);
    return ResponseEntity.noContent().build();
}

3. Service 层实现

@Transactional
public void publish(long creatorId, long id) {
    int updated = mapper.publish(id, creatorId);

    if (updated == 0) {
        throw new BusinessException(ErrorCode.BAD_REQUEST, "草稿不存在或无权限");
    }

    try {
        userCounterService.incrementPosts(creatorId, 1);
    } catch (Exception ignored) {}

    try {
        long outId = idGen.nextId();
        String payload = objectMapper.writeValueAsString(
                Map.of("entity", "knowpost", "op", "upsert", "id", id)
        );
        outboxMapper.insert(outId, "knowpost", id, "KnowPostPublished", payload);
    } catch (Exception e) {
        log.warn("Outbox event after publish failed, post {}: {}", id, e.getMessage());
    }

    try {
        ragIndexService.ensureIndexed(id);
    } catch (Exception e) {
        log.warn("Pre-index after publish failed, post {}: {}", id, e.getMessage());
    }
}

发布成功后,系统做了几件事:

  • 状态改为 published
  • 写入发布时间
  • 用户作品数加一
  • 写入 Outbox 事件,驱动搜索索引更新
  • 触发 RAG 预索引,减少后续问答冷启动

4. Mapper 层实现

<update id="publish">
    UPDATE know_posts
    SET status = 'published',
        publish_time = NOW(),
        update_time = NOW()
    WHERE id = #{id}
      AND creator_id = #{creatorId}
</update>

这里依然通过 creator_id 控制权限,保证用户只能发布自己的草稿。

八、知识点总结

1. MyBatis 动态 SQL

元数据更新使用 <set><if>,可以实现部分字段更新,非常适合草稿编辑场景。

2. AI 生成结果要做业务兜底

DeepSeek 可以生成摘要,但后端仍然要做长度截断、格式清理和异常处理,不能完全依赖模型输出。

3. 发布动作不只是改状态

正式发布后,系统还会联动用户计数、搜索索引和 RAG 预索引。

这说明发布系统不是孤立模块,而是内容平台很多后续能力的入口。

总结

这一篇主要整理了发布系统的后半段:元数据更新、DeepSeek 摘要生成、摘要保存和正式发布。

到这里,知文从一个空草稿,逐步补充正文、图片、标题、标签、摘要,最后变成一篇已发布内容。相比普通 CRUD,这个模块更贴近真实内容平台的业务流程,也体现了“发布系统”作为内容生产入口的价值。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐