diff --git a/xuqm-log-service/pom.xml b/xuqm-log-service/pom.xml
new file mode 100644
index 0000000..07837b5
--- /dev/null
+++ b/xuqm-log-service/pom.xml
@@ -0,0 +1,75 @@
+
+
+ 4.0.0
+
+
+ com.xuqm
+ xuqmgroup-server-parent
+ 0.1.0-SNAPSHOT
+ ../pom.xml
+
+
+ xuqm-log-service
+ xuqm-log-service
+ Log collection, dedup, symbolication, webhook notification & funnel analytics
+
+
+
+ com.xuqm
+ common
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ org.flywaydb
+ flyway-core
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/LogServiceApplication.java b/xuqm-log-service/src/main/java/com/xuqm/log/LogServiceApplication.java
new file mode 100644
index 0000000..48ce01e
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/LogServiceApplication.java
@@ -0,0 +1,15 @@
+package com.xuqm.log;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+@SpringBootApplication
+@ComponentScan(basePackages = {"com.xuqm.log", "com.xuqm.common"})
+@EnableAsync
+public class LogServiceApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(LogServiceApplication.class, args);
+ }
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/EventBatchRequest.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/EventBatchRequest.java
new file mode 100644
index 0000000..48f3f16
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/EventBatchRequest.java
@@ -0,0 +1,21 @@
+package com.xuqm.log.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+
+public record EventBatchRequest(
+ @JsonProperty("events") @NotEmpty List events
+) {
+ public record EventItem(
+ @NotBlank @JsonProperty("appKey") String appKey,
+ @NotBlank String name,
+ @JsonProperty("userId") String userId,
+ @JsonProperty("sessionId") String sessionId,
+ String properties,
+ String platform,
+ @JsonProperty("appVersion") String appVersion,
+ @JsonProperty("timestamp") long timestamp
+ ) {}
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueBatchRequest.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueBatchRequest.java
new file mode 100644
index 0000000..ba2abd2
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueBatchRequest.java
@@ -0,0 +1,23 @@
+package com.xuqm.log.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+
+public record IssueBatchRequest(
+ @JsonProperty("events") @NotEmpty List events
+) {
+ public record IssueEventItem(
+ @NotBlank String type,
+ String message,
+ String stack,
+ @NotBlank String fingerprint,
+ @JsonProperty("timestamp") long timestamp,
+ @JsonProperty("userId") String userId,
+ @JsonProperty("sessionId") String sessionId,
+ @NotBlank @JsonProperty("appKey") String appKey,
+ String platform,
+ @JsonProperty("appVersion") String appVersion
+ ) {}
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueEventResponse.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueEventResponse.java
new file mode 100644
index 0000000..40c404e
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueEventResponse.java
@@ -0,0 +1,19 @@
+package com.xuqm.log.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.LocalDateTime;
+
+public record IssueEventResponse(
+ Long id,
+ @JsonProperty("issueId") Long issueId,
+ @JsonProperty("appKey") String appKey,
+ @JsonProperty("userId") String userId,
+ @JsonProperty("sessionId") String sessionId,
+ String message,
+ String stack,
+ @JsonProperty("stackSymbolicated") String stackSymbolicated,
+ String metadata,
+ String platform,
+ @JsonProperty("appVersion") String appVersion,
+ @JsonProperty("createdAt") LocalDateTime createdAt
+) {}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueResponse.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueResponse.java
new file mode 100644
index 0000000..8cecdce
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/IssueResponse.java
@@ -0,0 +1,20 @@
+package com.xuqm.log.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record IssueResponse(
+ Long id,
+ @JsonProperty("appKey") String appKey,
+ String fingerprint,
+ String type,
+ String title,
+ @JsonProperty("firstSeenAt") LocalDateTime firstSeenAt,
+ @JsonProperty("lastSeenAt") LocalDateTime lastSeenAt,
+ int count,
+ @JsonProperty("isResolved") boolean isResolved,
+ String platform,
+ @JsonProperty("appVersion") String appVersion,
+ List events
+) {}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/WebhookRequest.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/WebhookRequest.java
new file mode 100644
index 0000000..ff0f893
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/WebhookRequest.java
@@ -0,0 +1,13 @@
+package com.xuqm.log.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+
+public record WebhookRequest(
+ @NotBlank @JsonProperty("appKey") String appKey,
+ @NotBlank String url,
+ @NotNull List events,
+ @JsonProperty("cooldownSec") int cooldownSec
+) {}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/dto/WebhookResponse.java b/xuqm-log-service/src/main/java/com/xuqm/log/dto/WebhookResponse.java
new file mode 100644
index 0000000..9c9fdf4
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/dto/WebhookResponse.java
@@ -0,0 +1,15 @@
+package com.xuqm.log.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.time.LocalDateTime;
+import java.util.List;
+
+public record WebhookResponse(
+ Long id,
+ @JsonProperty("appKey") String appKey,
+ String url,
+ List events,
+ @JsonProperty("cooldownSec") int cooldownSec,
+ boolean enabled,
+ @JsonProperty("createdAt") LocalDateTime createdAt
+) {}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogEventEntity.java b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogEventEntity.java
new file mode 100644
index 0000000..f9e85f9
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogEventEntity.java
@@ -0,0 +1,64 @@
+package com.xuqm.log.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "log_events")
+public class LogEventEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 64)
+ private String appKey;
+
+ @Column(nullable = false, length = 256)
+ private String name;
+
+ @Column(length = 128)
+ private String userId;
+
+ @Column(length = 128)
+ private String sessionId;
+
+ @Column(columnDefinition = "JSON")
+ private String properties;
+
+ @Column(length = 16)
+ private String platform;
+
+ @Column(length = 32)
+ private String appVersion;
+
+ @Column(nullable = false)
+ private LocalDateTime createdAt;
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getAppKey() { return appKey; }
+ public void setAppKey(String appKey) { this.appKey = appKey; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getUserId() { return userId; }
+ public void setUserId(String userId) { this.userId = userId; }
+
+ public String getSessionId() { return sessionId; }
+ public void setSessionId(String sessionId) { this.sessionId = sessionId; }
+
+ public String getProperties() { return properties; }
+ public void setProperties(String properties) { this.properties = properties; }
+
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getAppVersion() { return appVersion; }
+ public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogIssueEntity.java b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogIssueEntity.java
new file mode 100644
index 0000000..5e55475
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogIssueEntity.java
@@ -0,0 +1,76 @@
+package com.xuqm.log.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "log_issues")
+public class LogIssueEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 64)
+ private String appKey;
+
+ @Column(nullable = false, length = 64)
+ private String fingerprint;
+
+ @Column(nullable = false, length = 32)
+ private String type;
+
+ @Column(nullable = false, length = 500)
+ private String title;
+
+ @Column(nullable = false)
+ private LocalDateTime firstSeenAt;
+
+ @Column(nullable = false)
+ private LocalDateTime lastSeenAt;
+
+ @Column(nullable = false)
+ private int count = 1;
+
+ @Column(nullable = false)
+ private boolean isResolved = false;
+
+ @Column(length = 16)
+ private String platform;
+
+ @Column(length = 32)
+ private String appVersion;
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getAppKey() { return appKey; }
+ public void setAppKey(String appKey) { this.appKey = appKey; }
+
+ public String getFingerprint() { return fingerprint; }
+ public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
+
+ public String getType() { return type; }
+ public void setType(String type) { this.type = type; }
+
+ public String getTitle() { return title; }
+ public void setTitle(String title) { this.title = title; }
+
+ public LocalDateTime getFirstSeenAt() { return firstSeenAt; }
+ public void setFirstSeenAt(LocalDateTime firstSeenAt) { this.firstSeenAt = firstSeenAt; }
+
+ public LocalDateTime getLastSeenAt() { return lastSeenAt; }
+ public void setLastSeenAt(LocalDateTime lastSeenAt) { this.lastSeenAt = lastSeenAt; }
+
+ public int getCount() { return count; }
+ public void setCount(int count) { this.count = count; }
+
+ public boolean isResolved() { return isResolved; }
+ public void setResolved(boolean resolved) { isResolved = resolved; }
+
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getAppVersion() { return appVersion; }
+ public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogIssueEventEntity.java b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogIssueEventEntity.java
new file mode 100644
index 0000000..41cbdc4
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogIssueEventEntity.java
@@ -0,0 +1,82 @@
+package com.xuqm.log.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "log_issue_events")
+public class LogIssueEventEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private Long issueId;
+
+ @Column(nullable = false, length = 64)
+ private String appKey;
+
+ @Column(length = 128)
+ private String userId;
+
+ @Column(length = 128)
+ private String sessionId;
+
+ @Column(columnDefinition = "TEXT")
+ private String message;
+
+ @Column(columnDefinition = "LONGTEXT")
+ private String stack;
+
+ @Column(name = "stack_symbolicated", columnDefinition = "LONGTEXT")
+ private String stackSymbolicated;
+
+ @Column(columnDefinition = "JSON")
+ private String metadata;
+
+ @Column(length = 16)
+ private String platform;
+
+ @Column(length = 32)
+ private String appVersion;
+
+ @Column(nullable = false)
+ private LocalDateTime createdAt;
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public Long getIssueId() { return issueId; }
+ public void setIssueId(Long issueId) { this.issueId = issueId; }
+
+ public String getAppKey() { return appKey; }
+ public void setAppKey(String appKey) { this.appKey = appKey; }
+
+ public String getUserId() { return userId; }
+ public void setUserId(String userId) { this.userId = userId; }
+
+ public String getSessionId() { return sessionId; }
+ public void setSessionId(String sessionId) { this.sessionId = sessionId; }
+
+ public String getMessage() { return message; }
+ public void setMessage(String message) { this.message = message; }
+
+ public String getStack() { return stack; }
+ public void setStack(String stack) { this.stack = stack; }
+
+ public String getStackSymbolicated() { return stackSymbolicated; }
+ public void setStackSymbolicated(String stackSymbolicated) { this.stackSymbolicated = stackSymbolicated; }
+
+ public String getMetadata() { return metadata; }
+ public void setMetadata(String metadata) { this.metadata = metadata; }
+
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getAppVersion() { return appVersion; }
+ public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogSourcemapEntity.java b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogSourcemapEntity.java
new file mode 100644
index 0000000..4aea61d
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogSourcemapEntity.java
@@ -0,0 +1,52 @@
+package com.xuqm.log.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "log_sourcemaps")
+public class LogSourcemapEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 64)
+ private String appKey;
+
+ @Column(nullable = false, length = 16)
+ private String platform;
+
+ @Column(nullable = false, length = 32)
+ private String appVersion;
+
+ @Column(nullable = false, length = 128)
+ private String bundleName = "index";
+
+ @Column(nullable = false, length = 512)
+ private String storageKey;
+
+ @Column(nullable = false)
+ private LocalDateTime uploadedAt;
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getAppKey() { return appKey; }
+ public void setAppKey(String appKey) { this.appKey = appKey; }
+
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getAppVersion() { return appVersion; }
+ public void setAppVersion(String appVersion) { this.appVersion = appVersion; }
+
+ public String getBundleName() { return bundleName; }
+ public void setBundleName(String bundleName) { this.bundleName = bundleName; }
+
+ public String getStorageKey() { return storageKey; }
+ public void setStorageKey(String storageKey) { this.storageKey = storageKey; }
+
+ public LocalDateTime getUploadedAt() { return uploadedAt; }
+ public void setUploadedAt(LocalDateTime uploadedAt) { this.uploadedAt = uploadedAt; }
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogWebhookEntity.java b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogWebhookEntity.java
new file mode 100644
index 0000000..4c8d3a6
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/entity/LogWebhookEntity.java
@@ -0,0 +1,52 @@
+package com.xuqm.log.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "log_webhooks")
+public class LogWebhookEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, length = 64)
+ private String appKey;
+
+ @Column(nullable = false, length = 1024)
+ private String url;
+
+ @Column(nullable = false, columnDefinition = "JSON")
+ private String events;
+
+ @Column(nullable = false)
+ private int cooldownSec = 3600;
+
+ @Column(nullable = false)
+ private boolean enabled = true;
+
+ @Column(nullable = false)
+ private LocalDateTime createdAt;
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getAppKey() { return appKey; }
+ public void setAppKey(String appKey) { this.appKey = appKey; }
+
+ public String getUrl() { return url; }
+ public void setUrl(String url) { this.url = url; }
+
+ public String getEvents() { return events; }
+ public void setEvents(String events) { this.events = events; }
+
+ public int getCooldownSec() { return cooldownSec; }
+ public void setCooldownSec(int cooldownSec) { this.cooldownSec = cooldownSec; }
+
+ public boolean isEnabled() { return enabled; }
+ public void setEnabled(boolean enabled) { this.enabled = enabled; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+}
diff --git a/xuqm-log-service/src/main/java/com/xuqm/log/repository/LogEventRepository.java b/xuqm-log-service/src/main/java/com/xuqm/log/repository/LogEventRepository.java
new file mode 100644
index 0000000..46dc2ea
--- /dev/null
+++ b/xuqm-log-service/src/main/java/com/xuqm/log/repository/LogEventRepository.java
@@ -0,0 +1,44 @@
+package com.xuqm.log.repository;
+
+import com.xuqm.log.entity.LogEventEntity;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+public interface LogEventRepository extends JpaRepository {
+
+ Page findByAppKeyAndNameAndUserIdAndCreatedAtBetween(
+ String appKey, String name, String userId,
+ LocalDateTime from, LocalDateTime to,
+ Pageable pageable);
+
+ Page findByAppKeyAndNameAndCreatedAtBetween(
+ String appKey, String name,
+ LocalDateTime from, LocalDateTime to,
+ Pageable pageable);
+
+ Page findByAppKeyAndCreatedAtBetween(
+ String appKey, LocalDateTime from, LocalDateTime to, Pageable pageable);
+
+ @Query(value = "SELECT e.session_id, e.name, MIN(e.created_at) AS first_time " +
+ "FROM log_events e " +
+ "WHERE e.app_key = :appKey AND e.name IN :steps AND e.created_at BETWEEN :from AND :to " +
+ "GROUP BY e.session_id, e.name",
+ nativeQuery = true)
+ List