|
@@ -13,9 +13,12 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
|
|
import org.springframework.scheduling.annotation.Async;
|
|
import org.springframework.scheduling.annotation.Async;
|
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
|
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
import java.time.Instant;
|
|
import java.time.Instant;
|
|
|
import java.time.LocalDate;
|
|
import java.time.LocalDate;
|
|
|
import java.time.ZoneOffset;
|
|
import java.time.ZoneOffset;
|
|
|
|
|
+import java.time.ZoneId;
|
|
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
@@ -136,6 +139,87 @@ public class AnalyticsService {
|
|
|
return response;
|
|
return response;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ==================== 事件导出(CSV) ====================
|
|
|
|
|
+
|
|
|
|
|
+ /** 单次导出上限:超出时提示用户缩小时间范围分批导出 */
|
|
|
|
|
+ private static final int EXPORT_MAX_ROWS = 100_000;
|
|
|
|
|
+
|
|
|
|
|
+ /** UTF-8 BOM,便于 Excel 直接双击打开时正确识别中文 */
|
|
|
|
|
+ private static final byte[] CSV_BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
|
|
|
|
|
+
|
|
|
|
|
+ /** 事件类型中文名映射,与前端 TrackingInfoView 的 EVENT_LABEL_MAP 对齐;未知类型保留原值 */
|
|
|
|
|
+ private static final Map<String, String> EVENT_LABEL_MAP = Map.of(
|
|
|
|
|
+ "page_view", "页面浏览",
|
|
|
|
|
+ "search", "搜索",
|
|
|
|
|
+ "click", "点击",
|
|
|
|
|
+ "ai_qa", "AI问答",
|
|
|
|
|
+ "chat", "AI问答"
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ private static final ZoneId BEIJING = ZoneId.of("Asia/Shanghai");
|
|
|
|
|
+ private static final DateTimeFormatter EXPORT_TIME_FMT =
|
|
|
|
|
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BEIJING);
|
|
|
|
|
+
|
|
|
|
|
+ /** 导出结果:SUCCESS 返回 bytes;EMPTY/TOO_LARGE 返回面向用户的 message */
|
|
|
|
|
+ public record EventExportResult(Status status, String message, byte[] bytes) {
|
|
|
|
|
+ public enum Status { SUCCESS, EMPTY, TOO_LARGE }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 按当前筛选条件全量导出埋点事件为 CSV(UTF-8 + BOM,便于 Excel 正确显示中文)。
|
|
|
|
|
+ * 字段顺序:ID / 事件类型 / 来源系统 / 用户标识 / IP地址 / 时间,按时间倒序。
|
|
|
|
|
+ * 事件类型取页面展示的中文名(与前端 getEventLabel 对齐);时间为北京时间 yyyy-MM-dd HH:mm:ss。
|
|
|
|
|
+ */
|
|
|
|
|
+ public EventExportResult exportEvents(String eventType, String systemSource,
|
|
|
|
|
+ String startDate, String endDate) {
|
|
|
|
|
+ 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;
|
|
|
|
|
+ String systemFilter = normalizeSystemFilter(systemSource);
|
|
|
|
|
+ String typeFilter = (eventType == null || eventType.isBlank()) ? null : eventType;
|
|
|
|
|
+
|
|
|
|
|
+ long total = repo.countForExport(startTime, endTime, systemFilter, typeFilter);
|
|
|
|
|
+ if (total == 0) {
|
|
|
|
|
+ return new EventExportResult(EventExportResult.Status.EMPTY, "当前筛选条件下无数据,无法导出", null);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (total > EXPORT_MAX_ROWS) {
|
|
|
|
|
+ return new EventExportResult(EventExportResult.Status.TOO_LARGE,
|
|
|
|
|
+ "当前筛选条件下数据量为 " + total + " 条,超过单次导出上限 " + EXPORT_MAX_ROWS
|
|
|
|
|
+ + " 条,请缩小时间范围后分批导出", null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ List<Object[]> rows = repo.findForExport(startTime, endTime, systemFilter, typeFilter);
|
|
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
|
|
+ sb.append("ID,事件类型,来源系统,用户标识,IP地址,时间\n");
|
|
|
|
|
+ for (Object[] r : rows) {
|
|
|
|
|
+ // r: [id, systemSource, eventType, userKey, ip, createdAt]
|
|
|
|
|
+ sb.append(((Number) r[0]).longValue()).append(',')
|
|
|
|
|
+ .append(esc(mapEventLabel((String) r[2]))).append(',')
|
|
|
|
|
+ .append(esc((String) r[1])).append(',')
|
|
|
|
|
+ .append(esc((String) r[3])).append(',')
|
|
|
|
|
+ .append(esc((String) r[4])).append(',')
|
|
|
|
|
+ .append(esc(r[5] != null ? EXPORT_TIME_FMT.format((Instant) r[5]) : ""))
|
|
|
|
|
+ .append('\n');
|
|
|
|
|
+ }
|
|
|
|
|
+ byte[] content = sb.toString().getBytes(StandardCharsets.UTF_8);
|
|
|
|
|
+ byte[] withBom = new byte[CSV_BOM.length + content.length];
|
|
|
|
|
+ System.arraycopy(CSV_BOM, 0, withBom, 0, CSV_BOM.length);
|
|
|
|
|
+ System.arraycopy(content, 0, withBom, CSV_BOM.length, content.length);
|
|
|
|
|
+ return new EventExportResult(EventExportResult.Status.SUCCESS, "ok", withBom);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** CSV 单元格转义:空值返回空串;非空则用双引号包裹并把内部双引号翻倍。与 BrandRecommendService#esc 对齐 */
|
|
|
|
|
+ private static String esc(String s) {
|
|
|
|
|
+ return (s == null || s.isEmpty()) ? "" : "\"" + s.replace("\"", "\"\"") + "\"";
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 事件类型 → 页面展示中文名;未知类型保留原值,与前端 getEventLabel 一致 */
|
|
|
|
|
+ private static String mapEventLabel(String type) {
|
|
|
|
|
+ if (type == null) return "";
|
|
|
|
|
+ return EVENT_LABEL_MAP.getOrDefault(type, type);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
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();
|