From c2620720d2a499a1beff48a646e120b010abcd8d Mon Sep 17 00:00:00 2001 From: MIST <52695829+MISTLXC@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:38:08 +0800 Subject: [PATCH] feat(operational): one-click operational data export with 9-sheet Excel (#411) Add an async export feature on the Dashboard page -- global admins can generate and download a multi-sheet operational data report (.xlsx packaged as .zip). The export covers 9 sheets: 1. Overview - interval KPIs, system snapshot, 7-day trend, period comparison, model details (configured providers only), agent activity ranking top 10 2. Token Usage - daily breakdown by runtime_provider with avg tokens/msg 3. Skill Stats - skill list with usage count, last-call time, bound agents 4. User Stats - per-(workspace, user) aggregated tokens, duration, last active 5. User Conversations - detail rows pairing user-asst messages 6. Security and Audit - unified view across 6 sources (guard rules, audit logs, approvals, grants, config, business audit events) 7. Channel Stats - per-channel conversation count, tokens, unique users 8. Model Config - enabled plus API-key-configured models with parameters 9. Cron Jobs - execution records with duration and token usage Backend highlights: - generate/progress/download endpoints guarded by PreAuthorize hasRole ADMIN - single AtomicBoolean lock (409 when busy), 90-day frontend cap, 5-min deadline - metadata-based tool-call counting, deleted=0 filtering everywhere - value label mapping (chat to dialogue, TRUE to enabled, etc.) - one-time downloadToken, file auto-cleanup after 24h or download Frontend highlights: - SVG ring progress bar with smooth dashoffset transition plus slow rotation - visibility gated by workspaceStore.isGlobalAdmin (v-if on button) - 1-second polling driving progress state machine (idle/generating/done) - Element Plus date-picker (30-day default, 90-day max) --- .../controller/OperationalDataController.java | 112 ++ .../model/ExportInProgressException.java | 10 + .../mate/operational/model/ExportTask.java | 70 + .../service/OperationalDataExportService.java | 1301 +++++++++++++++++ mateclaw-ui/src/api/index.ts | 22 + mateclaw-ui/src/i18n/locales/en-US.ts | 14 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 14 +- mateclaw-ui/src/views/Dashboard.vue | 353 ++++- 8 files changed, 1846 insertions(+), 50 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java create mode 100644 mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java diff --git a/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java b/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java new file mode 100644 index 00000000..2d65a058 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java @@ -0,0 +1,112 @@ +package vip.mate.operational.controller; + +import org.springframework.core.io.InputStreamResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import vip.mate.operational.model.ExportTask; +import vip.mate.operational.service.OperationalDataExportService; + +import java.io.IOException; +import java.nio.file.Files; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 运营数据导出 Controller — 仅全局管理员可访问。 + *

+ * 不暴露标准 REST API 端点,下载通过一次性 token 保护。 + */ +@RestController +@RequestMapping("/api/v1/operational-data") +public class OperationalDataController { + + private final OperationalDataExportService exportService; + + public OperationalDataController(OperationalDataExportService exportService) { + this.exportService = exportService; + } + + @PostMapping("/generate") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> generate( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate) { + try { + ExportTask task = exportService.generate(startDate, endDate); + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getTaskId()); + result.put("status", task.getStatus()); + return ResponseEntity.ok(result); + } catch (Exception e) { + Map err = new LinkedHashMap<>(); + err.put("code", 500); + err.put("msg", e.getClass().getSimpleName() + ": " + e.getMessage()); + return ResponseEntity.internalServerError().body(err); + } + } + + /** + * 查询生成进度(驱动圆形进度条) + */ + @GetMapping("/progress") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> progress(@RequestParam String taskId) { + ExportTask task = exportService.getProgress(taskId); + if (task == null) { + Map err = new LinkedHashMap<>(); + err.put("code", 404); + err.put("msg", "任务不存在或已过期"); + return ResponseEntity.notFound().build(); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getTaskId()); + result.put("step", task.getStep()); + result.put("total", task.getTotal()); + result.put("status", task.getStatus()); + if ("completed".equals(task.getStatus())) { + result.put("downloadToken", task.getDownloadToken()); + } + if ("failed".equals(task.getStatus())) { + result.put("errorMessage", task.getErrorMessage()); + } + return ResponseEntity.ok(result); + } + + /** + * 下载已生成的文件(一次有效,需 downloadToken) + */ + @GetMapping("/download") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity download( + @RequestParam String taskId, + @RequestParam String token) { + ExportTask task = exportService.confirmDownload(taskId, token); + if (task == null) { + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.TEXT_PLAIN) + .body(null); + } + + try { + if (!Files.exists(task.getFilePath())) { + return ResponseEntity.status(HttpStatus.GONE).build(); + } + + task.setDownloaded(true); + InputStreamResource resource = new InputStreamResource(Files.newInputStream(task.getFilePath())); + + String encodedName = new String(task.getFileName().getBytes("UTF-8"), "ISO-8859-1"); + return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=\"" + encodedName + "\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(resource); + } catch (IOException e) { + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java new file mode 100644 index 00000000..275bd2e9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportInProgressException.java @@ -0,0 +1,10 @@ +package vip.mate.operational.model; + +/** + * 导出任务并发冲突异常——{@code AtomicBoolean} 已被占用。 + */ +public class ExportInProgressException extends RuntimeException { + public ExportInProgressException(String message) { + super(message); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java new file mode 100644 index 00000000..51391f0b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java @@ -0,0 +1,70 @@ +package vip.mate.operational.model; + +import java.nio.file.Path; +import java.util.UUID; + +/** + * 运营数据导出任务——异步生成 + 一次下载模型。 + */ +public class ExportTask { + private String taskId; + private volatile int step; + private int total = 9; + private volatile String status; // generating | completed | failed | timeout | oom + private volatile Path filePath; + private volatile long completedAt; + private volatile String downloadToken; + private volatile boolean downloaded; + private volatile String errorMessage; + + public ExportTask() { + this.taskId = UUID.randomUUID().toString().substring(0, 8); + this.status = "generating"; + } + + public void setCompleted(Path filePath) { + this.status = "completed"; + this.filePath = filePath; + this.completedAt = System.currentTimeMillis(); + this.downloadToken = UUID.randomUUID().toString().substring(0, 12); + } + + public void setFailed(String errorMessage) { + this.status = "failed"; + this.errorMessage = errorMessage; + } + + public String getFileName() { + if (filePath == null) return null; + return filePath.getFileName().toString(); + } + + // ── Manual getters/setters (avoid Lombok/Java25 issue) ── + + public String getTaskId() { return taskId; } + public void setTaskId(String taskId) { this.taskId = taskId; } + + public int getStep() { return step; } + public void setStep(int step) { this.step = step; } + + public int getTotal() { return total; } + public void setTotal(int total) { this.total = total; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Path getFilePath() { return filePath; } + public void setFilePath(Path filePath) { this.filePath = filePath; } + + public long getCompletedAt() { return completedAt; } + public void setCompletedAt(long completedAt) { this.completedAt = completedAt; } + + public String getDownloadToken() { return downloadToken; } + public void setDownloadToken(String downloadToken) { this.downloadToken = downloadToken; } + + public boolean isDownloaded() { return downloaded; } + public void setDownloaded(boolean downloaded) { this.downloaded = downloaded; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java new file mode 100644 index 00000000..2b7de4c2 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java @@ -0,0 +1,1301 @@ +package vip.mate.operational.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; +import vip.mate.approval.grant.entity.ApprovalGrant; +import vip.mate.approval.grant.repository.ApprovalGrantMapper; +import vip.mate.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.audit.model.AuditEventEntity; +import vip.mate.audit.repository.AuditEventMapper; +import vip.mate.audit.service.AuditEventService; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.repository.UserMapper; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.repository.ChannelMapper; +import vip.mate.cron.model.CronJobEntity; +import vip.mate.cron.repository.CronJobMapper; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.repository.CronJobRunMapper; +import vip.mate.dashboard.service.DashboardService; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.repository.ModelConfigMapper; +import vip.mate.llm.repository.ModelProviderMapper; +import vip.mate.operational.model.ExportTask; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; +import vip.mate.skill.repository.SkillUsageStatMapper; +import vip.mate.skill.usage.SkillUsageStatEntity; +import vip.mate.tool.guard.model.ToolGuardAuditLogEntity; +import vip.mate.tool.guard.model.ToolGuardConfigEntity; +import vip.mate.tool.guard.model.ToolGuardRuleEntity; +import vip.mate.tool.guard.repository.ToolGuardAuditLogMapper; +import vip.mate.tool.guard.repository.ToolGuardConfigMapper; +import vip.mate.tool.guard.repository.ToolGuardRuleMapper; +import vip.mate.workspace.conversation.TokenUsageService; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; +import vip.mate.workspace.conversation.vo.TokenUsageSummaryVO; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.repository.WorkspaceMapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * 运营数据导出服务——异步生成 9 Sheet Excel,一次下载。 + *

+ * 设计原则(来自 design/operational-data-export.md): + * SQL/Service → API → 自算。优先复现有 Service,其次 Mapper 直查,内存聚合。 + */ +@Service +public class OperationalDataExportService { + + private static final Logger log = LoggerFactory.getLogger(OperationalDataExportService.class); + + // ── 现成 Service ── + private final TokenUsageService tokenUsageService; + private final DashboardService dashboardService; + private final AuditEventService auditEventService; + + // ── Mapper(按需注入)── + private final MessageMapper messageMapper; + private final ConversationMapper conversationMapper; + private final UserMapper userMapper; + private final WorkspaceMapper workspaceMapper; + private final SkillMapper skillMapper; + private final SkillUsageStatMapper skillUsageStatMapper; + private final AgentSkillBindingMapper agentSkillBindingMapper; + private final AgentMapper agentMapper; + private final ChannelMapper channelMapper; + private final ModelConfigMapper modelConfigMapper; + private final ModelProviderMapper modelProviderMapper; + private final AuditEventMapper auditEventMapper; + private final ToolGuardRuleMapper toolGuardRuleMapper; + private final ToolGuardConfigMapper toolGuardConfigMapper; + private final ToolGuardAuditLogMapper toolGuardAuditLogMapper; + private final ToolApprovalMapper toolApprovalMapper; + private final ApprovalGrantMapper approvalGrantMapper; + private final CronJobMapper cronJobMapper; + private final CronJobRunMapper cronJobRunMapper; + + // ── 并发控制 ── + private final ObjectMapper objectMapper; + private final AtomicBoolean generating = new AtomicBoolean(false); + private final ConcurrentHashMap tasks = new ConcurrentHashMap<>(); + + public OperationalDataExportService(TokenUsageService tokenUsageService, + DashboardService dashboardService, + AuditEventService auditEventService, + ObjectMapper objectMapper, + MessageMapper messageMapper, + ConversationMapper conversationMapper, + UserMapper userMapper, + WorkspaceMapper workspaceMapper, + SkillMapper skillMapper, + SkillUsageStatMapper skillUsageStatMapper, + AgentSkillBindingMapper agentSkillBindingMapper, + AgentMapper agentMapper, + ChannelMapper channelMapper, + ModelConfigMapper modelConfigMapper, + ModelProviderMapper modelProviderMapper, + AuditEventMapper auditEventMapper, + ToolGuardRuleMapper toolGuardRuleMapper, + ToolGuardConfigMapper toolGuardConfigMapper, + ToolGuardAuditLogMapper toolGuardAuditLogMapper, + ToolApprovalMapper toolApprovalMapper, + ApprovalGrantMapper approvalGrantMapper, + CronJobMapper cronJobMapper, + CronJobRunMapper cronJobRunMapper) { + this.tokenUsageService = tokenUsageService; + this.dashboardService = dashboardService; + this.auditEventService = auditEventService; + this.objectMapper = objectMapper; + this.messageMapper = messageMapper; + this.conversationMapper = conversationMapper; + this.userMapper = userMapper; + this.workspaceMapper = workspaceMapper; + this.skillMapper = skillMapper; + this.skillUsageStatMapper = skillUsageStatMapper; + this.agentSkillBindingMapper = agentSkillBindingMapper; + this.agentMapper = agentMapper; + this.channelMapper = channelMapper; + this.modelConfigMapper = modelConfigMapper; + this.modelProviderMapper = modelProviderMapper; + this.auditEventMapper = auditEventMapper; + this.toolGuardRuleMapper = toolGuardRuleMapper; + this.toolGuardConfigMapper = toolGuardConfigMapper; + this.toolGuardAuditLogMapper = toolGuardAuditLogMapper; + this.toolApprovalMapper = toolApprovalMapper; + this.approvalGrantMapper = approvalGrantMapper; + this.cronJobMapper = cronJobMapper; + this.cronJobRunMapper = cronJobRunMapper; + } + + // ── 存储目录 ── + + // ── 常量 ── + private static final int MAX_DAYS_FRONTEND = 90; + private static final int LIMIT_USER_MSGS = 50_000; + private static final int LIMIT_AUDIT = 10_000; + private static final long DEADLINE_MS = 300_000; // 5 min + private static final Path EXPORT_DIR = Path.of(".", "data", "exports"); + private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + // ── 值转译字典 ── + private static final Map LABEL_MAP = Map.ofEntries( + Map.entry("TRUE", "启用"), Map.entry("1", "启用"), + Map.entry("FALSE", "禁用"), Map.entry("0", "禁用"), + Map.entry("builtin", "内置"), Map.entry("mcp", "MCP"), + Map.entry("dynamic", "动态"), Map.entry("acp", "ACP"), + Map.entry("active", "活跃"), Map.entry("stale", "待归档"), Map.entry("archived", "已归档"), + Map.entry("CREATE", "创建"), Map.entry("UPDATE", "更新"), Map.entry("DELETE", "删除"), + Map.entry("AGENT", "Agent"), Map.entry("SKILL", "Skill"), Map.entry("TOOL", "工具"), + Map.entry("CHANNEL", "渠道"), Map.entry("WORKSPACE", "工作区"), Map.entry("USER", "用户"), + Map.entry("rule", "防护规则"), Map.entry("audit", "防护审计"), + Map.entry("approval", "工具审批"), Map.entry("grant", "自动授权"), + Map.entry("config", "全局配置"), Map.entry("business_audit", "业务审计"), + Map.entry("critical", "严重"), Map.entry("high", "高"), + Map.entry("medium", "中"), Map.entry("low", "低"), Map.entry("info", "提示"), + Map.entry("ALLOW", "允许"), Map.entry("BLOCK", "阻止"), Map.entry("NEEDS_APPROVAL", "需审批"), + Map.entry("PENDING", "待处理"), Map.entry("APPROVED", "已批准"), + Map.entry("DENIED", "已拒绝"), Map.entry("EXPIRED", "已过期"), + Map.entry("revoked", "已撤销"), Map.entry("enabled", "启用"), Map.entry("disabled", "禁用"), + Map.entry("chat", "对话"), Map.entry("embedding", "嵌入"), Map.entry("image", "图像") + ); + + // ════════════════════════════════════════════════════ + // 公开方法 + // ════════════════════════════════════════════════════ + + /** 前端触发生成(≤90 天、有死线) */ + public ExportTask generate(LocalDate start, LocalDate end) { + final LocalDate from = start.isAfter(end) ? end : start; + final LocalDate to = start.isAfter(end) ? start : end; + if (ChronoUnit.DAYS.between(from, to) > MAX_DAYS_FRONTEND) { + throw new IllegalArgumentException("时间跨度不能超过 " + MAX_DAYS_FRONTEND + " 天"); + } + if (!generating.compareAndSet(false, true)) { + throw new IllegalStateException("正在生成中,请等待"); + } + ExportTask task = new ExportTask(); + tasks.put(task.getTaskId(), task); + CompletableFuture.runAsync(() -> runExport(task, from, to, true)); + return task; + } + + /** 后台直接调用(无限制) */ + public ExportTask exportBackend(LocalDate start, LocalDate end) { + if (!generating.compareAndSet(false, true)) { + throw new IllegalStateException("前端或后台已有生成任务在运行"); + } + ExportTask task = new ExportTask(); + tasks.put(task.getTaskId(), task); + runExport(task, start, end, false); + return task; + } + + /** 查询任务进度 */ + public ExportTask getProgress(String taskId) { + return tasks.get(taskId); + } + + /** 下载(一次性) */ + public ExportTask confirmDownload(String taskId, String token) { + ExportTask task = tasks.get(taskId); + if (task == null) return null; + if (!token.equals(task.getDownloadToken())) return null; + if (task.isDownloaded()) return null; + if (!"completed".equals(task.getStatus())) return null; + return task; + } + + // ════════════════════════════════════════════════════ + // 核心生成 + // ════════════════════════════════════════════════════ + + private void runExport(ExportTask task, LocalDate start, LocalDate end, boolean enforceLimit) { + long startedAt = System.currentTimeMillis(); + try { + byte[] zip = doExport(start, end, step -> { + task.setStep(step); + if (enforceLimit && System.currentTimeMillis() - startedAt > DEADLINE_MS) { + throw new RuntimeException("生成超时"); + } + }, enforceLimit); + + Files.createDirectories(EXPORT_DIR); + String fileName = "ops_data_" + start + "_" + end + ".zip"; + Path file = EXPORT_DIR.resolve(fileName); + Files.write(file, zip); + task.setCompleted(file); + log.info("Export completed: {}, size={}KB", task.getTaskId(), zip.length / 1024); + + try { + auditEventService.recordSync("EXPORT", "OPERATIONAL_DATA", + start + "~" + end, "运营数据导出 9 sheets", null); + } catch (Exception e) { + log.warn("Failed to write audit for export: {}", e.getMessage()); + } + } catch (Exception e) { + log.error("Export failed: {}", task.getTaskId(), e); + task.setFailed(e.getMessage()); + } finally { + generating.set(false); + } + } + + byte[] doExport(LocalDate start, LocalDate end, Consumer onStep, + boolean enforceLimit) throws IOException { + LocalDateTime startTime = start.atStartOfDay(); + LocalDateTime endTime = end.atTime(LocalTime.MAX); + + try (XSSFWorkbook wb = new XSSFWorkbook(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + + // CellStyles + CellStyle headerStyle = createHeaderStyle(wb); + CellStyle numStyle = createNumStyle(wb); + CellStyle dateStyle = createDateStyle(wb); + + // Sheet 1: 概览汇总 + onStep.accept(1); + buildOverviewSheet(wb, headerStyle, startTime, endTime); + + // Sheet 2: Token用量 + onStep.accept(2); + buildTokenSheet(wb, headerStyle, numStyle, start, end); + + // Sheet 3: 技能统计 + onStep.accept(3); + buildSkillSheet(wb, headerStyle, numStyle, dateStyle, startTime, endTime); + + // Sheet 4: 用户统计 + onStep.accept(4); + buildUserStatSheet(wb, headerStyle, numStyle, dateStyle, startTime, endTime); + + // Sheet 5: 用户对话 + onStep.accept(5); + buildUserDetailSheet(wb, headerStyle, numStyle, dateStyle, startTime, endTime, enforceLimit); + + // Sheet 6: 安全与审计 + onStep.accept(6); + buildSecuritySheet(wb, headerStyle, dateStyle, startTime, endTime, enforceLimit); + + // Sheet 7: 渠道统计 + onStep.accept(7); + buildChannelSheet(wb, headerStyle, numStyle, startTime, endTime); + + // Sheet 8: 模型配置 + onStep.accept(8); + buildModelSheet(wb, headerStyle, numStyle); + + // Sheet 9: 定时任务 + onStep.accept(9); + buildCronSheet(wb, headerStyle, dateStyle, startTime, endTime); + + wb.write(bos); + byte[] xlsx = bos.toByteArray(); + + // ZIP + ByteArrayOutputStream zbos = new ByteArrayOutputStream(); + try (ZipOutputStream zos = new ZipOutputStream(zbos)) { + ZipEntry entry = new ZipEntry("ops_data_" + start + "_" + end + ".xlsx"); + zos.putNextEntry(entry); + zos.write(xlsx); + zos.closeEntry(); + } + return zbos.toByteArray(); + } + } + + // ══════════════════════════════════════════════════ + // Sheet 1: 概览汇总 + // ══════════════════════════════════════════════════ + + private void buildOverviewSheet(XSSFWorkbook wb, CellStyle headerStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("概览汇总"); + sheet.createFreezePane(0, 1); + int r = 0; + + // A: 运营指标(区间) + Row aTitle = sheet.createRow(r++); + createCell(aTitle, 0, "A: 运营指标(区间)", headerStyle); + Map stats = queryOverviewStats(null, startTime, endTime); + long activeUsers = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getUsername)) + .stream().map(ConversationEntity::getUsername).filter(Objects::nonNull).distinct().count(); + addKvRow(sheet, r++, "对话数", stats.getOrDefault("conversations", 0L).toString(), headerStyle); + addKvRow(sheet, r++, "消息数", stats.getOrDefault("messages", 0L).toString(), headerStyle); + addKvRow(sheet, r++, "工具调用次数", stats.getOrDefault("toolCalls", 0L).toString(), headerStyle); + long totalTokensVal = ((Number) stats.getOrDefault("totalTokens", 0L)).longValue(); + long conversationsVal = ((Number) stats.getOrDefault("conversations", 0L)).longValue(); + addKvRow(sheet, r++, "Token 消耗", String.valueOf(totalTokensVal), headerStyle); + addKvRow(sheet, r++, "平均Token/对话", conversationsVal > 0 ? String.format("%.0f", (double) totalTokensVal / conversationsVal) : "-", headerStyle); + addKvRow(sheet, r++, "活跃用户数", String.valueOf(activeUsers), headerStyle); + r++; + + // B: 系统快照 + Row bTitle = sheet.createRow(r++); + createCell(bTitle, 0, "B: 系统快照(当前)", headerStyle); + long skillCount = skillMapper.selectCount(new LambdaQueryWrapper().eq(SkillEntity::getDeleted, 0)); + long agentTotal = agentMapper.selectCount(new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0)); + long agentEnabled = agentMapper.selectCount(new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0).eq(AgentEntity::getEnabled, true)); + addKvRow(sheet, r++, "技能总数", String.valueOf(skillCount), headerStyle); + addKvRow(sheet, r++, "Agent 总数(已启用/总数)", agentEnabled + " / " + agentTotal, headerStyle); + addKvRow(sheet, r++, "定时任务数(已启用)", + String.valueOf(cronJobMapper.selectCount(new LambdaQueryWrapper().eq(CronJobEntity::getEnabled, true).eq(CronJobEntity::getDeleted, 0))), headerStyle); + r++; + + // C: 最近 7 天活跃度 + Row cTitle = sheet.createRow(r++); + createCell(cTitle, 0, "C: 最近 7 天活跃度", headerStyle); + // Sum all 7 days from trend + List> weekTrend = dashboardService.getTrend(null, 7); + long weekConv = 0, weekMsg = 0, weekTok = 0; + for (Map d : weekTrend) { + weekConv += toLong(d.getOrDefault("conversations", 0L)); + weekMsg += toLong(d.getOrDefault("messages", 0L)); + weekTok += toLong(d.getOrDefault("totalTokens", 0L)); + } + addKvRow(sheet, r++, "最近7天对话数", String.valueOf(weekConv), headerStyle); + addKvRow(sheet, r++, "最近7天消息数", String.valueOf(weekMsg), headerStyle); + addKvRow(sheet, r++, "最近7天 Token 消耗", String.valueOf(weekTok), headerStyle); + r++; + + // D: 周期对比 + Map overview = dashboardService.getOverview(null); + Map todayOv = (Map) overview.get("today"); + Map weekOv = (Map) overview.get("thisWeek"); + Map monthOv = (Map) overview.get("thisMonth"); + Row dTitle = sheet.createRow(r++); + createCell(dTitle, 0, "D: 周期对比", headerStyle); + createCell(dTitle, 1, "今日 / 本周 / 本月", headerStyle); + addKvRow(sheet, r++, "对话数", + todayOv.getOrDefault("conversations", 0) + " / " + weekOv.getOrDefault("conversations", 0) + " / " + monthOv.getOrDefault("conversations", 0) + " 次对话", headerStyle); + addKvRow(sheet, r++, "消息数", + todayOv.getOrDefault("messages", 0) + " / " + weekOv.getOrDefault("messages", 0) + " / " + monthOv.getOrDefault("messages", 0) + " 条消息", headerStyle); + addKvRow(sheet, r++, "Token 消耗", + todayOv.getOrDefault("totalTokens", 0) + " / " + weekOv.getOrDefault("totalTokens", 0) + " / " + monthOv.getOrDefault("totalTokens", 0) + " tokens", headerStyle); + addKvRow(sheet, r++, "工具调用", + todayOv.getOrDefault("toolCalls", 0) + " / " + weekOv.getOrDefault("toolCalls", 0) + " / " + monthOv.getOrDefault("toolCalls", 0) + " 次", headerStyle); + r++; + + // E: 当前模型详情(仅已配置的 Provider) + Row eTitle = sheet.createRow(r++); + createCell(eTitle, 0, "E: 当前模型详情", headerStyle); + List allProviders = modelProviderMapper.selectList( + new LambdaQueryWrapper()); + Set configuredProvIds = allProviders.stream() + .filter(p -> Boolean.TRUE.equals(p.getEnabled())) + .filter(p -> p.getApiKey() != null && !p.getApiKey().isBlank()) + .map(ModelProviderEntity::getProviderId) + .collect(Collectors.toSet()); + Map provIdToName = allProviders.stream().collect(Collectors.toMap(ModelProviderEntity::getProviderId, ModelProviderEntity::getName)); + List activeConfigs = modelConfigMapper.selectList( + new LambdaQueryWrapper().eq(ModelConfigEntity::getDeleted, 0)); + boolean hasAny = false; + for (ModelConfigEntity cfg : activeConfigs) { + if (!configuredProvIds.contains(cfg.getProvider())) continue; + hasAny = true; + String provName = provIdToName.getOrDefault(cfg.getProvider(), "-"); + addKvRow(sheet, r++, "Provider 名称", provName, headerStyle); + addKvRow(sheet, r++, "模型名称", cfg.getModelName() != null ? cfg.getModelName() : "-", headerStyle); + addKvRow(sheet, r++, "模型类型", label(cfg.getModelType() != null ? cfg.getModelType() : "-"), headerStyle); + addKvRow(sheet, r++, "Max Tokens", cfg.getMaxTokens() != null ? String.valueOf(cfg.getMaxTokens()) : "-", headerStyle); + addKvRow(sheet, r++, "Temperature", cfg.getTemperature() != null ? String.valueOf(cfg.getTemperature()) : "-", headerStyle); + if (cfg.getIsDefault() != null && cfg.getIsDefault()) { + addKvRow(sheet, r++, "默认模型", "是", headerStyle); + } + r++; + } + r++; + + // F: Agent 活跃排名 — by conversation.agentId → agent.name + Row fTitle = sheet.createRow(r++); + createCell(fTitle, 0, "F: Agent 活跃排名 (Top 10)", headerStyle); + String[] rankingCols = {"Agent", "对话数", "消息数", "Token消耗"}; + Row rHead = sheet.createRow(r++); + for (int i = 0; i < rankingCols.length; i++) createCell(rHead, i, rankingCols[i], headerStyle); + + // Load conversations to map convId → agentId + List rankConvs = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId, ConversationEntity::getAgentId)); + Map convAgentMap = rankConvs.stream() + .filter(c -> c.getAgentId() != null) + .collect(Collectors.toMap(ConversationEntity::getConversationId, ConversationEntity::getAgentId, (a, b) -> a)); + Map agentIdToName = agentMapper.selectList( + new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0)) + .stream().collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName, (a, b) -> a)); + + LambdaQueryWrapper rankW = new LambdaQueryWrapper() + .eq(MessageEntity::getRole, "assistant") + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, MessageEntity::getConversationId); + List rankMsgs = messageMapper.selectList(rankW); + + Map agentMap = new LinkedHashMap<>(); + Map> agentConvSet = new LinkedHashMap<>(); + for (MessageEntity m : rankMsgs) { + Long agId = convAgentMap.get(m.getConversationId()); + String agentName = agId != null ? agentIdToName.getOrDefault(agId, "Agent#" + agId) : "-"; + long[] acc = agentMap.computeIfAbsent(agentName, k -> new long[3]); + acc[0]++; // message count + acc[1] += m.getPromptTokens() != null ? m.getPromptTokens() : 0; + acc[1] += m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + agentConvSet.computeIfAbsent(agentName, k -> new HashSet<>()).add(m.getConversationId()); + } + final int[] rr = {r}; + agentMap.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue()[0], a.getValue()[0])) + .limit(10) + .forEach(e -> { + Row row = sheet.createRow(rr[0]++); + createCell(row, 0, e.getKey(), null); + createCell(row, 1, agentConvSet.getOrDefault(e.getKey(), Set.of()).size(), null); + createCell(row, 2, e.getValue()[0], null); + createCell(row, 3, e.getValue()[1], null); + }); + r = rr[0]; + + // Auto-size + sheet.setColumnWidth(0, 30 * 256); + sheet.setColumnWidth(1, 30 * 256); + } + + // ══════════════════════════════════════════════════ + // Sheet 2: Token用量 + // ══════════════════════════════════════════════════ + + private void buildTokenSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + LocalDate start, LocalDate end) { + Sheet sheet = wb.createSheet("Token用量"); + sheet.createFreezePane(0, 1); + String[] cols = {"日期", "Provider", "Prompt Tokens", "Completion Tokens", "总Tokens", "消息数", "平均Tokens/消息"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + // 按日聚合 byModel + LocalDateTime startTime = start.atStartOfDay(); + LocalDateTime endTime = end.atTime(LocalTime.MAX); + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(MessageEntity::getRole, "assistant") + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, + MessageEntity::getRuntimeProvider, MessageEntity::getCreateTime, MessageEntity::getRuntimeModel); + List msgs = messageMapper.selectList(w); + + Map byDateProv = new TreeMap<>(); + Map byDateMsg = new TreeMap<>(); + for (MessageEntity m : msgs) { + String date = m.getCreateTime().toLocalDate().toString(); + String prov = m.getRuntimeProvider() != null ? m.getRuntimeProvider() : "-"; + String key = date + "|" + prov; + long[] acc = byDateProv.computeIfAbsent(key, k -> new long[3]); + acc[0] += m.getPromptTokens() != null ? m.getPromptTokens() : 0; + acc[1] += m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + acc[2]++; + byDateMsg.merge(date, 1L, Long::sum); + } + + int r = 1; + for (Map.Entry e : byDateProv.entrySet()) { + String[] parts = e.getKey().split("\\|"); + long[] v = e.getValue(); + Row row = sheet.createRow(r++); + createCell(row, 0, parts[0], null); + createCell(row, 1, parts[1], null); + createCell(row, 2, v[0], numStyle); + createCell(row, 3, v[1], numStyle); + createCell(row, 4, v[0] + v[1], numStyle); + createCell(row, 5, v[2], numStyle); + createCell(row, 6, v[2] > 0 ? String.format("%.0f", (double)(v[0] + v[1]) / v[2]) : "-", null); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 3: 技能统计 + // ══════════════════════════════════════════════════ + + private void buildSkillSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + CellStyle dateStyle, LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("技能统计"); + sheet.createFreezePane(0, 1); + String[] cols = {"技能ID", "技能名称", "类型", "生命周期", "工作区ID", "工作区名称", "启用", "描述", "最近调用时间", "调用次数", "绑定Agent"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List skills = skillMapper.selectList( + new LambdaQueryWrapper().eq(SkillEntity::getDeleted, 0)); + + // usage stats + List usageRows = skillUsageStatMapper.selectList( + new LambdaQueryWrapper() + .ge(SkillUsageStatEntity::getLastLoadedAt, startTime) + .le(SkillUsageStatEntity::getLastLoadedAt, endTime)); + Map usageByName = new HashMap<>(); + for (SkillUsageStatEntity u : usageRows) { + long[] acc = usageByName.computeIfAbsent(u.getSkillName(), k -> new long[]{0, 0}); + acc[0] += u.getLoadCount() != null ? u.getLoadCount() : 0; + acc[1] = Math.max(acc[1], u.getLastLoadedAt() != null + ? u.getLastLoadedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0); + } + + // agent bindings + List bindings = agentSkillBindingMapper.selectList( + new LambdaQueryWrapper().eq(AgentSkillBinding::getEnabled, true)); + Map> agentsBySkill = new HashMap<>(); + for (AgentSkillBinding b : bindings) { + agentsBySkill.computeIfAbsent(b.getSkillId(), k -> new ArrayList<>()) + .add(String.valueOf(b.getAgentId())); + } + // resolve agent names + List allAgentIds = bindings.stream().map(AgentSkillBinding::getAgentId).distinct().toList(); + Map agentNames = new HashMap<>(); + if (!allAgentIds.isEmpty()) { + agentMapper.selectList(new LambdaQueryWrapper().in(AgentEntity::getId, allAgentIds)) + .forEach(a -> agentNames.put(a.getId(), a.getName())); + } + + Map wsNames = workspaceMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(WorkspaceEntity::getId, WorkspaceEntity::getName, (a, b) -> a)); + + int r = 1; + for (SkillEntity s : skills) { + Row row = sheet.createRow(r++); + createCell(row, 0, s.getId(), null); + createCell(row, 1, s.getName(), null); + createCell(row, 2, label(s.getSkillType()), null); + createCell(row, 3, label(s.getLifecycleState()), null); + createCell(row, 4, s.getWorkspaceId(), null); + createCell(row, 5, wsNames.getOrDefault(s.getWorkspaceId(), ""), null); + createCell(row, 6, label(Boolean.TRUE.equals(s.getEnabled()) ? "TRUE" : "FALSE"), null); + createCell(row, 7, s.getDescription(), null); + long[] usage = usageByName.get(s.getName()); + if (usage != null && usage[1] > 0) { + createCell(row, 8, java.time.LocalDateTime.ofEpochSecond(usage[1], 0, java.time.ZoneOffset.UTC), dateStyle); + createCell(row, 9, usage[0], numStyle); + } else { + createCell(row, 8, "", null); + createCell(row, 9, 0, numStyle); + } + List boundAgents = agentsBySkill.getOrDefault(s.getId(), List.of()); + createCell(row, 10, boundAgents.isEmpty() ? "" : + boundAgents.stream().map(id -> agentNames.getOrDefault(Long.valueOf(id), id.toString())).collect(Collectors.joining(", ")), null); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 4: 用户统计 + // ══════════════════════════════════════════════════ + + private void buildUserStatSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, CellStyle dateStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("用户统计"); + sheet.createFreezePane(0, 1); + String[] cols = {"工作区ID", "工作区名称", "用户ID", "用户名", "角色", "对话数", "Prompt Tokens", "Completion Tokens", "总Tokens", "总耗时(s)", "用户消息数", "平均会话时长(s)", "最后活跃时间"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + // Query conversations in range + List convs = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId, ConversationEntity::getWorkspaceId, + ConversationEntity::getUsername, ConversationEntity::getCreateTime, + ConversationEntity::getLastActiveTime)); + + // Group by (workspace_id, username) + Map userMap = new LinkedHashMap<>(); + Map> userConvIds = new LinkedHashMap<>(); + Map userDuration = new LinkedHashMap<>(); + Map userLastActive = new LinkedHashMap<>(); + for (ConversationEntity c : convs) { + String key = (c.getWorkspaceId() != null ? c.getWorkspaceId() : 0) + "|" + (c.getUsername() != null ? c.getUsername() : "-"); + userConvIds.computeIfAbsent(key, k -> new HashSet<>()).add(c.getConversationId()); + if (c.getCreateTime() != null && c.getLastActiveTime() != null) { + userDuration.merge(key, (long) java.time.Duration.between(c.getCreateTime(), c.getLastActiveTime()).getSeconds(), Long::sum); + } + if (c.getLastActiveTime() != null) { + userLastActive.merge(key, c.getLastActiveTime(), (a, b) -> a.isAfter(b) ? a : b); + } + } + + // Load workspaces and users + Map wsNames = workspaceMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(WorkspaceEntity::getId, WorkspaceEntity::getName, (a, b) -> a)); + Map users = userMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(UserEntity::getUsername, u -> u, (a, b) -> a)); + + // Query messages for token sums + if (!convs.isEmpty()) { + List allConvIds = convs.stream().map(ConversationEntity::getConversationId).distinct().toList(); + List msgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, allConvIds) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getConversationId, MessageEntity::getPromptTokens, + MessageEntity::getCompletionTokens, MessageEntity::getRole)); + // Aggregate by (ws, username) + // Re-query to get the user mapping... For simplicity, aggregate in memory + Map convWsMap = convs.stream().collect(Collectors.toMap( + ConversationEntity::getConversationId, c -> (c.getWorkspaceId() != null ? c.getWorkspaceId() : 0L) + "|" + c.getUsername(), (a, b) -> a)); + for (MessageEntity m : msgs) { + String key = convWsMap.get(m.getConversationId()); + if (key == null) continue; + long[] acc = userMap.computeIfAbsent(key, k -> new long[5]); + if ("assistant".equals(m.getRole())) { + acc[0] += m.getPromptTokens() != null ? m.getPromptTokens() : 0; + acc[1] += m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + acc[2]++; + } else if ("user".equals(m.getRole())) { + acc[3]++; + } + } + } + + int r = 1; + for (String key : userConvIds.keySet()) { + String[] parts = key.split("\\|"); + long wsId = Long.parseLong(parts[0]); + String uname = parts[1]; + long[] acc = userMap.getOrDefault(key, new long[5]); + Row row = sheet.createRow(r++); + createCell(row, 0, wsId, null); + createCell(row, 1, wsNames.getOrDefault(wsId, ""), null); + UserEntity u = users.get(uname); + createCell(row, 2, u != null ? u.getId() : 0, null); + createCell(row, 3, uname, null); + createCell(row, 4, u != null ? u.getRole() : "", null); + createCell(row, 5, userConvIds.get(key).size(), numStyle); + createCell(row, 6, acc[0], numStyle); + createCell(row, 7, acc[1], numStyle); + createCell(row, 8, acc[0] + acc[1], numStyle); + createCell(row, 9, userDuration.getOrDefault(key, 0L), numStyle); + createCell(row, 10, acc[3], numStyle); + long durSec = userDuration.getOrDefault(key, 0L); + long convCount = userConvIds.get(key).size(); + createCell(row, 11, convCount > 0 ? String.format("%.1f", (double) durSec / convCount) : "-", null); + createCell(row, 12, userLastActive.get(key) != null ? userLastActive.get(key) : "", dateStyle); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 5: 用户对话(明细) + // ══════════════════════════════════════════════════ + + private void buildUserDetailSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + CellStyle dateStyle, LocalDateTime startTime, LocalDateTime endTime, + boolean enforceLimit) { + Sheet sheet = wb.createSheet("用户对话"); + sheet.createFreezePane(0, 1); + String[] cols = {"工作区ID", "工作区名称", "用户ID", "用户名", "角色", "Agent名称", "会话ID", "消息时间", "用户内容", "响应Tokens", "响应模型", "响应Provider", "响应耗时(s)"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + // Load convs in range + List convs = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId, ConversationEntity::getWorkspaceId, + ConversationEntity::getUsername, ConversationEntity::getAgentId)); + if (convs.isEmpty()) { for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); return; } + + Map convWs = convs.stream().collect(Collectors.toMap( + ConversationEntity::getConversationId, c -> c.getWorkspaceId() != null ? c.getWorkspaceId() : 0, (a, b) -> a)); + Map convUser = convs.stream().collect(Collectors.toMap( + ConversationEntity::getConversationId, c -> c.getUsername() != null ? c.getUsername() : "-", (a, b) -> a)); + Map convAgent = convs.stream().filter(c -> c.getAgentId() != null).collect(Collectors.toMap( + ConversationEntity::getConversationId, ConversationEntity::getAgentId, (a, b) -> a)); + Map wsNames = workspaceMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(WorkspaceEntity::getId, WorkspaceEntity::getName, (a, b) -> a)); + Map userMap = userMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(UserEntity::getUsername, u -> u, (a, b) -> a)); + Map agentNames = agentMapper.selectList(new LambdaQueryWrapper().eq(AgentEntity::getDeleted, 0)) + .stream().collect(Collectors.toMap(AgentEntity::getId, AgentEntity::getName, (a, b) -> a)); + + List convIds = convs.stream().map(ConversationEntity::getConversationId).toList(); + List msgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, convIds) + .eq(MessageEntity::getDeleted, 0) + .orderByAsc(MessageEntity::getConversationId) + .orderByAsc(MessageEntity::getCreateTime) + .select(MessageEntity::getConversationId, MessageEntity::getRole, MessageEntity::getCreateTime, + MessageEntity::getContent, MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, + MessageEntity::getRuntimeModel, MessageEntity::getRuntimeProvider)); + + // Pair user messages with next assistant + int r = 1; + for (int i = 0; i < msgs.size() - 1 && (!enforceLimit || r <= LIMIT_USER_MSGS); i++) { + MessageEntity m = msgs.get(i); + if (!"user".equals(m.getRole())) continue; + MessageEntity next = msgs.get(i + 1); + boolean isAssistant = "assistant".equals(next.getRole()); + Row row = sheet.createRow(r++); + Long wsId = convWs.get(m.getConversationId()); + String uname = convUser.get(m.getConversationId()); + UserEntity u = userMap.get(uname); + createCell(row, 0, wsId != null ? wsId : 0, null); + createCell(row, 1, wsNames.getOrDefault(wsId, ""), null); + createCell(row, 2, u != null ? u.getId() : 0, null); + createCell(row, 3, uname, null); + createCell(row, 4, u != null ? u.getRole() : "", null); + Long agId = convAgent.get(m.getConversationId()); + createCell(row, 5, agId != null ? agentNames.getOrDefault(agId, "-") : "-", null); + createCell(row, 6, m.getConversationId(), null); + createCell(row, 7, m.getCreateTime(), dateStyle); + createCell(row, 8, m.getContent(), null); + if (isAssistant) { + createCell(row, 9, + (long)(next.getCompletionTokens() != null ? next.getCompletionTokens() : 0), numStyle); + createCell(row, 10, next.getRuntimeModel(), null); + createCell(row, 11, next.getRuntimeProvider(), null); + createCell(row, 12, java.time.Duration.between(m.getCreateTime(), next.getCreateTime()).toMillis() / 1000.0, numStyle); + } + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 6: 安全与审计 + // ══════════════════════════════════════════════════ + + private void buildSecuritySheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle dateStyle, + LocalDateTime startTime, LocalDateTime endTime, boolean enforceLimit) { + Sheet sheet = wb.createSheet("安全与审计"); + sheet.createFreezePane(0, 1); + String[] cols = {"来源", "ID", "工作区ID", "工具名/资源类型", "严重级别", "决策", "状态", "操作人", "分类", "授权范围类型", "授权范围ID", "描述", "时间", "资源类型", "操作", "详情"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + int r = 1; + r = appendGuardRules(sheet, r, cols.length, dateStyle); + r = appendGuardAuditLog(sheet, r, cols.length, startTime, endTime, dateStyle); + r = appendApprovals(sheet, r, cols.length, startTime, endTime, dateStyle); + r = appendGrants(sheet, r, cols.length, dateStyle); + r = appendGuardConfig(sheet, r, cols.length, dateStyle); + if (enforceLimit) r = Math.min(r, LIMIT_AUDIT + 1); + // business audit events + List auditEvents = auditEventMapper.selectList( + new LambdaQueryWrapper() + .ge(AuditEventEntity::getCreateTime, startTime) + .le(AuditEventEntity::getCreateTime, endTime) + .orderByDesc(AuditEventEntity::getCreateTime) + .last(enforceLimit ? "LIMIT " + LIMIT_AUDIT : "")); + for (AuditEventEntity ae : auditEvents) { + Row row = sheet.createRow(r++); + createCell(row, 0, "business_audit", null); + createCell(row, 1, ae.getId(), null); + createCell(row, 2, ae.getWorkspaceId(), null); + createCell(row, 3, "", null); + createCell(row, 4, "", null); + createCell(row, 5, "", null); + createCell(row, 6, "", null); + createCell(row, 7, ae.getUsername(), null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, "", null); + createCell(row, 12, ae.getCreateTime(), dateStyle); + createCell(row, 13, label(ae.getResourceType()), null); + createCell(row, 14, label(ae.getAction()), null); + createCell(row, 15, ae.getDetailJson(), null); + } + + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + private int appendGuardRules(Sheet sheet, int r, int maxCols, CellStyle dateStyle) { + List rules = toolGuardRuleMapper.selectList( + new LambdaQueryWrapper().eq(ToolGuardRuleEntity::getDeleted, 0)); + for (ToolGuardRuleEntity rl : rules) { + Row row = sheet.createRow(r++); + createCell(row, 0, "rule", null); + createCell(row, 1, rl.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, rl.getToolName(), null); + createCell(row, 4, rl.getSeverity() != null ? rl.getSeverity().toLowerCase() : "", null); + createCell(row, 5, rl.getDecision(), null); + createCell(row, 6, Boolean.TRUE.equals(rl.getEnabled()) ? "enabled" : "disabled", null); + createCell(row, 7, "", null); + createCell(row, 8, rl.getCategory(), null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, rl.getDescription(), null); + createCell(row, 12, rl.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "", null); + } + return r; + } + + private int appendGuardAuditLog(Sheet sheet, int r, int maxCols, LocalDateTime start, LocalDateTime end, CellStyle dateStyle) { + List logs = toolGuardAuditLogMapper.selectList( + new LambdaQueryWrapper() + .ge(ToolGuardAuditLogEntity::getCreateTime, start) + .le(ToolGuardAuditLogEntity::getCreateTime, end)); + Map userIdToName = userMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(UserEntity::getId, UserEntity::getUsername, (a, b) -> a)); + for (ToolGuardAuditLogEntity l : logs) { + Row row = sheet.createRow(r++); + createCell(row, 0, "audit", null); + createCell(row, 1, l.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, l.getToolName(), null); + createCell(row, 4, l.getMaxSeverity() != null ? l.getMaxSeverity().toLowerCase() : "", null); + createCell(row, 5, l.getDecision(), null); + createCell(row, 6, "", null); + createCell(row, 7, l.getUserId() != null ? userIdToName.getOrDefault(l.getUserId(), String.valueOf(l.getUserId())) : "", null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, "", null); + createCell(row, 12, l.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, l.getFindingsJson(), null); + } + return r; + } + + private int appendApprovals(Sheet sheet, int r, int maxCols, LocalDateTime start, LocalDateTime end, CellStyle dateStyle) { + List approvals = toolApprovalMapper.selectList( + new LambdaQueryWrapper() + .ge(ToolApprovalEntity::getCreateTime, start) + .le(ToolApprovalEntity::getCreateTime, end)); + for (ToolApprovalEntity a : approvals) { + Row row = sheet.createRow(r++); + createCell(row, 0, "approval", null); + createCell(row, 1, a.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, a.getToolName(), null); + createCell(row, 4, a.getMaxSeverity() != null ? a.getMaxSeverity().toLowerCase() : "", null); + createCell(row, 5, "", null); + createCell(row, 6, a.getStatus(), null); + createCell(row, 7, a.getRequesterName(), null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, a.getSummary(), null); + createCell(row, 12, a.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "", null); + } + return r; + } + + private int appendGrants(Sheet sheet, int r, int maxCols, CellStyle dateStyle) { + List grants = approvalGrantMapper.selectList( + new LambdaQueryWrapper().eq(ApprovalGrant::getDeleted, 0)); + for (ApprovalGrant g : grants) { + Row row = sheet.createRow(r++); + createCell(row, 0, "grant", null); + createCell(row, 1, g.getId(), null); + createCell(row, 2, g.getWorkspaceId(), null); + createCell(row, 3, g.getToolName(), null); + createCell(row, 4, g.getMaxSeverity(), null); + createCell(row, 5, "", null); + createCell(row, 6, g.getRevoked() != null && g.getRevoked() == 0 ? "active" : "revoked", null); + createCell(row, 7, g.getGrantedBy() != null ? String.valueOf(g.getGrantedBy()) : "", null); + createCell(row, 8, "", null); + createCell(row, 9, g.getScopeType(), null); + createCell(row, 10, g.getScopeId(), null); + createCell(row, 11, g.getNote(), null); + createCell(row, 12, g.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "", null); + } + return r; + } + + private int appendGuardConfig(Sheet sheet, int r, int maxCols, CellStyle dateStyle) { + List configs = toolGuardConfigMapper.selectList(new LambdaQueryWrapper<>()); + for (ToolGuardConfigEntity c : configs) { + Row row = sheet.createRow(r++); + createCell(row, 0, "config", null); + createCell(row, 1, c.getId(), null); + createCell(row, 2, null, null); + createCell(row, 3, "", null); + createCell(row, 4, "", null); + createCell(row, 5, "", null); + createCell(row, 6, Boolean.TRUE.equals(c.getEnabled()) ? "enabled" : "disabled", null); + createCell(row, 7, "", null); + createCell(row, 8, "", null); + createCell(row, 9, "", null); + createCell(row, 10, "", null); + createCell(row, 11, "", null); + createCell(row, 12, c.getCreateTime(), dateStyle); + createCell(row, 13, "", null); + createCell(row, 14, "", null); + createCell(row, 15, "guard_scope=" + (c.getGuardScope() != null ? c.getGuardScope() : "-") + + "; file_guard=" + (c.getFileGuardEnabled() != null ? c.getFileGuardEnabled() : "-") + + "; retention=" + (c.getAuditRetentionDays() != null ? c.getAuditRetentionDays() : "-") + "d", null); + } + return r; + } + + // ══════════════════════════════════════════════════ + // Sheet 7: 渠道统计 + // ══════════════════════════════════════════════════ + + private void buildChannelSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("渠道统计"); + sheet.createFreezePane(0, 1); + String[] cols = {"渠道ID", "渠道名称", "渠道类型", "工作区ID", "启用", "绑定Agent", "对话数", "总Tokens", "独立用户数"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List channels = channelMapper.selectList( + new LambdaQueryWrapper().eq(ChannelEntity::getDeleted, 0)); + Map agentNames = new HashMap<>(); + for (ChannelEntity ch : channels) { + if (ch.getAgentId() != null) { + AgentEntity ag = agentMapper.selectById(ch.getAgentId()); + if (ag != null) agentNames.put(ch.getAgentId(), ag.getName()); + } + } + + int r = 1; + for (ChannelEntity ch : channels) { + long conversations = 0, tokens = 0, uniqueUsers = 0; + if (ch.getAgentId() != null) { + // count conversations for this agent + conversations = conversationMapper.selectCount( + new LambdaQueryWrapper() + .eq(ConversationEntity::getAgentId, ch.getAgentId()) + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime)); + // sum tokens + List agentConvIds = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getAgentId, ch.getAgentId()) + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime) + .select(ConversationEntity::getConversationId)) + .stream().map(ConversationEntity::getConversationId).toList(); + if (!agentConvIds.isEmpty()) { + List msgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, agentConvIds) + .eq(MessageEntity::getRole, "assistant") + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens)); + for (MessageEntity m : msgs) { + tokens += (m.getPromptTokens() != null ? m.getPromptTokens() : 0) + + (m.getCompletionTokens() != null ? m.getCompletionTokens() : 0); + } + uniqueUsers = conversationMapper.selectList( + new LambdaQueryWrapper() + .in(ConversationEntity::getConversationId, agentConvIds) + .select(ConversationEntity::getUsername)) + .stream().map(ConversationEntity::getUsername).filter(Objects::nonNull).distinct().count(); + } + } + Row row = sheet.createRow(r++); + createCell(row, 0, ch.getId(), null); + createCell(row, 1, ch.getName(), null); + createCell(row, 2, ch.getChannelType(), null); + createCell(row, 3, ch.getWorkspaceId(), null); + createCell(row, 4, label(Boolean.TRUE.equals(ch.getEnabled()) ? "TRUE" : "FALSE"), null); + createCell(row, 5, agentNames.getOrDefault(ch.getAgentId(), ""), null); + createCell(row, 6, conversations, numStyle); + createCell(row, 7, tokens, numStyle); + createCell(row, 8, uniqueUsers, numStyle); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 8: 模型配置 + // ══════════════════════════════════════════════════ + + private void buildModelSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle numStyle) { + Sheet sheet = wb.createSheet("模型配置"); + sheet.createFreezePane(0, 1); + String[] cols = {"Provider ID", "Provider名称", "模型名称", "模型类型", "启用", "Temperature", "Max Tokens", "默认模型", "最大输入Tokens", "描述"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List configs = modelConfigMapper.selectList( + new LambdaQueryWrapper().eq(ModelConfigEntity::getDeleted, 0)); + List allProvs = modelProviderMapper.selectList(new LambdaQueryWrapper<>()); + Map provNames = allProvs.stream().collect(Collectors.toMap(ModelProviderEntity::getProviderId, ModelProviderEntity::getName, (a, b) -> a)); + Set configuredProvIds = allProvs.stream() + .filter(p -> Boolean.TRUE.equals(p.getEnabled())) + .filter(p -> p.getApiKey() != null && !p.getApiKey().isBlank()) + .map(ModelProviderEntity::getProviderId) + .collect(Collectors.toSet()); + + int r = 1; + for (ModelConfigEntity c : configs) { + if (!configuredProvIds.contains(c.getProvider())) continue; + Row row = sheet.createRow(r++); + createCell(row, 0, c.getProvider(), null); + createCell(row, 1, provNames.getOrDefault(c.getProvider(), ""), null); + createCell(row, 2, c.getModelName(), null); + createCell(row, 3, label(c.getModelType() != null ? c.getModelType() : "-"), null); + createCell(row, 4, label(Boolean.TRUE.equals(c.getEnabled()) ? "TRUE" : "FALSE"), null); + if (c.getTemperature() != null) createCell(row, 5, c.getTemperature().doubleValue(), numStyle); + else createCell(row, 5, "", null); + if (c.getMaxTokens() != null) createCell(row, 6, c.getMaxTokens(), numStyle); + else createCell(row, 6, "", null); + createCell(row, 7, Boolean.TRUE.equals(c.getIsDefault()) ? "是" : "否", null); + createCell(row, 8, c.getMaxInputTokens() != null ? c.getMaxInputTokens() : "", numStyle); + createCell(row, 9, c.getDescription(), null); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // Sheet 9: 定时任务 + // ══════════════════════════════════════════════════ + + private void buildCronSheet(XSSFWorkbook wb, CellStyle headerStyle, CellStyle dateStyle, + LocalDateTime startTime, LocalDateTime endTime) { + Sheet sheet = wb.createSheet("定时任务"); + sheet.createFreezePane(0, 1); + String[] cols = {"任务ID", "任务名称", "触发方式", "状态", "耗时(秒)", "Token消耗", "执行时间"}; + Row header = sheet.createRow(0); + for (int i = 0; i < cols.length; i++) createCell(header, i, cols[i], headerStyle); + + List runs = cronJobRunMapper.selectList( + new LambdaQueryWrapper() + .ge(CronJobRunEntity::getStartedAt, startTime) + .le(CronJobRunEntity::getStartedAt, endTime) + .orderByDesc(CronJobRunEntity::getStartedAt)); + + Map jobNames = cronJobMapper.selectList(new LambdaQueryWrapper()) + .stream().collect(Collectors.toMap(CronJobEntity::getId, CronJobEntity::getName, (a, b) -> a)); + + int r = 1; + for (CronJobRunEntity run : runs) { + Row row = sheet.createRow(r++); + createCell(row, 0, run.getCronJobId(), null); + createCell(row, 1, jobNames.getOrDefault(run.getCronJobId(), ""), null); + createCell(row, 2, run.getTriggerType(), null); + createCell(row, 3, run.getStatus(), null); + if (run.getStartedAt() != null && run.getFinishedAt() != null) { + createCell(row, 4, java.time.Duration.between(run.getStartedAt(), run.getFinishedAt()).toMillis() / 1000.0, null); + } else { + createCell(row, 4, "", null); + } + createCell(row, 5, run.getTokenUsage() != null ? run.getTokenUsage() : "", null); + createCell(row, 6, run.getStartedAt(), dateStyle); + } + for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); + } + + // ══════════════════════════════════════════════════ + // 工具方法 + // ══════════════════════════════════════════════════ + + private Map queryOverviewStats(Long workspaceId, LocalDateTime start, LocalDateTime end) { + // Reuse DashboardService logic but with custom time range + // Simplified: just query counts and tokens + LambdaQueryWrapper cw = new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, start) + .le(ConversationEntity::getCreateTime, end); + long conversations = conversationMapper.selectCount(cw); + + List convIds = conversationMapper.selectList( + new LambdaQueryWrapper() + .eq(ConversationEntity::getDeleted, 0) + .ge(ConversationEntity::getCreateTime, start) + .le(ConversationEntity::getCreateTime, end) + .select(ConversationEntity::getConversationId)) + .stream().map(ConversationEntity::getConversationId).toList(); + + Map stats = new LinkedHashMap<>(); + stats.put("conversations", conversations); + if (convIds.isEmpty()) { + stats.put("messages", 0L); + stats.put("totalTokens", 0L); + stats.put("toolCalls", 0L); + return stats; + } + + long messages = messageMapper.selectCount( + new LambdaQueryWrapper().in(MessageEntity::getConversationId, convIds).eq(MessageEntity::getDeleted, 0)); + stats.put("messages", messages); + + List assistantMsgs = messageMapper.selectList( + new LambdaQueryWrapper() + .in(MessageEntity::getConversationId, convIds) + .eq(MessageEntity::getRole, "assistant") + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens, MessageEntity::getMetadata)); + long totalTokens = 0, toolCalls = 0; + for (MessageEntity m : assistantMsgs) { + totalTokens += (m.getPromptTokens() != null ? m.getPromptTokens() : 0) + + (m.getCompletionTokens() != null ? m.getCompletionTokens() : 0); + toolCalls += countToolCallsFromMetadata(m.getMetadata()); + } + stats.put("totalTokens", totalTokens); + stats.put("toolCalls", toolCalls); + return stats; + } + + private String label(String raw) { + if (raw == null) return ""; + return LABEL_MAP.getOrDefault(raw, raw); + } + + private static long toLong(Object val) { + if (val instanceof Number n) return n.longValue(); + if (val instanceof String s) { + try { return Long.parseLong(s); } catch (NumberFormatException ignored) { } + } + return 0L; + } + + private long countToolCallsFromMetadata(String metadataJson) { + if (metadataJson == null || metadataJson.isBlank() || "{}".equals(metadataJson.trim())) { + return 0; + } + try { + String json = metadataJson.trim(); + if (json.startsWith("\"") && json.endsWith("\"")) { + json = objectMapper.readValue(json, String.class); + } + if (json.isBlank() || "{}".equals(json)) return 0; + JsonNode root = objectMapper.readTree(json); + JsonNode toolCalls = root.get("toolCalls"); + if (toolCalls != null && toolCalls.isArray() && !toolCalls.isEmpty()) { + return toolCalls.size(); + } + JsonNode segments = root.get("segments"); + if (segments != null && segments.isArray()) { + long count = 0; + for (JsonNode seg : segments) { + if ("tool_call".equals(seg.path("type").asText())) count++; + } + return count; + } + } catch (Exception ignored) { } + return 0; + } + + private void addKvRow(Sheet sheet, int r, String key, String value, CellStyle style) { + Row row = sheet.createRow(r); + Cell k = row.createCell(0); + k.setCellValue(key); + if (style != null) k.setCellStyle(style); + row.createCell(1).setCellValue(value); + } + + private CellStyle createHeaderStyle(XSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + Font font = wb.createFont(); + font.setBold(true); + style.setFont(font); + style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + style.setFillPattern(FillPatternType.SOLID_FOREGROUND); + style.setBorderBottom(BorderStyle.THIN); + return style; + } + + private CellStyle createNumStyle(XSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + style.setDataFormat(wb.createDataFormat().getFormat("#,##0")); + return style; + } + + private CellStyle createDateStyle(XSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + style.setDataFormat(wb.createDataFormat().getFormat("yyyy-MM-dd HH:mm:ss")); + return style; + } + + private void createCell(Row row, int col, Object value, CellStyle style) { + Cell cell = row.createCell(col); + if (value == null) { + cell.setCellValue(""); + } else if (value instanceof String s) { + cell.setCellValue(s); + } else if (value instanceof Long l) { + cell.setCellValue((double) l); + } else if (value instanceof Integer i) { + cell.setCellValue((double) i); + } else if (value instanceof Double d) { + cell.setCellValue(d); + } else if (value instanceof Boolean b) { + cell.setCellValue(b.toString()); + } else if (value instanceof LocalDateTime ldt) { + cell.setCellValue(ldt.format(DT_FMT)); + } else { + cell.setCellValue(value.toString()); + } + if (style != null) cell.setCellStyle(style); + } + + // ══════════════════════════════════════════════════ + // 定时清理 + // ══════════════════════════════════════════════════ + + @Scheduled(fixedRate = 3600000) + public void cleanExpiredTasks() { + long cutoff = System.currentTimeMillis() - 86400000; + tasks.values().removeIf(task -> { + if (task.getCompletedAt() > 0 && task.getCompletedAt() < cutoff) { + try { Files.deleteIfExists(task.getFilePath()); } catch (IOException ignored) {} + return true; + } + return false; + }); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index f1693ec1..b6291684 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -1039,6 +1039,28 @@ export const dashboardApi = { recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }), } +// ==================== Operational Data Export ==================== +export const operationalApi = { + generate: (startDate: string, endDate: string) => + http.post('/operational-data/generate', null, { params: { startDate, endDate } }), + progress: (taskId: string) => + http.get('/operational-data/progress', { params: { taskId } }), + /** Download file — uses native fetch to avoid axios R interceptor */ + download: async (taskId: string, token: string): Promise => { + const jwt = localStorage.getItem('token') + const resp = await fetch(`/api/v1/operational-data/download?taskId=${taskId}&token=${token}`, { + headers: { Authorization: jwt ? `Bearer ${jwt}` : '' }, + }) + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`) + const blob = await resp.blob() + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `ops_data.zip` + a.click() + URL.revokeObjectURL(a.href) + }, +} + // ==================== Plugins ==================== export const pluginApi = { list: () => http.get('/plugins'), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 3d487e51..9d824d6e 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2028,8 +2028,8 @@ export default { tier: { toCore: '→ Core', toExtension: '→ Extension', - toCoreHint: 'Make core: advertised to the model directly', - toExtensionHint: 'Make extension: lives in the tool box, activated after enable_tool', + toCoreHint: 'Move to Core: advertised to the model directly', + toExtensionHint: 'Move to Extension: lives in the tool box, activated after enable_tool', locked: 'Source-owned', lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there", core: { desc: 'Advertised to the model directly' }, @@ -3953,6 +3953,16 @@ export default { duration: 'Duration', tokens: 'Tokens', }, + operationalExport: 'Export Operational Data', + exportDescription: 'Select a date range to generate an operational report. Max 90 days.', + generateReport: 'Generate Report', + generating: 'Generating...', + downloadReport: 'Download', + regenerating: 'Regenerate', + generateFailed: 'Generation Failed', + exportInProgress: 'Export in progress...', + expiredHint: 'Report is ready, please download soon', + generatingProgress: 'Generating... ({step}/{total})', }, memory: { kicker: 'Memory', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 328a54de..74c47a9b 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1903,8 +1903,8 @@ export default { tier: { toCore: '→ 核心', toExtension: '→ 扩展', - toCoreHint: '改为核心:直接进入模型可调用列表', - toExtensionHint: '改为扩展:进入工具盒目录,调用 enable_tool 后激活', + toCoreHint: '移至核心工具:直接进入模型可调用列表', + toExtensionHint: '移至扩展工具:进入工具盒目录,模型调用 enable_tool 后激活', locked: '由来源决定', lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改', core: { desc: '直接进入模型可调用列表' }, @@ -4045,6 +4045,16 @@ export default { duration: '耗时', tokens: 'Token', }, + operationalExport: '导出运营数据', + exportDescription: '选择时间范围,生成运营数据报告。最长 90 天。', + generateReport: '生成报告', + generating: '生成中...', + downloadReport: '下载', + regenerating: '重新生成', + generateFailed: '生成失败', + exportInProgress: '后台生成中...', + expiredHint: '数据已生成,请尽快下载', + generatingProgress: '生成中... ({step}/{total})', }, memory: { kicker: '记忆', diff --git a/mateclaw-ui/src/views/Dashboard.vue b/mateclaw-ui/src/views/Dashboard.vue index 73d705b1..b2864093 100644 --- a/mateclaw-ui/src/views/Dashboard.vue +++ b/mateclaw-ui/src/views/Dashboard.vue @@ -3,20 +3,21 @@

-
+
{{ t('dashboard.kicker') }}

{{ t('dashboard.title') }}

{{ t('dashboard.desc') }}

-
- - {{ t('doctor.database') }} - {{ dbLabel }} -
+
-
-
{{ t('dashboard.periods.today') }}
-
{{ todayStats.conversations }}
-
{{ t('dashboard.conversations') }} · {{ todayStats.messages }} {{ t('dashboard.messages') }}
+
+
+
{{ t('dashboard.periods.today') }}
+
{{ todayStats.conversations }}
+
{{ t('dashboard.conversations') }} · {{ todayStats.messages }} {{ t('dashboard.messages') }}
+
@@ -187,6 +188,72 @@
+ + + +
+

{{ t('dashboard.exportDescription') }}

+ + + +
+
+ + + + + + +
+ {{ exportStep }}/{{ exportTotal }} + {{ exportStepLabel }} +
+
+
+ +

{{ t('dashboard.expiredHint') }}

+

生成失败,请重试

+
+ +
@@ -195,8 +262,9 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue' -import { dashboardApi, modelApi, http } from '@/api' +import { dashboardApi, modelApi, operationalApi } from '@/api' import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons' +import { useWorkspaceStore } from '@/stores/useWorkspaceStore' import * as echarts from 'echarts/core' import { LineChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components' @@ -206,6 +274,8 @@ echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, Canvas const { t, locale } = useI18n() const router = useRouter() +const workspaceStore = useWorkspaceStore() +const isGlobalAdmin = computed(() => workspaceStore.isGlobalAdmin) const overview = ref>({}) const recentRuns = ref([]) @@ -224,9 +294,7 @@ const todayStats = reactive({ // ── Model configuration card ── const modelProviders = ref([]) const activeModel = ref<{ providerId: string; model: string } | null>(null) -// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), surfaced -// as a subtle line in the page header. Empty string hides it when unavailable. -const dbLabel = ref('') + const readyProviderCount = computed( () => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length, @@ -280,15 +348,6 @@ onMounted(async () => { // Dashboard data is non-critical } - // Connected database label — independent and non-critical. Reuses the - // existing system health endpoint, which already reports the product name. - try { - const healthRes: any = await http.get('/system/health') - dbLabel.value = (healthRes?.data || healthRes)?.database || '' - } catch { - dbLabel.value = '' - } - // Model configuration card — loaded independently so a failure here never // blanks the analytics above, and vice versa. try { @@ -402,6 +461,122 @@ function calcDuration(run: any): string { if (ms < 1000) return ms + 'ms' return (ms / 1000).toFixed(1) + 's' } + +// ── Operational Data Export ── +const exportDialogVisible = ref(false) +const exportDateRange = ref<[string, string] | null>(null) +const exportStatus = ref<'idle' | 'generating' | 'locked' | 'completed' | 'failed'>('idle') +const exportStep = ref(0) +const exportTotal = ref(9) +const exportTaskId = ref('') +const exportDownloadToken = ref('') +let exportPollTimer: ReturnType | null = null + +const stepLabels = ['概览汇总', 'Token用量', '技能统计', '用户统计', '用户对话', '安全与审计', '渠道统计', '模型配置', '定时任务'] + +const exportStepLabel = computed(() => { + const i = exportStep.value - 1 + return stepLabels[i] || '' +}) + +const circumference = 2 * Math.PI * 52 // r=52 + +const progressOffset = computed(() => { + const pct = Math.min(exportStep.value / exportTotal.value, 1) + return circumference * (1 - pct) +}) + +const disabledExportDate = (date: Date) => { + // Max 90 days, not in the future + const now = new Date() + now.setHours(23, 59, 59, 0) + return date > now +} + +function openExportDialog() { + // Reconnect to in-progress or completed task + if (exportStatus.value === 'completed') { + // Keep completed state — user can still download + } else if (exportStatus.value === 'generating' || exportStatus.value === 'locked') { + // Resume polling for in-progress task + startPolling() + } else { + // Fresh start + const end = new Date() + const start = new Date() + start.setDate(start.getDate() - 30) + exportDateRange.value = [formatDateStr(start), formatDateStr(end)] + exportStatus.value = 'idle' + exportStep.value = 0 + } + exportDialogVisible.value = true +} + +watch(exportDialogVisible, (v) => { + if (!v && exportPollTimer) { + clearInterval(exportPollTimer) + exportPollTimer = null + } +}) + +function formatDateStr(d: Date): string { + return d.toISOString().slice(0, 10) +} + +async function doGenerate() { + if (!exportDateRange.value) return + try { + exportStatus.value = 'generating' + const [start, end] = exportDateRange.value + const res: any = await operationalApi.generate(start, end) + exportTaskId.value = res.data?.taskId || res.taskId || '' + if (!exportTaskId.value) throw new Error('No taskId') + startPolling() + } catch (e: any) { + const msg = e?.response?.data?.msg || e?.message || 'Unknown error' + if (e?.response?.status === 409) { + exportStatus.value = 'locked' + } else { + exportStatus.value = 'failed' + console.error('Export generate failed:', msg) + } + } +} + +function startPolling() { + if (exportPollTimer) clearInterval(exportPollTimer) + exportPollTimer = setInterval(async () => { + try { + const res: any = await operationalApi.progress(exportTaskId.value) + const data = res.data || res + exportStep.value = data.step || 0 + exportTotal.value = data.total || 9 + if (data.status === 'completed') { + exportStatus.value = 'completed' + exportDownloadToken.value = data.downloadToken || '' + if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null } + } else if (data.status === 'failed') { + exportStatus.value = 'failed' + if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null } + } + } catch { + exportStatus.value = 'failed' + if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null } + } + }, 1000) +} + +async function doDownload() { + try { + await operationalApi.download(exportTaskId.value, exportDownloadToken.value) + exportDialogVisible.value = false + exportStatus.value = 'idle' + exportTaskId.value = '' + exportDownloadToken.value = '' + } catch (e) { + console.error('Download failed:', e) + } +}