package com.pharmacopoeia.service; import com.pharmacopoeia.entity.UserEvent; import com.pharmacopoeia.repository.UserEventRepository; import jakarta.persistence.criteria.Predicate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.Specification; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @Service public class AnalyticsService { 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; public AnalyticsService(UserEventRepository repo) { this.repo = repo; } @Async public void saveEvent( final String userKey, final String eventType, @SuppressWarnings("rawtypes") final Map eventData, final String pageUrl, final String referrer, final String ip, final String userAgent) { try { // Truncate data to avoid sql errors when user-agent is longer than the column, etc. UserEvent event = UserEvent.builder() .userKey(trunc(userKey, 128)) .eventType(trunc(eventType, 64)) .eventData(eventData != null ? eventData : Map.of()) .pageUrl(trunc(pageUrl, 512)) .referrer(trunc(referrer, 512)) .ip(trunc(ip, 64)) .userAgent(trunc(userAgent, 512)) .build(); repo.save(event); } catch (Exception e) { log.warn("埋点写入失败: type={}, error={}", eventType, e.getMessage()); } } private static String trunc(String s, int max) { if (s == null) return null; return s.length() <= max ? s : s.substring(0, max); } // ==================== 埋点事件列表 ==================== public Map queryEvents(String eventType, String startDate, String endDate, int page, int pageSize) { Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH; Instant endTime = endDate != null && !endDate.isBlank() ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : FAR_FUTURE; Specification spec = buildTimeSpec(eventType, startTime, endTime); var pageable = PageRequest.of(page - 1, pageSize, Sort.by(Sort.Direction.DESC, "createdAt")); var result = repo.findAll(spec, pageable); List> items = result.getContent().stream() .map(e -> { Map m = new LinkedHashMap<>(); m.put("id", e.getId()); m.put("event_type", e.getEventType()); m.put("user_key", e.getUserKey()); m.put("page_url", e.getPageUrl()); m.put("ip", e.getIp()); m.put("created_at", e.getCreatedAt()); return m; }) .collect(Collectors.toList()); Map response = new LinkedHashMap<>(); response.put("items", items); response.put("total", result.getTotalElements()); response.put("page", page); response.put("page_size", pageSize); return response; } public Map getEventDetail(long id) { var opt = repo.findById(id); if (opt.isEmpty()) return Map.of(); var e = opt.get(); Map m = new LinkedHashMap<>(); m.put("id", e.getId()); m.put("user_key", e.getUserKey()); m.put("event_type", e.getEventType()); m.put("event_data", e.getEventData()); m.put("page_url", e.getPageUrl()); m.put("referrer", e.getReferrer()); m.put("ip", e.getIp()); m.put("user_agent", e.getUserAgent()); m.put("created_at", e.getCreatedAt()); return m; } // ==================== 概览数字 ==================== /** * 统计概览 * @param range today | yesterday | week | total | custom * @param startDate custom 的起始日期(yyyy-MM-dd),仅 range=custom 时生效 * @param endDate custom 的结束日期(yyyy-MM-dd),仅 range=custom 时生效 */ public Map queryOverview(String range, String startDate, String endDate) { if (range == null || range.isBlank()) { range = "total"; } Instant todayStart = parseDate(LocalDate.now().toString()); Instant todayEnd = LocalDate.now().plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant(); Instant startTime; Instant endTime; String label; switch (range) { case "today": startTime = todayStart; endTime = todayEnd; label = "today"; break; case "yesterday": startTime = todayStart.minus(1, java.time.temporal.ChronoUnit.DAYS); endTime = todayStart; label = "yesterday"; break; case "week": startTime = todayStart.minus(6, java.time.temporal.ChronoUnit.DAYS); endTime = todayEnd; label = "week"; break; case "custom": Instant customStart = startDate != null ? parseDate(startDate) : Instant.EPOCH; Instant customEnd = endDate != null ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : FAR_FUTURE; startTime = customStart != null ? customStart : Instant.EPOCH; endTime = customEnd; label = "custom"; break; default: // "total" startTime = Instant.EPOCH; endTime = FAR_FUTURE; label = "total"; } Map overview = new LinkedHashMap<>(); overview.put("range", label); overview.put("total_events", repo.countTotal(startTime, endTime)); overview.put("active_users", repo.countDistinctUsers(startTime, endTime)); overview.put("unique_ips", repo.countDistinctIps(startTime, endTime)); overview.put("page_views", repo.countByType("page_view", startTime, endTime)); overview.put("searches", repo.countByType("search", startTime, endTime)); overview.put("ai_qa", repo.countByType("chat", startTime, endTime)); return overview; } // ==================== 维度分页统计 ==================== /** 分页维度统计:ip / url / type / user / user_ip */ public Map queryDimension(String dimension, String startDate, String endDate, int page, int pageSize) { Instant startTime = parseDate(startDate) != null ? parseDate(startDate) : Instant.EPOCH; Instant endTime = endDate != null && !endDate.isBlank() ? LocalDate.parse(endDate).plusDays(1).atStartOfDay(ZoneOffset.UTC).toInstant() : FAR_FUTURE; Page 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(); } List> items = mapResult(result.getContent(), dimension); Map 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> mapResult(List rows, String dimension) { return rows.stream() .map(row -> { Map m = new LinkedHashMap<>(); 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; }) .collect(Collectors.toList()); } // ==================== Event Types ==================== /** Return all distinct event types for frontend switching */ public List getEventTypes() { return repo.findDistinctEventTypes(); } // ==================== Helpers ==================== private Specification buildTimeSpec(String eventType, Instant startTime, Instant endTime) { return (root, query, cb) -> { List 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(); } }