Ver código fonte

feat(api): 添加任务执行日志查询功能

- 新增 /v2/ai/order/v2/taskLog 接口用于查询容器执行日志
- 实现 DockerLogService 服务通过 Docker Engine API 访问容器日志
- 添加 DockerProperties 配置类管理 Docker 相关配置
- 在 application.yaml 中新增 docker 配置项
- 更新 build.sh 脚本挂载 Docker Socket 到容器
- 修改 Dockerfile 安装 curl 依赖支持 HTTP 请求
- 添加详细的 API 文档说明查询任务日志的使用方法
ChenYu 7 meses atrás
pai
commit
4079c619a9

+ 1 - 1
Dockerfile

@@ -22,7 +22,7 @@ ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
 
 RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \
     && apt-get update \
-    && apt-get install -y --no-install-recommends ffmpeg fontconfig fonts-dejavu-core \
+    && apt-get install -y --no-install-recommends ffmpeg fontconfig fonts-dejavu-core curl \
     && rm -rf /var/lib/apt/lists/*
 
 WORKDIR /app

+ 1 - 0
build.sh

@@ -127,6 +127,7 @@ docker run -d \
     -p 9901:9901 \
     -p 9902:8090 \
     --volume /etc/localtime:/etc/localtime \
+    -v /var/run/docker.sock:/var/run/docker.sock \
     -v /logs/${MODULE}:/app/logs \
     -v /data/www/file/:/data/www/file/ \
     -e ACTIVE_ENV=${ENV} \

+ 58 - 0
doc/AI_ORDER_V2_API.md

@@ -307,3 +307,61 @@ GET /v2/ai/order/v2/queryResult?taskId=2b80cfe9-eb90-44c2-9201-55ecff33a85d
   "timestamp": 1773033600000
 }
 ```
+
+---
+
+## 5. 查询任务执行日志
+
+*   **URL**: `/v2/ai/order/v2/taskLog`
+*   **Method**: `GET`
+*   **描述**: 根据 taskId 查询 `ai-flow-py` 容器中的执行日志。等价于在宿主机执行 `docker logs ai-flow-py 2>&1 | grep "{taskId}"`。通过 Docker Engine API(unix socket)直接访问同宿主机上的容器日志。
+
+### 前置条件
+
+部署时需要将 Docker Socket 挂载到容器内:
+```bash
+-v /var/run/docker.sock:/var/run/docker.sock
+```
+
+### 请求参数
+
+| 字段名 | 类型 | 必填 | 说明 |
+| :--- | :--- | :--- | :--- |
+| `taskId` | string | 是 | 任务ID,例如 `b07cbbc8-1f8f-4060-98f8-051b66ccf1ab` |
+
+### 响应参数(`data` 字段)
+
+返回匹配的日志行列表(`List<String>`),每行包含时间戳和日志内容。
+
+### 示例请求
+
+```http
+GET /v2/ai/order/v2/taskLog?taskId=b07cbbc8-1f8f-4060-98f8-051b66ccf1ab
+```
+
+### 示例响应
+
+```json
+{
+  "code": 200,
+  "message": "success",
+  "success": true,
+  "data": [
+    "2026-03-18T10:05:00.123456789Z INFO [task] Processing task b07cbbc8-1f8f-4060-98f8-051b66ccf1ab",
+    "2026-03-18T10:05:01.234567890Z INFO [task] Task b07cbbc8-1f8f-4060-98f8-051b66ccf1ab completed"
+  ],
+  "timestamp": 1773033600000
+}
+```
+
+### 示例响应(Docker Socket未挂载)
+
+```json
+{
+  "code": 500,
+  "message": "Docker socket 不存在: /var/run/docker.sock,请确认已挂载 -v /var/run/docker.sock:/var/run/docker.sock",
+  "success": false,
+  "data": null,
+  "timestamp": 1773033600000
+}
+```

+ 19 - 0
src/main/java/com/aitori/config/DockerProperties.java

@@ -0,0 +1,19 @@
+package com.aitori.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Getter
+@Setter
+@Component
+@ConfigurationProperties(prefix = "docker")
+public class DockerProperties {
+
+    private String socketPath = "/var/run/docker.sock";
+
+    private String defaultContainer = "ai-flow-py";
+
+    private int logTailLines = 10000;
+}

+ 19 - 0
src/main/java/com/aitori/controller/TAiOrderV2Controller.java

@@ -6,6 +6,7 @@ import com.aitori.entity.TAiOrderV2;
 import com.aitori.entity.message.WorkflowTaskMessage;
 import com.aitori.entity.request.AiOrderV2QueryRequest;
 import com.aitori.entity.vo.AiOrderV2VO;
+import com.aitori.service.DockerLogService;
 import com.aitori.service.TAiOrderV2Service;
 import com.aitori.service.WorkflowTaskResultHandler;
 import com.baomidou.mybatisplus.core.metadata.IPage;
@@ -17,6 +18,8 @@ import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.List;
+
 @Slf4j
 @RestController
 @RequestMapping("/v2/ai/order/v2")
@@ -27,6 +30,7 @@ public class TAiOrderV2Controller {
     private final TAiOrderV2Service aiOrderV2Service;
     private final WorkflowTaskClient workflowTaskClient;
     private final WorkflowTaskResultHandler resultHandler;
+    private final DockerLogService dockerLogService;
 
     @GetMapping("/list")
     @Operation(summary = "订单V2列表", description = "分页查询订单V2列表")
@@ -57,4 +61,19 @@ public class TAiOrderV2Controller {
             return R.fail("查询异常: " + e.getMessage());
         }
     }
+
+    @GetMapping("/taskLog")
+    @Operation(summary = "查询任务执行日志", description = "根据taskId查询ai-flow-py容器中的执行日志")
+    public R<List<String>> taskLog(
+            @Parameter(description = "任务ID", required = true, example = "b07cbbc8-1f8f-4060-98f8-051b66ccf1ab")
+            @RequestParam String taskId) {
+        log.info("查询任务执行日志, taskId={}", taskId);
+        try {
+            List<String> lines = dockerLogService.queryLogs(null, taskId, null);
+            return R.success(lines);
+        } catch (Exception e) {
+            log.error("查询任务执行日志异常, taskId={}", taskId, e);
+            return R.fail(e.getMessage());
+        }
+    }
 }

+ 108 - 0
src/main/java/com/aitori/service/DockerLogService.java

@@ -0,0 +1,108 @@
+package com.aitori.service;
+
+import com.aitori.config.DockerProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.BufferedReader;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class DockerLogService {
+
+    private final DockerProperties dockerProperties;
+
+    /**
+     * 通过 Docker Engine API 查询容器日志并按关键词过滤。
+     * 需要宿主机将 /var/run/docker.sock 挂载到容器内。
+     */
+    public List<String> queryLogs(String containerName, String keyword, Integer tailLines) {
+        if (containerName == null || containerName.isBlank()) {
+            containerName = dockerProperties.getDefaultContainer();
+        }
+        if (tailLines == null || tailLines <= 0) {
+            tailLines = dockerProperties.getLogTailLines();
+        }
+
+        String socketPath = dockerProperties.getSocketPath();
+        if (!Files.exists(Path.of(socketPath))) {
+            throw new IllegalStateException(
+                    "Docker socket 不存在: " + socketPath + ",请确认已挂载 -v /var/run/docker.sock:/var/run/docker.sock");
+        }
+
+        String url = String.format(
+                "http://localhost/v1.44/containers/%s/logs?stdout=true&stderr=true&tail=%d&timestamps=true",
+                containerName, tailLines);
+
+        ProcessBuilder pb = new ProcessBuilder(
+                "curl", "--silent", "--no-buffer",
+                "--unix-socket", socketPath,
+                url
+        );
+        pb.redirectErrorStream(true);
+
+        try {
+            Process process = pb.start();
+            List<String> matchedLines = new ArrayList<>();
+
+            try (InputStream is = process.getInputStream();
+                 BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
+
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    String cleaned = stripDockerStreamHeader(line);
+                    if (keyword == null || keyword.isBlank() || cleaned.contains(keyword)) {
+                        matchedLines.add(cleaned);
+                    }
+                }
+            }
+
+            boolean finished = process.waitFor(30, TimeUnit.SECONDS);
+            if (!finished) {
+                process.destroyForcibly();
+                log.warn("查询Docker日志超时, container={}", containerName);
+            }
+
+            int exitCode = finished ? process.exitValue() : -1;
+            if (exitCode != 0 && matchedLines.isEmpty()) {
+                log.error("curl 退出码={}, container={}", exitCode, containerName);
+                throw new RuntimeException("查询Docker日志失败,请检查容器名称是否正确: " + containerName);
+            }
+
+            log.info("查询Docker日志完成, container={}, keyword={}, matched={}", containerName, keyword, matchedLines.size());
+            return matchedLines;
+
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException("查询Docker日志被中断", e);
+        } catch (Exception e) {
+            log.error("查询Docker日志异常, container={}, keyword={}", containerName, keyword, e);
+            throw new RuntimeException("查询Docker日志异常: " + e.getMessage(), e);
+        }
+    }
+
+    /**
+     * Docker 非 TTY 模式日志的每一帧带有 8 字节二进制头,
+     * 这里尝试剥离行首的不可打印字符。
+     */
+    private String stripDockerStreamHeader(String line) {
+        if (line == null || line.isEmpty()) {
+            return line;
+        }
+        int start = 0;
+        while (start < line.length() && (line.charAt(start) < 0x20 && line.charAt(start) != '\t')) {
+            start++;
+        }
+        return start > 0 ? line.substring(start) : line;
+    }
+}

+ 6 - 0
src/main/resources/application.yaml

@@ -68,6 +68,12 @@ knife4j:
     password: ${KNIFE4J_PASSWORD:Goodsense1}
   cors: false
 
+# --- docker ---
+docker:
+  socket-path: ${DOCKER_SOCKET_PATH:/var/run/docker.sock}
+  default-container: ${DOCKER_LOG_CONTAINER:ai-flow-py}
+  log-tail-lines: ${DOCKER_LOG_TAIL:10000}
+
 # --- forest  ---
 forest:
   backend: okhttp3