|
@@ -5,6 +5,7 @@ import com.pharmacopoeia.repository.UserEventRepository;
|
|
|
import jakarta.persistence.criteria.Predicate;
|
|
import jakarta.persistence.criteria.Predicate;
|
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
|
+import org.springframework.data.domain.Page;
|
|
|
import org.springframework.data.domain.PageRequest;
|
|
import org.springframework.data.domain.PageRequest;
|
|
|
import org.springframework.data.domain.Sort;
|
|
import org.springframework.data.domain.Sort;
|
|
|
import org.springframework.data.jpa.domain.Specification;
|
|
import org.springframework.data.jpa.domain.Specification;
|
|
@@ -24,6 +25,8 @@ import java.util.stream.Collectors;
|
|
|
public class AnalyticsService {
|
|
public class AnalyticsService {
|
|
|
|
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
|
|
private static final Logger log = LoggerFactory.getLogger(AnalyticsService.class);
|
|
|
|
|
+ private static final Instant FAR_FUTURE = Instant.parse("2099-12-31T23:59:59Z");
|
|
|
|
|
+
|
|
|
private final UserEventRepository repo;
|
|
private final UserEventRepository repo;
|
|
|
|
|
|
|
|
public AnalyticsService(UserEventRepository repo) {
|
|
public AnalyticsService(UserEventRepository repo) {
|
|
@@ -49,30 +52,16 @@ public class AnalyticsService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ==================== 埋点查询 ====================
|
|
|
|
|
|
|
+ // ==================== 埋点事件列表 ====================
|
|
|
|
|
|
|
|
- /** 列表(精简字段,不含 event_data / page_url / referrer / user_agent) */
|
|
|
|
|
public Map<String, Object> queryEvents(String eventType, String startDate, String endDate,
|
|
public Map<String, Object> queryEvents(String eventType, String startDate, String endDate,
|
|
|
int page, int pageSize) {
|
|
int page, int pageSize) {
|
|
|
Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
|
|
Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
|
|
|
Instant endTime = endDate != null && !endDate.isBlank()
|
|
Instant endTime = endDate != null && !endDate.isBlank()
|
|
|
? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
|
? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
|
|
- : Instant.parse("2099-12-31T23:59:59Z");
|
|
|
|
|
-
|
|
|
|
|
- Specification<UserEvent> spec = (root, query, cb) -> {
|
|
|
|
|
- List<Predicate> predicates = new ArrayList<>();
|
|
|
|
|
- if (eventType != null && !eventType.isBlank()) {
|
|
|
|
|
- predicates.add(cb.equal(root.get("eventType"), eventType));
|
|
|
|
|
- }
|
|
|
|
|
- if (startTime != null) {
|
|
|
|
|
- predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
|
|
|
|
|
- }
|
|
|
|
|
- if (endTime != null) {
|
|
|
|
|
- predicates.add(cb.lessThan(root.get("createdAt"), endTime));
|
|
|
|
|
- }
|
|
|
|
|
- return cb.and(predicates.toArray(new Predicate[0]));
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ : FAR_FUTURE;
|
|
|
|
|
|
|
|
|
|
+ Specification<UserEvent> spec = buildTimeSpec(eventType, startTime, endTime);
|
|
|
var pageable = PageRequest.of(page - 1, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
|
|
var pageable = PageRequest.of(page - 1, pageSize, Sort.by(Sort.Direction.DESC, "createdAt"));
|
|
|
var result = repo.findAll(spec, pageable);
|
|
var result = repo.findAll(spec, pageable);
|
|
|
|
|
|
|
@@ -97,7 +86,6 @@ public class AnalyticsService {
|
|
|
return response;
|
|
return response;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** 详情(返回全部字段) */
|
|
|
|
|
public Map<String, Object> getEventDetail(long id) {
|
|
public Map<String, Object> getEventDetail(long id) {
|
|
|
var opt = repo.findById(id);
|
|
var opt = repo.findById(id);
|
|
|
if (opt.isEmpty()) return Map.of();
|
|
if (opt.isEmpty()) return Map.of();
|
|
@@ -115,14 +103,8 @@ public class AnalyticsService {
|
|
|
return m;
|
|
return m;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private Instant parseDate(String date) {
|
|
|
|
|
- if (date == null || date.isBlank()) return null;
|
|
|
|
|
- return LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ==================== 统计概览 ====================
|
|
|
|
|
|
|
+ // ==================== 概览数字 ====================
|
|
|
|
|
|
|
|
- /** 概览数字:支持三种场景——累计、今天、最近7天 */
|
|
|
|
|
public Map<String, Object> queryOverview(String range) {
|
|
public Map<String, Object> queryOverview(String range) {
|
|
|
String today = LocalDate.now().toString();
|
|
String today = LocalDate.now().toString();
|
|
|
Instant todayStart = parseDate(today);
|
|
Instant todayStart = parseDate(today);
|
|
@@ -141,9 +123,8 @@ public class AnalyticsService {
|
|
|
endTime = todayEnd;
|
|
endTime = todayEnd;
|
|
|
label = "week";
|
|
label = "week";
|
|
|
} else {
|
|
} else {
|
|
|
- // 累计:用极值替代 null,避免 PG 无法推断 NULL::timestamptz 类型
|
|
|
|
|
startTime = Instant.EPOCH;
|
|
startTime = Instant.EPOCH;
|
|
|
- endTime = Instant.parse("2099-12-31T23:59:59Z");
|
|
|
|
|
|
|
+ endTime = FAR_FUTURE;
|
|
|
label = "total";
|
|
label = "total";
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -157,58 +138,85 @@ public class AnalyticsService {
|
|
|
return overview;
|
|
return overview;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // ==================== 统计维度 ====================
|
|
|
|
|
|
|
+ // ==================== 维度分页统计 ====================
|
|
|
|
|
|
|
|
- /** 五个维度统计,支持按维度切换,概览支持 range=today/week/total;
|
|
|
|
|
- * 如果日期都不传,调用方默认填了当天 */
|
|
|
|
|
- public Map<String, Object> queryStats(String startDate, String endDate, String dimension,
|
|
|
|
|
- String range) {
|
|
|
|
|
|
|
+ /** 分页维度统计:ip / url / type / user / user_ip */
|
|
|
|
|
+ public Map<String, Object> queryDimension(String dimension, String startDate, String endDate,
|
|
|
|
|
+ int page, int pageSize) {
|
|
|
Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
|
|
Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH;
|
|
|
Instant endTime = endDate != null && !endDate.isBlank()
|
|
Instant endTime = endDate != null && !endDate.isBlank()
|
|
|
? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
|
? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant()
|
|
|
- : Instant.parse("2099-12-31T23:59:59Z");
|
|
|
|
|
-
|
|
|
|
|
- Map<String, Object> result = new LinkedHashMap<>();
|
|
|
|
|
- // 概览数字(range 控制累计/今天/7天)
|
|
|
|
|
- result.put("overview", queryOverview(range));
|
|
|
|
|
-
|
|
|
|
|
- // 指定维度只查一个,不查全部
|
|
|
|
|
- boolean all = (dimension == null || dimension.isBlank());
|
|
|
|
|
- if (all || "ip".equals(dimension)) {
|
|
|
|
|
- result.put("by_ip", mapGroupResult(repo.countByIp(startTime, endTime)));
|
|
|
|
|
|
|
+ : FAR_FUTURE;
|
|
|
|
|
+
|
|
|
|
|
+ Page<Object[]> result;
|
|
|
|
|
+ switch (dimension != null ? dimension : "") {
|
|
|
|
|
+ case "ip":
|
|
|
|
|
+ result = repo.countByIpPaged(startTime, endTime,
|
|
|
|
|
+ PageRequest.of(page - 1, pageSize));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "url":
|
|
|
|
|
+ result = repo.countByPageUrlPaged(startTime, endTime,
|
|
|
|
|
+ PageRequest.of(page - 1, pageSize));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "type":
|
|
|
|
|
+ result = repo.countByEventTypePaged(startTime, endTime,
|
|
|
|
|
+ PageRequest.of(page - 1, pageSize));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "user":
|
|
|
|
|
+ result = repo.countByUserKeyPaged(startTime, endTime,
|
|
|
|
|
+ PageRequest.of(page - 1, pageSize));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "user_ip":
|
|
|
|
|
+ result = repo.countByUserIpPaged(startTime, endTime,
|
|
|
|
|
+ PageRequest.of(page - 1, pageSize));
|
|
|
|
|
+ break;
|
|
|
|
|
+ default:
|
|
|
|
|
+ return Map.of();
|
|
|
}
|
|
}
|
|
|
- if (all || "url".equals(dimension)) {
|
|
|
|
|
- result.put("by_url", mapGroupResult(repo.countByPageUrl(startTime, endTime)));
|
|
|
|
|
- }
|
|
|
|
|
- if (all || "type".equals(dimension)) {
|
|
|
|
|
- result.put("by_type", mapGroupResult(repo.countByEventType(startTime, endTime)));
|
|
|
|
|
- }
|
|
|
|
|
- if (all || "user".equals(dimension)) {
|
|
|
|
|
- List<Map<String, Object>> byUser = mapGroupResult(repo.countByUserKey(startTime, endTime));
|
|
|
|
|
- result.put("by_user", byUser);
|
|
|
|
|
- result.put("total_users", byUser.size());
|
|
|
|
|
- }
|
|
|
|
|
- if (all || "user_ip".equals(dimension)) {
|
|
|
|
|
- result.put("by_user_ip", mapUserIpResult(repo.countByUserIp(startTime, endTime)));
|
|
|
|
|
- }
|
|
|
|
|
- return result;
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- private List<Map<String, Object>> mapGroupResult(List<Object[]> rows) {
|
|
|
|
|
- return rows.stream()
|
|
|
|
|
- .map(row -> Map.of("key", row[0] != null ? row[0] : "", "count", (Long) row[1]))
|
|
|
|
|
- .collect(Collectors.toList());
|
|
|
|
|
|
|
+ List<Map<String, Object>> items = mapResult(result.getContent(), dimension);
|
|
|
|
|
+ Map<String, Object> response = new LinkedHashMap<>();
|
|
|
|
|
+ response.put("dimension", dimension);
|
|
|
|
|
+ response.put("items", items);
|
|
|
|
|
+ response.put("total", result.getTotalElements());
|
|
|
|
|
+ response.put("page", page);
|
|
|
|
|
+ response.put("page_size", pageSize);
|
|
|
|
|
+ return response;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private List<Map<String, Object>> mapUserIpResult(List<Object[]> rows) {
|
|
|
|
|
|
|
+ private List<Map<String, Object>> mapResult(List<Object[]> rows, String dimension) {
|
|
|
return rows.stream()
|
|
return rows.stream()
|
|
|
.map(row -> {
|
|
.map(row -> {
|
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
Map<String, Object> m = new LinkedHashMap<>();
|
|
|
- m.put("user_key", row[0] != null ? row[0] : "");
|
|
|
|
|
- m.put("ip", row[1] != null ? row[1] : "");
|
|
|
|
|
- m.put("count", (Long) row[2]);
|
|
|
|
|
|
|
+ if ("user_ip".equals(dimension)) {
|
|
|
|
|
+ m.put("user_key", row[0] != null ? row[0] : "");
|
|
|
|
|
+ m.put("ip", row[1] != null ? row[1] : "");
|
|
|
|
|
+ m.put("count", (Long) row[2]);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ m.put("key", row[0] != null ? row[0] : "");
|
|
|
|
|
+ m.put("count", (Long) row[1]);
|
|
|
|
|
+ }
|
|
|
return m;
|
|
return m;
|
|
|
})
|
|
})
|
|
|
.collect(Collectors.toList());
|
|
.collect(Collectors.toList());
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Helpers ====================
|
|
|
|
|
+
|
|
|
|
|
+ private Specification<UserEvent> buildTimeSpec(String eventType, Instant startTime, Instant endTime) {
|
|
|
|
|
+ return (root, query, cb) -> {
|
|
|
|
|
+ List<Predicate> predicates = new ArrayList<>();
|
|
|
|
|
+ if (eventType != null && !eventType.isBlank()) {
|
|
|
|
|
+ predicates.add(cb.equal(root.get("eventType"), eventType));
|
|
|
|
|
+ }
|
|
|
|
|
+ predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
|
|
|
|
|
+ predicates.add(cb.lessThan(root.get("createdAt"), endTime));
|
|
|
|
|
+ return cb.and(predicates.toArray(new Predicate[0]));
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Instant parseDate(String date) {
|
|
|
|
|
+ if (date == null || date.isBlank()) return null;
|
|
|
|
|
+ return LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant();
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|