Files
lpt-be/src/main/java/com/guo/learningprogresstracker/service/impl/AiServiceClient.java
T
cat-shark 55b7c1c83c feat(后端):AiServiceClient 改为 submit+poll 异步任务模式
- 内部实现重构:每个公有方法内部 submitAndWait() → submitTask() + pollResult()
- submitTask(): POST /ai/tasks,返回 taskId(30s 超时)
- pollResult(): 轮询 GET /ai/tasks/:taskId,500ms 首次延迟,
  2s × 10 次后退避到 5s,总超时 600s(10 min)
- 公有方法签名完全不变:aggregateReport()、generateMindMap()、compareRecall()
- 异常失败时返回 Optional.empty(),调用方降级逻辑不变
- application.yml timeout-seconds: 180 → 600
2026-07-04 16:41:21 +08:00

239 lines
9.7 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.guo.learningprogresstracker.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* lpt-ai 独立 AI 服务客户端。
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
* <p>
* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果,
* 避免同步等待 LLM 响应时阻塞 HTTP 连接。
* </p>
*/
@Slf4j
@Component
@ConfigurationProperties(prefix = "lpt.ai-service")
@Setter
public class AiServiceClient {
/** lpt-ai 服务地址,如 http://localhost:5199 */
private String url;
/** 超时秒数(等待 AI 任务完成的最长时间) */
private int timeoutSeconds = 600;
private HttpClient httpClient;
private final ObjectMapper objectMapper = new ObjectMapper();
@PostConstruct
void init() {
httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
if (isConfigured()) {
log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds);
} else {
log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑)");
}
}
public boolean isConfigured() {
return url != null && !url.isBlank();
}
// ============ 公开 API(签名不变,内部改为 submit + poll============
/**
* 调用 lpt-ai 将残片聚合为学习报告草稿。
*/
public Optional<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
return Optional.empty();
}
try {
List<Map<String, String>> fragmentBodies = fragments.stream()
.map(content -> Map.of("content", content))
.toList();
Map<String, Object> params = expectation == null || expectation.isBlank()
? Map.of("taskName", taskName, "fragments", fragmentBodies)
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
Optional<JsonNode> result = submitAndWait("aggregate-report", params);
return result.map(r -> r.path("report").asText(null)).filter(s -> s != null && !s.isBlank());
} catch (Exception e) {
log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 调用 lpt-ai 从学习数据生成思维导图大纲。
*/
public Optional<String> generateMindMap(String taskName, String taskDescription,
List<String> reports, List<String> fragments) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, Object> params = Map.of(
"taskName", taskName,
"taskDescription", taskDescription != null ? taskDescription : "",
"reports", reports != null ? reports : List.of(),
"fragments", fragments != null ? fragments : List.of()
);
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
return result.map(r -> r.path("outline").asText(null)).filter(s -> s != null && !s.isBlank());
} catch (Exception e) {
log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 调用 lpt-ai 进行语义化回忆对比。
*/
public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, Object> params = Map.of(
"taskName", taskName,
"standardOutline", standardOutline,
"recallOutline", recallOutline
);
return submitAndWait("compare-recall", params);
} catch (Exception e) {
log.warn("lpt-ai 回忆对比异常,将降级为内置算法: {}", e.getMessage());
return Optional.empty();
}
}
// ============ 内部异步 submit + poll ============
/** 提交任务并同步等待结果(内部轮询,调用方无感知) */
private Optional<JsonNode> submitAndWait(String type, Map<String, Object> params) {
String taskId = submitTask(type, params);
if (taskId == null) {
return Optional.empty();
}
return pollResult(taskId);
}
/** 提交任务到 lpt-ai /ai/tasks */
private String submitTask(String type, Map<String, Object> params) {
try {
Map<String, Object> body = Map.of("type", type, "params", params);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/tasks"))
.timeout(Duration.ofSeconds(30)) // 提交本身不应超时
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 201) {
log.warn("lpt-ai 提交任务失败: type={}, status={}, body={}",
type, response.statusCode(),
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
return null;
}
JsonNode json = objectMapper.readTree(response.body());
String taskId = json.path("taskId").asText(null);
if (taskId == null || taskId.isBlank()) {
log.warn("lpt-ai 提交任务成功但未返回 taskId");
return null;
}
log.info("lpt-ai 任务已提交: type={}, taskId={}", type, taskId);
return taskId;
} catch (Exception e) {
log.warn("lpt-ai 提交任务异常: {}", e.getMessage());
return null;
}
}
/** 轮询等待任务完成 */
private Optional<JsonNode> pollResult(String taskId) {
Instant deadline = Instant.now().plusSeconds(timeoutSeconds);
int pollCount = 0;
try {
// 给 worker 一点启动时间
Thread.sleep(500);
while (Instant.now().isBefore(deadline)) {
pollCount++;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/tasks/" + taskId))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
log.warn("lpt-ai 任务 {} 不存在(可能已过期或被清理)", taskId);
return Optional.empty();
}
if (response.statusCode() != 200) {
log.warn("lpt-ai 查询任务失败: taskId={}, status={}", taskId, response.statusCode());
// 临时故障,继续轮询
Thread.sleep(2000);
continue;
}
JsonNode json = objectMapper.readTree(response.body());
String status = json.path("status").asText("");
switch (status) {
case "done":
log.info("lpt-ai 任务完成: taskId={}, 轮询次数={}", taskId, pollCount);
return Optional.ofNullable(json.path("result"));
case "failed":
String err = json.path("error").asText("未知错误");
log.warn("lpt-ai 任务失败: taskId={}, error={}", taskId, err);
return Optional.empty();
case "pending":
case "running":
// 还在处理,继续轮询
break;
default:
log.warn("lpt-ai 未知任务状态: taskId={}, status={}", taskId, status);
return Optional.empty();
}
} catch (Exception e) {
log.warn("lpt-ai 轮询异常 (第{}次): {}", pollCount, e.getMessage());
}
// 退避:前 10 次 2s,之后 5s
long delay = pollCount <= 10 ? 2000 : 5000;
Thread.sleep(delay);
}
log.warn("lpt-ai 任务超时: taskId={}, timeout={}s", taskId, timeoutSeconds);
return Optional.empty();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("lpt-ai 轮询被中断: taskId={}", taskId);
return Optional.empty();
}
}
}