瀏覽代碼

add-d4-metrics

Qyanger 4 月之前
父節點
當前提交
9b7e73aca3

+ 6 - 0
.dockerignore

@@ -0,0 +1,6 @@
+target/
+.git/
+.idea/
+.vscode/
+*.iml
+*.log

+ 19 - 0
.gitignore

@@ -1,4 +1,23 @@
 # ---> VisualStudioCode
 .settings
 
+# Java
+target/
+*.class
+*.jar
+.mvn/wrapper/apache-maven-*/
+.mvn/wrapper/apache-maven-*.zip
+.mvn/wrapper/apache-maven-*.tar.gz
+
+# IDE
+.idea/
+.vscode/
+*.iml
+
+# Logs
+*.log
+
+# Local I Don't Want to Share
+d4-metrics-90284c04.md
+
 

+ 2 - 0
.mvn/wrapper/maven-wrapper.properties

@@ -0,0 +1,2 @@
+distributionUrl=https://archive.apache.org/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.zip
+distributionSha256Sum=a555254d6b53d267965a3404ecb14e53c3827c09c3b94b5678835887ab404556

+ 12 - 0
Dockerfile

@@ -0,0 +1,12 @@
+FROM maven:3.9-eclipse-temurin-17 AS build
+WORKDIR /workspace
+COPY pom.xml .
+COPY src ./src
+RUN mvn -B package
+
+FROM eclipse-temurin:17-jre
+WORKDIR /app
+ENV SERVER_PORT=8080
+COPY --from=build /workspace/target/seecoder-analysis-backend-0.0.1-SNAPSHOT.jar /app/app.jar
+EXPOSE 8080
+ENTRYPOINT ["java", "-jar", "/app/app.jar"]

+ 81 - 0
README.md

@@ -1,2 +1,83 @@
 # seecoder-analysis-backend
 
+SeeCoder 日志分析后端,当前实现 D4 指标接口。服务从 Elasticsearch 的 `k8s-logs-*` 索引读取业务埋点日志,解析 `business_info` 中的 `key=value|key=value` 字段并返回前端可直接使用的统计结果。
+
+## 技术栈
+
+- Java 17
+- Spring Boot 3.3.5
+- Elasticsearch Java Client / Spring Boot Data Elasticsearch
+- Docker 多阶段构建
+
+## 配置
+
+云平台部署时通过环境变量配置 Elasticsearch:
+
+```bash
+ELASTICSEARCH_URIS=http://environment-27-45.seec.svc.cluster.local:9200
+ELASTICSEARCH_USERNAME=
+ELASTICSEARCH_PASSWORD=
+```
+
+可选配置:
+
+```bash
+D4_METRICS_INDEX_PATTERN=k8s-logs-*
+D4_METRICS_DEFAULT_RANGE_DAYS=30
+D4_METRICS_PAGE_SIZE=500
+D4_METRICS_MAX_EVENTS_TO_SCAN=10000
+D4_METRICS_DEFAULT_TASK_SLA_MS=86400000
+SERVER_PORT=8080
+```
+
+## 接口
+
+```http
+GET /api/d4/metrics
+```
+
+查询参数:
+
+- `from`: 起始时间,ISO-8601,例如 `2026-03-30T00:00:00Z`
+- `to`: 结束时间,ISO-8601
+- `projectId`: 可选,按项目过滤
+- `taskType`: 可选,例如 `fileReview` 或 `branchReview`
+- `userId`: 可选,匹配 `creatorId/reviewerId/operatorId/resolverId/receiverId`
+- `slaMs`: 可选,按时完成 SLA,默认 1 天
+
+示例:
+
+```bash
+curl "http://localhost:8080/api/d4/metrics?from=2026-03-30T00:00:00Z&to=2026-03-31T00:00:00Z&projectId=50"
+```
+
+返回指标包含:
+
+- 协作任务完成率:`completedCount / assignedCount`
+- 按时完成率:`taskDurationMs <= slaMs` 的完成任务数 / 完成任务总数
+- 团队沟通平均响应耗时:`avg(responseMs)`
+- 有效协作次数:`count(D4_COLLAB_EFFECTIVE)`
+- 协作冲突解决次数和解决率:`resolved / created`
+
+## 本地运行
+
+```bash
+./mvnw spring-boot:run
+```
+
+## 构建与部署
+
+```bash
+./mvnw clean package
+docker build -t seecoder-analysis-backend:latest .
+docker run --rm -p 8080:8080 \
+  -e ELASTICSEARCH_URIS=http://localhost:9200 \
+  seecoder-analysis-backend:latest
+```
+
+健康检查:
+
+```http
+GET /actuator/health
+```
+

+ 2 - 0
mvn

@@ -0,0 +1,2 @@
+#!/usr/bin/env sh
+exec "$(dirname "$0")/mvnw" "$@"

+ 2 - 0
mvn.cmd

@@ -0,0 +1,2 @@
+@echo off
+call "%~dp0mvnw.cmd" %*

+ 24 - 0
mvnw

@@ -0,0 +1,24 @@
+#!/usr/bin/env sh
+set -eu
+
+MVN_VERSION="3.9.9"
+BASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
+WRAPPER_DIR="$BASE_DIR/.mvn/wrapper"
+MVN_HOME="$WRAPPER_DIR/apache-maven-$MVN_VERSION"
+ARCHIVE="$WRAPPER_DIR/apache-maven-$MVN_VERSION-bin.tar.gz"
+URL="https://archive.apache.org/dist/maven/maven-3/$MVN_VERSION/binaries/apache-maven-$MVN_VERSION-bin.tar.gz"
+
+if [ ! -x "$MVN_HOME/bin/mvn" ]; then
+  mkdir -p "$WRAPPER_DIR"
+  if command -v curl >/dev/null 2>&1; then
+    curl -fsSL "$URL" -o "$ARCHIVE"
+  elif command -v wget >/dev/null 2>&1; then
+    wget -q "$URL" -O "$ARCHIVE"
+  else
+    echo "curl or wget is required to download Maven $MVN_VERSION" >&2
+    exit 1
+  fi
+  tar -xzf "$ARCHIVE" -C "$WRAPPER_DIR"
+fi
+
+exec "$MVN_HOME/bin/mvn" "$@"

+ 16 - 0
mvnw.cmd

@@ -0,0 +1,16 @@
+@echo off
+setlocal
+
+set "MVN_VERSION=3.9.9"
+set "BASE_DIR=%~dp0"
+set "WRAPPER_DIR=%BASE_DIR%.mvn\wrapper"
+set "MVN_HOME=%WRAPPER_DIR%\apache-maven-%MVN_VERSION%"
+
+if not exist "%MVN_HOME%\bin\mvn.cmd" (
+  if not exist "%WRAPPER_DIR%" mkdir "%WRAPPER_DIR%"
+  set "MAVEN_WRAPPER_DIR=%WRAPPER_DIR%"
+  powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $version='%MVN_VERSION%'; $url='https://archive.apache.org/dist/maven/maven-3/' + $version + '/binaries/apache-maven-' + $version + '-bin.zip'; $zip=Join-Path $env:TEMP ('apache-maven-' + $version + '-bin.zip'); Invoke-WebRequest -Uri $url -OutFile $zip; Expand-Archive -Path $zip -DestinationPath $env:MAVEN_WRAPPER_DIR -Force"
+  if errorlevel 1 exit /b 1
+)
+
+call "%MVN_HOME%\bin\mvn.cmd" %*

+ 63 - 0
pom.xml

@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>3.3.5</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>cn.seecoder</groupId>
+    <artifactId>seecoder-analysis-backend</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <name>seecoder-analysis-backend</name>
+    <description>Backend service for SeeCoder analysis metrics.</description>
+
+    <properties>
+        <java.version>17</java.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 15 - 0
src/main/java/cn/seecoder/analysis/SeecoderAnalysisBackendApplication.java

@@ -0,0 +1,15 @@
+package cn.seecoder.analysis;
+
+import cn.seecoder.analysis.config.D4MetricsProperties;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+@SpringBootApplication
+@EnableConfigurationProperties(D4MetricsProperties.class)
+public class SeecoderAnalysisBackendApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(SeecoderAnalysisBackendApplication.class, args);
+    }
+}

+ 67 - 0
src/main/java/cn/seecoder/analysis/config/D4MetricsProperties.java

@@ -0,0 +1,67 @@
+package cn.seecoder.analysis.config;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.validation.annotation.Validated;
+
+@Validated
+@ConfigurationProperties(prefix = "d4.metrics")
+public class D4MetricsProperties {
+
+    @NotBlank
+    private String indexPattern = "k8s-logs-*";
+
+    @Min(1)
+    private int defaultRangeDays = 30;
+
+    @Min(1)
+    private int pageSize = 500;
+
+    @Min(1)
+    @Max(10000)
+    private int maxEventsToScan = 10000;
+
+    private Long defaultTaskSlaMs = 86_400_000L;
+
+    public String getIndexPattern() {
+        return indexPattern;
+    }
+
+    public void setIndexPattern(String indexPattern) {
+        this.indexPattern = indexPattern;
+    }
+
+    public int getDefaultRangeDays() {
+        return defaultRangeDays;
+    }
+
+    public void setDefaultRangeDays(int defaultRangeDays) {
+        this.defaultRangeDays = defaultRangeDays;
+    }
+
+    public int getPageSize() {
+        return pageSize;
+    }
+
+    public void setPageSize(int pageSize) {
+        this.pageSize = pageSize;
+    }
+
+    public int getMaxEventsToScan() {
+        return maxEventsToScan;
+    }
+
+    public void setMaxEventsToScan(int maxEventsToScan) {
+        this.maxEventsToScan = maxEventsToScan;
+    }
+
+    public Long getDefaultTaskSlaMs() {
+        return defaultTaskSlaMs;
+    }
+
+    public void setDefaultTaskSlaMs(Long defaultTaskSlaMs) {
+        this.defaultTaskSlaMs = defaultTaskSlaMs;
+    }
+}

+ 43 - 0
src/main/java/cn/seecoder/analysis/d4/BusinessInfoParser.java

@@ -0,0 +1,43 @@
+package cn.seecoder.analysis.d4;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public final class BusinessInfoParser {
+
+    private BusinessInfoParser() {
+    }
+
+    public static Map<String, String> parse(String businessInfo) {
+        Map<String, String> values = new LinkedHashMap<>();
+        if (businessInfo == null || businessInfo.isBlank()) {
+            return values;
+        }
+
+        String[] parts = businessInfo.split("\\|");
+        for (String part : parts) {
+            int separator = part.indexOf('=');
+            if (separator <= 0) {
+                continue;
+            }
+            String key = part.substring(0, separator).trim();
+            String value = part.substring(separator + 1).trim();
+            if (!key.isEmpty()) {
+                values.put(key, value);
+            }
+        }
+        return values;
+    }
+
+    public static Long longValue(Map<String, String> info, String key) {
+        String value = info.get(key);
+        if (value == null || value.isBlank() || "null".equalsIgnoreCase(value)) {
+            return null;
+        }
+        try {
+            return Long.parseLong(value);
+        } catch (NumberFormatException ignored) {
+            return null;
+        }
+    }
+}

+ 33 - 0
src/main/java/cn/seecoder/analysis/d4/D4EventType.java

@@ -0,0 +1,33 @@
+package cn.seecoder.analysis.d4;
+
+import java.util.Set;
+
+public enum D4EventType {
+    TASK_ASSIGNED("D4_TASK_ASSIGNED"),
+    TASK_COMPLETED("D4_TASK_COMPLETED"),
+    COLLAB_EFFECTIVE("D4_COLLAB_EFFECTIVE"),
+    COLLAB_CONFLICT_CREATED("D4_COLLAB_CONFLICT_CREATED"),
+    COLLAB_CONFLICT_RESOLVED("D4_COLLAB_CONFLICT_RESOLVED"),
+    COMM_MESSAGE_SENT("D4_COMM_MESSAGE_SENT"),
+    COMM_MESSAGE_RESPONSE("D4_COMM_MESSAGE_RESPONSE");
+
+    public static final Set<String> ALL_BIZ_NAMES = Set.of(
+            TASK_ASSIGNED.bizName,
+            TASK_COMPLETED.bizName,
+            COLLAB_EFFECTIVE.bizName,
+            COLLAB_CONFLICT_CREATED.bizName,
+            COLLAB_CONFLICT_RESOLVED.bizName,
+            COMM_MESSAGE_SENT.bizName,
+            COMM_MESSAGE_RESPONSE.bizName
+    );
+
+    private final String bizName;
+
+    D4EventType(String bizName) {
+        this.bizName = bizName;
+    }
+
+    public String bizName() {
+        return bizName;
+    }
+}

+ 21 - 0
src/main/java/cn/seecoder/analysis/d4/D4MetricQuery.java

@@ -0,0 +1,21 @@
+package cn.seecoder.analysis.d4;
+
+import java.time.Instant;
+import java.util.Objects;
+
+public record D4MetricQuery(
+        Instant from,
+        Instant to,
+        Long projectId,
+        String taskType,
+        Long userId,
+        Long slaMs
+) {
+    public D4MetricQuery {
+        Objects.requireNonNull(from, "from must not be null");
+        Objects.requireNonNull(to, "to must not be null");
+        if (!from.isBefore(to)) {
+            throw new IllegalArgumentException("from must be earlier than to");
+        }
+    }
+}

+ 58 - 0
src/main/java/cn/seecoder/analysis/d4/D4MetricsController.java

@@ -0,0 +1,58 @@
+package cn.seecoder.analysis.d4;
+
+import cn.seecoder.analysis.config.D4MetricsProperties;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.CrossOrigin;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+@RestController
+@CrossOrigin
+public class D4MetricsController {
+
+    private final D4MetricsService metricsService;
+    private final D4MetricsProperties properties;
+
+    public D4MetricsController(D4MetricsService metricsService, D4MetricsProperties properties) {
+        this.metricsService = metricsService;
+        this.properties = properties;
+    }
+
+    @GetMapping({"/api/d4/metrics", "/api/v1/d4/metrics"})
+    public ResponseEntity<D4MetricsResponse> getMetrics(
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
+            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
+            @RequestParam(required = false) Long projectId,
+            @RequestParam(required = false) String taskType,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) Long slaMs
+    ) {
+        Instant effectiveTo = to == null ? Instant.now() : to;
+        Instant effectiveFrom = from == null
+                ? effectiveTo.minus(properties.getDefaultRangeDays(), ChronoUnit.DAYS)
+                : from;
+        Long effectiveSlaMs = slaMs == null ? properties.getDefaultTaskSlaMs() : slaMs;
+
+        D4MetricQuery query = new D4MetricQuery(
+                effectiveFrom,
+                effectiveTo,
+                projectId,
+                normalize(taskType),
+                userId,
+                effectiveSlaMs
+        );
+        return ResponseEntity.ok(metricsService.calculate(query));
+    }
+
+    private String normalize(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        return value.trim();
+    }
+}

+ 8 - 0
src/main/java/cn/seecoder/analysis/d4/D4MetricsQueryException.java

@@ -0,0 +1,8 @@
+package cn.seecoder.analysis.d4;
+
+public class D4MetricsQueryException extends RuntimeException {
+
+    public D4MetricsQueryException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}

+ 61 - 0
src/main/java/cn/seecoder/analysis/d4/D4MetricsResponse.java

@@ -0,0 +1,61 @@
+package cn.seecoder.analysis.d4;
+
+import java.time.Instant;
+import java.util.Map;
+
+public record D4MetricsResponse(
+        Instant from,
+        Instant to,
+        Filter filter,
+        TaskCompletionMetric taskCompletion,
+        CommunicationMetric communication,
+        CollaborationMetric collaboration,
+        ConflictMetric conflict,
+        Map<String, Long> eventCounts,
+        ScanInfo scan
+) {
+
+    public record Filter(
+            Long projectId,
+            String taskType,
+            Long userId,
+            Long slaMs
+    ) {
+    }
+
+    public record TaskCompletionMetric(
+            long assignedCount,
+            long completedCount,
+            Double completionRate,
+            Long onTimeCompletedCount,
+            Double onTimeCompletionRate
+    ) {
+    }
+
+    public record CommunicationMetric(
+            long sentMessageCount,
+            long respondedMessageCount,
+            Double averageResponseMs
+    ) {
+    }
+
+    public record CollaborationMetric(
+            long effectiveCollaborationCount
+    ) {
+    }
+
+    public record ConflictMetric(
+            long createdCount,
+            long resolvedCount,
+            Double resolutionRate
+    ) {
+    }
+
+    public record ScanInfo(
+            String indexPattern,
+            int scannedEvents,
+            int maxEventsToScan,
+            boolean truncated
+    ) {
+    }
+}

+ 228 - 0
src/main/java/cn/seecoder/analysis/d4/D4MetricsService.java

@@ -0,0 +1,228 @@
+package cn.seecoder.analysis.d4;
+
+import cn.seecoder.analysis.config.D4MetricsProperties;
+import co.elastic.clients.elasticsearch.ElasticsearchClient;
+import co.elastic.clients.elasticsearch._types.FieldValue;
+import co.elastic.clients.elasticsearch._types.SortOrder;
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.elasticsearch.core.SearchResponse;
+import co.elastic.clients.elasticsearch.core.search.Hit;
+import co.elastic.clients.json.JsonData;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+
+@Service
+public class D4MetricsService {
+
+    private static final List<String> USER_FIELDS = List.of(
+            "creatorId",
+            "receiverId",
+            "reviewerId",
+            "operatorId",
+            "resolverId"
+    );
+
+    private final ElasticsearchClient elasticsearchClient;
+    private final D4MetricsProperties properties;
+
+    public D4MetricsService(ElasticsearchClient elasticsearchClient, D4MetricsProperties properties) {
+        this.elasticsearchClient = elasticsearchClient;
+        this.properties = properties;
+    }
+
+    public D4MetricsResponse calculate(D4MetricQuery metricQuery) {
+        AggregationState state = new AggregationState(metricQuery);
+        int scanned = 0;
+        int offset = 0;
+        boolean truncated = false;
+
+        while (scanned < properties.getMaxEventsToScan()) {
+            int size = Math.min(properties.getPageSize(), properties.getMaxEventsToScan() - scanned);
+            SearchResponse<LogEvent> response = search(metricQuery, offset, size);
+            List<Hit<LogEvent>> hits = response.hits().hits();
+            if (hits.isEmpty()) {
+                break;
+            }
+
+            for (Hit<LogEvent> hit : hits) {
+                LogEvent event = hit.source();
+                if (event == null) {
+                    continue;
+                }
+                scanned++;
+                state.accept(event);
+            }
+
+            if (hits.size() < size) {
+                break;
+            }
+            offset += hits.size();
+            truncated = scanned >= properties.getMaxEventsToScan();
+        }
+
+        return state.toResponse(properties.getIndexPattern(), scanned, properties.getMaxEventsToScan(), truncated);
+    }
+
+    private SearchResponse<LogEvent> search(D4MetricQuery metricQuery, int from, int size) {
+        Query query = Query.of(q -> q.bool(b -> b
+                .filter(f -> f.range(r -> r
+                        .field("@timestamp")
+                        .gte(JsonData.of(metricQuery.from().toString()))
+                        .lt(JsonData.of(metricQuery.to().toString()))))
+                .filter(f -> f.terms(t -> t
+                        .field("bizName.keyword")
+                        .terms(v -> v.value(D4EventType.ALL_BIZ_NAMES.stream()
+                                .map(FieldValue::of)
+                                .toList()))))));
+
+        try {
+            return elasticsearchClient.search(s -> s
+                            .index(properties.getIndexPattern())
+                            .ignoreUnavailable(true)
+                            .allowNoIndices(true)
+                            .from(from)
+                            .size(size)
+                            .query(query)
+                            .sort(sort -> sort.field(f -> f.field("@timestamp").order(SortOrder.Asc))),
+                    LogEvent.class);
+        } catch (IOException ex) {
+            throw new D4MetricsQueryException("Failed to query D4 logs from Elasticsearch", ex);
+        }
+    }
+
+    private static final class AggregationState {
+
+        private final D4MetricQuery query;
+        private final Map<String, Long> eventCounts = new LinkedHashMap<>();
+        private long assignedCount;
+        private long completedCount;
+        private long onTimeCompletedCount;
+        private long sentMessageCount;
+        private long respondedMessageCount;
+        private long responseMsSum;
+        private long effectiveCollaborationCount;
+        private long conflictCreatedCount;
+        private long conflictResolvedCount;
+
+        private AggregationState(D4MetricQuery query) {
+            this.query = query;
+            D4EventType.ALL_BIZ_NAMES.stream()
+                    .sorted()
+                    .forEach(bizName -> eventCounts.put(bizName, 0L));
+        }
+
+        private void accept(LogEvent event) {
+            if (isDuplicateConflictTroubleshootingLog(event)) {
+                return;
+            }
+
+            Map<String, String> businessInfo = event.parsedBusinessInfo();
+            if (!matchesFilters(businessInfo)) {
+                return;
+            }
+
+            eventCounts.computeIfPresent(event.getBizName(), (key, value) -> value + 1);
+
+            if (D4EventType.TASK_ASSIGNED.bizName().equals(event.getBizName())) {
+                assignedCount++;
+                return;
+            }
+            if (D4EventType.TASK_COMPLETED.bizName().equals(event.getBizName())) {
+                completedCount++;
+                Long taskDurationMs = BusinessInfoParser.longValue(businessInfo, "taskDurationMs");
+                if (query.slaMs() != null && taskDurationMs != null && taskDurationMs <= query.slaMs()) {
+                    onTimeCompletedCount++;
+                }
+                return;
+            }
+            if (D4EventType.COMM_MESSAGE_SENT.bizName().equals(event.getBizName())) {
+                sentMessageCount++;
+                return;
+            }
+            if (D4EventType.COMM_MESSAGE_RESPONSE.bizName().equals(event.getBizName())) {
+                Long responseMs = BusinessInfoParser.longValue(businessInfo, "responseMs");
+                if (responseMs != null) {
+                    respondedMessageCount++;
+                    responseMsSum += responseMs;
+                }
+                return;
+            }
+            if (D4EventType.COLLAB_EFFECTIVE.bizName().equals(event.getBizName())) {
+                effectiveCollaborationCount++;
+                return;
+            }
+            if (D4EventType.COLLAB_CONFLICT_CREATED.bizName().equals(event.getBizName())) {
+                conflictCreatedCount++;
+                return;
+            }
+            if (D4EventType.COLLAB_CONFLICT_RESOLVED.bizName().equals(event.getBizName())) {
+                conflictResolvedCount++;
+            }
+        }
+
+        private boolean matchesFilters(Map<String, String> businessInfo) {
+            if (query.projectId() != null && !Objects.equals(query.projectId(), BusinessInfoParser.longValue(businessInfo, "projectId"))) {
+                return false;
+            }
+            if (query.taskType() != null) {
+                String taskType = businessInfo.get("taskType");
+                if (taskType == null || !taskType.equalsIgnoreCase(query.taskType())) {
+                    return false;
+                }
+            }
+            if (query.userId() != null) {
+                return USER_FIELDS.stream()
+                        .map(field -> BusinessInfoParser.longValue(businessInfo, field))
+                        .anyMatch(query.userId()::equals);
+            }
+            return true;
+        }
+
+        private boolean isDuplicateConflictTroubleshootingLog(LogEvent event) {
+            return D4EventType.COLLAB_CONFLICT_CREATED.bizName().equals(event.getBizName())
+                    && event.getLogLevel() != null
+                    && "ERROR".equals(event.getLogLevel().toUpperCase(Locale.ROOT));
+        }
+
+        private D4MetricsResponse toResponse(String indexPattern, int scanned, int maxEventsToScan, boolean truncated) {
+            return new D4MetricsResponse(
+                    query.from(),
+                    query.to(),
+                    new D4MetricsResponse.Filter(query.projectId(), query.taskType(), query.userId(), query.slaMs()),
+                    new D4MetricsResponse.TaskCompletionMetric(
+                            assignedCount,
+                            completedCount,
+                            rate(completedCount, assignedCount),
+                            query.slaMs() == null ? null : onTimeCompletedCount,
+                            query.slaMs() == null ? null : rate(onTimeCompletedCount, completedCount)
+                    ),
+                    new D4MetricsResponse.CommunicationMetric(
+                            sentMessageCount,
+                            respondedMessageCount,
+                            respondedMessageCount == 0 ? null : (double) responseMsSum / respondedMessageCount
+                    ),
+                    new D4MetricsResponse.CollaborationMetric(effectiveCollaborationCount),
+                    new D4MetricsResponse.ConflictMetric(
+                            conflictCreatedCount,
+                            conflictResolvedCount,
+                            rate(conflictResolvedCount, conflictCreatedCount)
+                    ),
+                    eventCounts,
+                    new D4MetricsResponse.ScanInfo(indexPattern, scanned, maxEventsToScan, truncated)
+            );
+        }
+
+        private Double rate(long numerator, long denominator) {
+            if (denominator == 0) {
+                return null;
+            }
+            return (double) numerator / denominator;
+        }
+    }
+}

+ 60 - 0
src/main/java/cn/seecoder/analysis/d4/LogEvent.java

@@ -0,0 +1,60 @@
+package cn.seecoder.analysis.d4;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.Map;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class LogEvent {
+
+    private String bizName;
+
+    private String businessInfo;
+
+    private String logLevel;
+
+    private String traceId;
+
+    public String getBizName() {
+        return bizName;
+    }
+
+    public void setBizName(String bizName) {
+        this.bizName = bizName;
+    }
+
+    @JsonProperty("business_info")
+    public String getBusinessInfo() {
+        return businessInfo;
+    }
+
+    @JsonProperty("business_info")
+    public void setBusinessInfo(String businessInfo) {
+        this.businessInfo = businessInfo;
+    }
+
+    @JsonProperty("log_level")
+    public String getLogLevel() {
+        return logLevel;
+    }
+
+    @JsonProperty("log_level")
+    public void setLogLevel(String logLevel) {
+        this.logLevel = logLevel;
+    }
+
+    @JsonProperty("trace_id")
+    public String getTraceId() {
+        return traceId;
+    }
+
+    @JsonProperty("trace_id")
+    public void setTraceId(String traceId) {
+        this.traceId = traceId;
+    }
+
+    public Map<String, String> parsedBusinessInfo() {
+        return BusinessInfoParser.parse(businessInfo);
+    }
+}

+ 12 - 0
src/main/java/cn/seecoder/analysis/web/ApiError.java

@@ -0,0 +1,12 @@
+package cn.seecoder.analysis.web;
+
+import java.time.Instant;
+
+public record ApiError(
+        Instant timestamp,
+        int status,
+        String error,
+        String message,
+        String path
+) {
+}

+ 41 - 0
src/main/java/cn/seecoder/analysis/web/GlobalExceptionHandler.java

@@ -0,0 +1,41 @@
+package cn.seecoder.analysis.web;
+
+import cn.seecoder.analysis.d4.D4MetricsQueryException;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+import java.time.Instant;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+    @ExceptionHandler({
+            IllegalArgumentException.class,
+            MethodArgumentNotValidException.class,
+            MethodArgumentTypeMismatchException.class
+    })
+    public ResponseEntity<ApiError> handleBadRequest(Exception ex, HttpServletRequest request) {
+        return build(HttpStatus.BAD_REQUEST, ex.getMessage(), request);
+    }
+
+    @ExceptionHandler(D4MetricsQueryException.class)
+    public ResponseEntity<ApiError> handleElasticsearchError(D4MetricsQueryException ex, HttpServletRequest request) {
+        return build(HttpStatus.BAD_GATEWAY, ex.getMessage(), request);
+    }
+
+    private ResponseEntity<ApiError> build(HttpStatus status, String message, HttpServletRequest request) {
+        ApiError body = new ApiError(
+                Instant.now(),
+                status.value(),
+                status.getReasonPhrase(),
+                message,
+                request.getRequestURI()
+        );
+        return ResponseEntity.status(status).body(body);
+    }
+}

+ 28 - 0
src/main/resources/application.yml

@@ -0,0 +1,28 @@
+server:
+  port: ${SERVER_PORT:8080}
+
+spring:
+  application:
+    name: seecoder-analysis-backend
+  elasticsearch:
+    uris: ${ELASTICSEARCH_URIS:http://localhost:9200}
+    username: ${ELASTICSEARCH_USERNAME:}
+    password: ${ELASTICSEARCH_PASSWORD:}
+
+d4:
+  metrics:
+    index-pattern: ${D4_METRICS_INDEX_PATTERN:k8s-logs-*}
+    default-range-days: ${D4_METRICS_DEFAULT_RANGE_DAYS:30}
+    page-size: ${D4_METRICS_PAGE_SIZE:500}
+    max-events-to-scan: ${D4_METRICS_MAX_EVENTS_TO_SCAN:10000}
+    default-task-sla-ms: ${D4_METRICS_DEFAULT_TASK_SLA_MS:86400000}
+
+management:
+  endpoints:
+    web:
+      exposure:
+        include: health,info
+  endpoint:
+    health:
+      probes:
+        enabled: true

+ 26 - 0
src/test/java/cn/seecoder/analysis/d4/BusinessInfoParserTests.java

@@ -0,0 +1,26 @@
+package cn.seecoder.analysis.d4;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class BusinessInfoParserTests {
+
+    @Test
+    void parsesPipeSeparatedBusinessInfo() {
+        Map<String, String> info = BusinessInfoParser.parse(
+                "messageId=null|creatorId=-1|receiverId=1008|linkTo=/project/50/filereviewdetail?fileReviewId=1|ts=1774804054416"
+        );
+
+        assertThat(info)
+                .containsEntry("messageId", "null")
+                .containsEntry("creatorId", "-1")
+                .containsEntry("receiverId", "1008")
+                .containsEntry("linkTo", "/project/50/filereviewdetail?fileReviewId=1")
+                .containsEntry("ts", "1774804054416");
+        assertThat(BusinessInfoParser.longValue(info, "receiverId")).isEqualTo(1008L);
+        assertThat(BusinessInfoParser.longValue(info, "messageId")).isNull();
+    }
+}