@@ -55,13 +70,15 @@ import { useRoute, useRouter } from 'vue-router'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
-import { createArticle, updateArticle, getArticleDetail, getCategories, getTags } from '@/api/article'
-import { ElMessage } from 'element-plus'
+import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary } from '@/api/article'
+import { ElMessage, ElMessageBox } from 'element-plus'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const saving = ref(false)
+const summarizing = ref(false)
+const lastSummaryMeta = ref('')
const categories = ref([])
const tags = ref([])
const form = ref({
@@ -73,6 +90,53 @@ const form = ref({
allowComment: true,
})
+const MIN_CONTENT_LENGTH = 20
+
+function getPlainTextContent() {
+ const html = editor.value?.getHTML() || ''
+ const tmp = document.createElement('div')
+ tmp.innerHTML = html
+ return (tmp.textContent || tmp.innerText || '').trim()
+}
+
+async function handleGenerateSummary() {
+ const plain = getPlainTextContent()
+ if (plain.length < MIN_CONTENT_LENGTH) {
+ ElMessage.warning('正文过短,无法生成摘要')
+ return
+ }
+ if (form.value.summary && form.value.summary.trim()) {
+ try {
+ await ElMessageBox.confirm('已有摘要,确认覆盖?', '提示', {
+ confirmButtonText: '覆盖',
+ cancelButtonText: '取消',
+ type: 'warning',
+ })
+ } catch {
+ return
+ }
+ }
+ summarizing.value = true
+ lastSummaryMeta.value = ''
+ try {
+ const res = await generateArticleSummary({
+ title: form.value.title || '',
+ content: plain,
+ })
+ form.value.summary = res.data.summary
+ const meta = res.data
+ lastSummaryMeta.value = `模型: ${meta.model || '-'} 耗时: ${meta.costMillis ?? '-'}ms` +
+ (meta.promptTokens != null ? ` tokens: ${meta.promptTokens}/${meta.completionTokens}` : '')
+ ElMessage.success('摘要生成成功')
+ } catch (err) {
+ if (err?.code === 'ECONNABORTED') {
+ ElMessage.error('AI 服务响应超时,请稍后再试')
+ }
+ } finally {
+ summarizing.value = false
+ }
+}
+
const editor = useEditor({
extensions: [
StarterKit,
@@ -193,4 +257,17 @@ onBeforeUnmount(() => {
color: var(--text-secondary);
margin: var(--spacing-md) 0;
}
+.summary-wrapper {
+ width: 100%;
+}
+.summary-actions {
+ margin-top: var(--spacing-sm);
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-md);
+}
+.summary-meta {
+ font-size: var(--font-size-sm);
+ color: var(--text-secondary);
+}
diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java
new file mode 100644
index 0000000..9d74541
--- /dev/null
+++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/config/DashScopeProperties.java
@@ -0,0 +1,35 @@
+package fun.nojava.module.blog.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 阿里云百炼(DashScope)大模型平台配置。
+ *
+ * 通过 OpenAI 兼容模式接入,敏感的 {@code apiKey} 必须通过环境变量
+ * {@code DASHSCOPE_API_KEY} 注入,禁止入仓。
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "dashscope")
+public class DashScopeProperties {
+
+ /** API Key(必填,仅可通过环境变量注入) */
+ private String apiKey = "";
+
+ /** OpenAI 兼容模式 BaseURL */
+ private String baseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1";
+
+ /** 默认模型 */
+ private String model = "qwen-turbo";
+
+ /** 单次调用超时(毫秒) */
+ private int timeout = 30000;
+
+ /** 单次输入正文字符上限 */
+ private int maxInput = 8000;
+
+ /** 单用户每分钟调用次数上限 */
+ private int rateLimitPerMinute = 5;
+}
diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java
index 998ea21..ec9939f 100644
--- a/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java
+++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/controller/ArticleController.java
@@ -3,6 +3,7 @@ package fun.nojava.module.blog.controller;
import fun.nojava.common.model.ApiResult;
import fun.nojava.common.model.PageResult;
import fun.nojava.module.blog.dto.*;
+import fun.nojava.module.blog.service.AiSummaryService;
import fun.nojava.module.blog.service.ArticleService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.*;
public class ArticleController {
private final ArticleService articleService;
+ private final AiSummaryService aiSummaryService;
@Operation(summary = "创建文章")
@PostMapping
@@ -81,4 +83,12 @@ public class ArticleController {
@AuthenticationPrincipal Long userId) {
return ApiResult.success(articleService.toggleLike(userId, id));
}
+
+ @Operation(summary = "AI 生成文章摘要")
+ @PostMapping("/summary")
+ public ApiResult generateSummary(
+ @AuthenticationPrincipal Long userId,
+ @Valid @RequestBody GenerateSummaryDTO dto) {
+ return ApiResult.success(aiSummaryService.generateSummary(userId, dto));
+ }
}
diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java
new file mode 100644
index 0000000..3a05b88
--- /dev/null
+++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/GenerateSummaryDTO.java
@@ -0,0 +1,16 @@
+package fun.nojava.module.blog.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class GenerateSummaryDTO {
+
+ @Size(max = 200, message = "标题最长200字符")
+ private String title;
+
+ @NotBlank(message = "内容不能为空")
+ @Size(max = 50000, message = "正文过长")
+ private String content;
+}
diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java
new file mode 100644
index 0000000..ba7d2b0
--- /dev/null
+++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/dto/SummaryResultVO.java
@@ -0,0 +1,19 @@
+package fun.nojava.module.blog.dto;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class SummaryResultVO {
+
+ private String summary;
+
+ private String model;
+
+ private Integer promptTokens;
+
+ private Integer completionTokens;
+
+ private Long costMillis;
+}
diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java
new file mode 100644
index 0000000..2261030
--- /dev/null
+++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/AiSummaryService.java
@@ -0,0 +1,9 @@
+package fun.nojava.module.blog.service;
+
+import fun.nojava.module.blog.dto.GenerateSummaryDTO;
+import fun.nojava.module.blog.dto.SummaryResultVO;
+
+public interface AiSummaryService {
+
+ SummaryResultVO generateSummary(Long userId, GenerateSummaryDTO dto);
+}
diff --git a/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java
new file mode 100644
index 0000000..b469a49
--- /dev/null
+++ b/jog-module-blog/src/main/java/fun/nojava/module/blog/service/impl/AiSummaryServiceImpl.java
@@ -0,0 +1,224 @@
+package fun.nojava.module.blog.service.impl;
+
+import fun.nojava.common.exception.BusinessException;
+import fun.nojava.common.exception.ErrorCode;
+import fun.nojava.module.blog.config.DashScopeProperties;
+import fun.nojava.module.blog.dto.GenerateSummaryDTO;
+import fun.nojava.module.blog.dto.SummaryResultVO;
+import fun.nojava.module.blog.service.AiSummaryService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.ResourceAccessException;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientResponseException;
+
+import jakarta.annotation.PostConstruct;
+import java.net.SocketTimeoutException;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 阿里云百炼(DashScope)OpenAI 兼容模式摘要生成服务。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AiSummaryServiceImpl implements AiSummaryService {
+
+ private static final int MIN_CONTENT_LENGTH = 20;
+ private static final int SUMMARY_MAX_LENGTH = 200;
+ private static final String SUMMARY_TRUNCATE_SUFFIX = "…";
+ private static final String RATE_LIMIT_KEY_PREFIX = "ai_summary:rate:";
+
+ private static final String SYSTEM_PROMPT =
+ "你是一名专业的博客编辑,请根据用户提供的标题与正文生成 100~150 字的中文摘要。" +
+ "要求:语言精炼、概括文章核心观点、不使用第一人称、不要出现\"本文\"以外的元描述、" +
+ "直接输出摘要正文,不要加任何前缀或引号。";
+
+ private final DashScopeProperties properties;
+ private final StringRedisTemplate redisTemplate;
+
+ private RestClient restClient;
+
+ @PostConstruct
+ void init() {
+ SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+ factory.setConnectTimeout((int) Duration.ofMillis(properties.getTimeout()).toMillis());
+ factory.setReadTimeout((int) Duration.ofMillis(properties.getTimeout()).toMillis());
+ this.restClient = RestClient.builder()
+ .baseUrl(properties.getBaseUrl())
+ .requestFactory(factory)
+ .build();
+ }
+
+ @Override
+ public SummaryResultVO generateSummary(Long userId, GenerateSummaryDTO dto) {
+ String plainContent = stripHtml(dto.getContent());
+ if (plainContent.length() < MIN_CONTENT_LENGTH) {
+ throw new BusinessException(ErrorCode.AI_CONTENT_TOO_SHORT);
+ }
+ if (!hasApiKey()) {
+ throw new BusinessException(ErrorCode.AI_API_KEY_MISSING);
+ }
+ checkRateLimit(userId);
+
+ String input = plainContent.length() > properties.getMaxInput()
+ ? plainContent.substring(0, properties.getMaxInput())
+ : plainContent;
+
+ String userPrompt = buildUserPrompt(dto.getTitle(), input);
+ long startMillis = System.currentTimeMillis();
+
+ Map requestBody = Map.of(
+ "model", properties.getModel(),
+ "messages", List.of(
+ Map.of("role", "system", "content", SYSTEM_PROMPT),
+ Map.of("role", "user", "content", userPrompt)
+ ),
+ "temperature", 0.3,
+ "top_p", 0.9
+ );
+
+ Map response;
+ try {
+ response = callModel(requestBody);
+ } catch (ResourceAccessException ex) {
+ if (ex.getCause() instanceof SocketTimeoutException) {
+ log.error("DashScope 调用超时, userId={}, cost={}ms", userId, System.currentTimeMillis() - startMillis);
+ throw new BusinessException(ErrorCode.AI_SERVICE_TIMEOUT);
+ }
+ log.error("DashScope 网络异常, userId={}, error={}", userId, ex.getMessage());
+ throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
+ } catch (RestClientResponseException ex) {
+ log.error("DashScope HTTP 异常, status={}, body={}", ex.getStatusCode(), ex.getResponseBodyAsString());
+ throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
+ } catch (Exception ex) {
+ log.error("DashScope 未知异常, userId={}, error={}", userId, ex.getMessage(), ex);
+ throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
+ }
+
+ long costMillis = System.currentTimeMillis() - startMillis;
+ SummaryResultVO result = parseResponse(response, costMillis);
+ log.info("AI 摘要生成成功 userId={}, model={}, promptTokens={}, completionTokens={}, cost={}ms",
+ userId, result.getModel(), result.getPromptTokens(), result.getCompletionTokens(), costMillis);
+ return result;
+ }
+
+ @SuppressWarnings("unchecked")
+ protected Map callModel(Map requestBody) {
+ return restClient.post()
+ .uri("/chat/completions")
+ .header("Authorization", "Bearer " + properties.getApiKey())
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(requestBody)
+ .retrieve()
+ .body(Map.class);
+ }
+
+ private boolean hasApiKey() {
+ return properties.getApiKey() != null && !properties.getApiKey().isBlank();
+ }
+
+ private void checkRateLimit(Long userId) {
+ if (userId == null) {
+ return;
+ }
+ String key = RATE_LIMIT_KEY_PREFIX + userId;
+ Long count = redisTemplate.opsForValue().increment(key);
+ if (count != null && count == 1L) {
+ redisTemplate.expire(key, 1, TimeUnit.MINUTES);
+ }
+ if (count != null && count > properties.getRateLimitPerMinute()) {
+ log.warn("AI 摘要限流命中 userId={}, count={}", userId, count);
+ throw new BusinessException(ErrorCode.RATE_LIMITED);
+ }
+ }
+
+ private String buildUserPrompt(String title, String content) {
+ StringBuilder sb = new StringBuilder();
+ if (title != null && !title.isBlank()) {
+ sb.append("标题:").append(title.trim()).append("\n");
+ }
+ sb.append("正文:\n").append(content);
+ return sb.toString();
+ }
+
+ private SummaryResultVO parseResponse(Map response, long costMillis) {
+ if (response == null) {
+ throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
+ }
+ try {
+ @SuppressWarnings("unchecked")
+ List