Explorar el Código

feat:完成AI分析功能

yangyi hace 1 mes
padre
commit
2f360ea51f

+ 23 - 0
client/src/main/java/space/anyi/client/controller/MainController.java

@@ -1,5 +1,6 @@
 package space.anyi.client.controller;
 
+import javafx.application.Platform;
 import javafx.collections.FXCollections;
 import javafx.fxml.FXML;
 import javafx.fxml.FXMLLoader;
@@ -169,6 +170,7 @@ public class MainController {
     @FXML private TableColumn<TransactionRecord, Integer> allTransNumCol;
     @FXML private TableColumn<TransactionRecord, java.math.BigDecimal> allTransPriceCol;
     @FXML private TableColumn<TransactionRecord, String> allTransTimeCol;
+    @FXML private Button generateBtn;
     @FXML private TextArea analysisResult;
 
     private final BookService bookService = new BookService();
@@ -1180,6 +1182,27 @@ public class MainController {
 
     @FXML
     private void handleGenerateAnalysis() {
+        analysisResult.setText("正在生成分析报告...");
+        generateBtn.setDisable(true);
+        aiService.streamAnalysis(
+                token -> Platform.runLater(() -> {
+                    if ("正在生成分析报告...".equals(analysisResult.getText())) {
+                        analysisResult.setText("");
+                    }
+                    analysisResult.appendText(token);
+                }),
+                () -> Platform.runLater(() -> {
+                    generateBtn.setDisable(false);
+                    showInfo("分析完成");
+                }),
+                error -> Platform.runLater(() -> {
+                    generateBtn.setDisable(false);
+                    fallbackGenerateAnalysis();
+                })
+        );
+    }
+
+    private void fallbackGenerateAnalysis() {
         R<String> result = aiService.getAnalysis();
         if (result.getCode() == 200 && result.getData() != null) {
             analysisResult.setText(result.getData());

+ 27 - 0
client/src/main/java/space/anyi/client/service/AiService.java

@@ -2,10 +2,37 @@ package space.anyi.client.service;
 
 import space.anyi.client.util.HttpClientUtil;
 import space.anyi.common.dto.R;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.util.function.Consumer;
 
 public class AiService {
 
+    private static final String STREAM_URL = "http://localhost:8080/api/ai/analysis/stream";
+
     public R<String> getAnalysis() {
         return HttpClientUtil.get("/api/ai/analysis", String.class);
     }
+
+    public void streamAnalysis(Consumer<String> onToken, Runnable onComplete, Consumer<Throwable> onError) {
+        HttpClient client = HttpClient.newHttpClient();
+        HttpRequest request = HttpRequest.newBuilder()
+                .uri(URI.create(STREAM_URL))
+                .GET()
+                .build();
+        client.sendAsync(request, java.net.http.HttpResponse.BodyHandlers.ofLines())
+                .thenAccept(response -> {
+                    if (response.statusCode() == 200) {
+                        response.body().forEach(onToken);
+                        onComplete.run();
+                    } else {
+                        onError.accept(new RuntimeException("stream failed: " + response.statusCode()));
+                    }
+                })
+                .exceptionally(e -> {
+                    onError.accept(e);
+                    return null;
+                });
+    }
 }

+ 1 - 1
client/src/main/resources/fxml/admin.fxml

@@ -136,7 +136,7 @@
         <VBox styleClass="well" spacing="12">
             <HBox spacing="10" alignment="CENTER_LEFT">
                 <Label text="🤖 AI 分析" style="-fx-font-size: 16px; -fx-font-weight: bold;" HBox.hgrow="ALWAYS"/>
-                <Button text="生成分析报告" onAction="#handleGenerateAnalysis" styleClass="btn,btn-primary"/>
+                <Button fx:id="generateBtn" text="生成分析报告" onAction="#handleGenerateAnalysis" styleClass="btn,btn-primary"/>
             </HBox>
             <TextArea fx:id="analysisResult" editable="false" wrapText="true" prefHeight="200" styleClass="form-control"/>
         </VBox>

+ 10 - 0
pom.xml

@@ -24,6 +24,7 @@
         <javafx.version>23.0.2</javafx.version>
         <jackson.version>2.17.3</jackson.version>
         <lombok.version>1.18.36</lombok.version>
+        <spring-ai.version>1.1.8</spring-ai.version>
     </properties>
 
     <dependencyManagement>
@@ -88,6 +89,15 @@
                 <scope>provided</scope>
             </dependency>
 
+            <!-- Spring AI -->
+            <dependency>
+                <groupId>org.springframework.ai</groupId>
+                <artifactId>spring-ai-bom</artifactId>
+                <version>${spring-ai.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+
             <!-- Intra-project -->
             <dependency>
                 <groupId>space.anyi</groupId>

+ 4 - 0
server/pom.xml

@@ -40,6 +40,10 @@
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-validation</artifactId>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.ai</groupId>
+            <artifactId>spring-ai-starter-model-openai</artifactId>
+        </dependency>
     </dependencies>
 
     <build>

+ 21 - 0
server/src/main/java/space/anyi/server/controller/AiController.java

@@ -1,8 +1,11 @@
 package space.anyi.server.controller;
 
+import org.springframework.http.MediaType;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
+import reactor.core.publisher.Flux;
 import space.anyi.common.dto.R;
 import space.anyi.server.service.AiService;
 
@@ -21,4 +24,22 @@ public class AiController {
         String report = aiService.generateAnalysis();
         return R.ok(report);
     }
+
+    @GetMapping("/analysis/stream")
+    public ResponseBodyEmitter streamAnalysis() {
+        ResponseBodyEmitter emitter = new ResponseBodyEmitter(180_000L);
+        Flux<String> flux = aiService.streamAnalysis();
+        flux.subscribe(
+                chunk -> {
+                    try {
+                        emitter.send(chunk, MediaType.TEXT_PLAIN);
+                    } catch (Exception e) {
+                        emitter.completeWithError(e);
+                    }
+                },
+                emitter::completeWithError,
+                emitter::complete
+        );
+        return emitter;
+    }
 }

+ 3 - 0
server/src/main/java/space/anyi/server/service/AiService.java

@@ -1,5 +1,8 @@
 package space.anyi.server.service;
 
+import reactor.core.publisher.Flux;
+
 public interface AiService {
     String generateAnalysis();
+    Flux<String> streamAnalysis();
 }

+ 68 - 1
server/src/main/java/space/anyi/server/service/impl/AiServiceImpl.java

@@ -1,6 +1,8 @@
 package space.anyi.server.service.impl;
 
+import org.springframework.ai.chat.client.ChatClient;
 import org.springframework.stereotype.Service;
+import reactor.core.publisher.Flux;
 import space.anyi.common.dto.*;
 import space.anyi.server.service.AiService;
 import space.anyi.server.service.StatisticsService;
@@ -10,9 +12,11 @@ import java.util.List;
 public class AiServiceImpl implements AiService {
 
     private final StatisticsService statisticsService;
+    private final ChatClient chatClient;
 
-    public AiServiceImpl(StatisticsService statisticsService) {
+    public AiServiceImpl(StatisticsService statisticsService, ChatClient.Builder chatClientBuilder) {
         this.statisticsService = statisticsService;
+        this.chatClient = chatClientBuilder.build();
     }
 
     @Override
@@ -83,4 +87,67 @@ public class AiServiceImpl implements AiService {
         report.append("\n================================================");
         return report.toString();
     }
+
+    @Override
+    public Flux<String> streamAnalysis() {
+        StatisticsSummary summary = statisticsService.getSummary();
+        List<DeptSalesDTO> topDepts = statisticsService.getTopDepartments();
+        List<HotBookDTO> topBooks = statisticsService.getTopBooks();
+        List<DeptDiscountDTO> discounts = statisticsService.getDeptDiscountRates();
+        String prompt = buildPrompt(summary, topDepts, topBooks, discounts);
+        return chatClient.prompt().user(prompt).stream().content();
+    }
+
+    private String buildPrompt(StatisticsSummary summary, List<DeptSalesDTO> topDepts,
+                                List<HotBookDTO> topBooks, List<DeptDiscountDTO> discounts) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("你是一位校园二手书交易平台的数据分析师。请根据以下平台运营数据,撰写一份运营分析报告。\n\n");
+
+        sb.append("【交易总览】交易总金额:¥").append(summary.getTotalAmount())
+          .append(",总笔数:").append(summary.getTotalCount()).append("笔\n\n");
+
+        sb.append("【热门院系 TOP3(按交易额)】\n");
+        if (topDepts.isEmpty()) {
+            sb.append("暂无数据\n");
+        } else {
+            for (int i = 0; i < topDepts.size(); i++) {
+                DeptSalesDTO d = topDepts.get(i);
+                sb.append(i + 1).append(". ").append(d.getDept())
+                  .append(" - ¥").append(d.getSalesAmount()).append("\n");
+            }
+        }
+        sb.append("\n");
+
+        sb.append("【热门书籍 TOP5(按销量)】\n");
+        if (topBooks.isEmpty()) {
+            sb.append("暂无数据\n");
+        } else {
+            for (int i = 0; i < topBooks.size(); i++) {
+                HotBookDTO b = topBooks.get(i);
+                sb.append(i + 1).append(". 《").append(b.getTitle())
+                  .append("》").append(b.getAuthor())
+                  .append(" - 销量 ").append(b.getTotalSold()).append("本\n");
+            }
+        }
+        sb.append("\n");
+
+        sb.append("【各院系平均折扣率】\n");
+        if (discounts.isEmpty()) {
+            sb.append("暂无数据\n");
+        } else {
+            for (DeptDiscountDTO d : discounts) {
+                sb.append(d.getDept()).append(":").append(String.format("%.1f", d.getAvgDiscountRate())).append("%\n");
+            }
+        }
+        sb.append("\n");
+
+        sb.append("请从以下5个维度分析(中文,800字内):\n");
+        sb.append("1. 整体运营评估:基于交易规模和活跃度,判断平台处于什么阶段(起步期/成长期/成熟期),概括当前运营状况。\n");
+        sb.append("2. 院系推广建议:针对不同表现的院系,提出差异化的推广策略。\n");
+        sb.append("3. 书籍选品与库存建议:分析热门书籍特点,给出库存建议。\n");
+        sb.append("4. 定价策略优化:分析折扣率分布,给出合理折扣区间建议。\n");
+        sb.append("5. 具体行动建议:列举3-5条下周可执行的具体运营动作。\n\n");
+        sb.append("要求:基于数据,不空泛,专业客观。");
+        return sb.toString();
+    }
 }

+ 9 - 0
server/src/main/resources/application.yml

@@ -10,6 +10,15 @@ spring:
     password: root
     driver-class-name: com.mysql.cj.jdbc.Driver
 
+  ai:
+    openai:
+      base-url: ${OPENAI_BASE_URL:https://spark-api-open.xf-yun.com}
+      api-key: ${OPENAI_API_KEY:key}
+      chat:
+        options:
+          model: ${OPENAI_MODEL:lite}
+          temperature: 0.3
+
 mybatis-plus:
   configuration:
     map-underscore-to-camel-case: true