From 9310335cc8aa16fb665720bd11acac0516576ae6 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 24 Jun 2026 17:48:42 +0800 Subject: [PATCH] fix(operational): admin gate, atomic one-time download, lock safety and Excel ID precision --- .../controller/OperationalDataController.java | 10 ++--- .../mate/operational/model/ExportTask.java | 8 ++-- .../service/OperationalDataExportService.java | 41 +++++++++++++------ mateclaw-ui/src/views/Dashboard.vue | 1 - 4 files changed, 39 insertions(+), 21 deletions(-) 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 index 2d65a058..3f8934a4 100644 --- a/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java +++ b/mateclaw-server/src/main/java/vip/mate/operational/controller/OperationalDataController.java @@ -5,10 +5,10 @@ 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 vip.mate.workspace.core.annotation.RequireGlobalAdmin; import java.io.IOException; import java.nio.file.Files; @@ -32,7 +32,7 @@ public class OperationalDataController { } @PostMapping("/generate") - @PreAuthorize("hasRole('ADMIN')") + @RequireGlobalAdmin public ResponseEntity> generate( @RequestParam LocalDate startDate, @RequestParam LocalDate endDate) { @@ -54,7 +54,7 @@ public class OperationalDataController { * 查询生成进度(驱动圆形进度条) */ @GetMapping("/progress") - @PreAuthorize("hasRole('ADMIN')") + @RequireGlobalAdmin public ResponseEntity> progress(@RequestParam String taskId) { ExportTask task = exportService.getProgress(taskId); if (task == null) { @@ -81,7 +81,7 @@ public class OperationalDataController { * 下载已生成的文件(一次有效,需 downloadToken) */ @GetMapping("/download") - @PreAuthorize("hasRole('ADMIN')") + @RequireGlobalAdmin public ResponseEntity download( @RequestParam String taskId, @RequestParam String token) { @@ -97,7 +97,7 @@ public class OperationalDataController { return ResponseEntity.status(HttpStatus.GONE).build(); } - task.setDownloaded(true); + // The one-time token was already atomically claimed in confirmDownload(). InputStreamResource resource = new InputStreamResource(Files.newInputStream(task.getFilePath())); String encodedName = new String(task.getFileName().getBytes("UTF-8"), "ISO-8859-1"); 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 index 51391f0b..86c3eb37 100644 --- a/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java +++ b/mateclaw-server/src/main/java/vip/mate/operational/model/ExportTask.java @@ -2,6 +2,7 @@ package vip.mate.operational.model; import java.nio.file.Path; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; /** * 运营数据导出任务——异步生成 + 一次下载模型。 @@ -14,7 +15,7 @@ public class ExportTask { private volatile Path filePath; private volatile long completedAt; private volatile String downloadToken; - private volatile boolean downloaded; + private final AtomicBoolean downloaded = new AtomicBoolean(false); private volatile String errorMessage; public ExportTask() { @@ -62,8 +63,9 @@ public class ExportTask { 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 boolean isDownloaded() { return downloaded.get(); } + /** Atomically claim the one-time download; returns false if already claimed. */ + public boolean claimDownload() { return downloaded.compareAndSet(false, true); } 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 index 2b7de4c2..7f3234c5 100644 --- a/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java +++ b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java @@ -70,10 +70,11 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; /** - * 运营数据导出服务——异步生成 9 Sheet Excel,一次下载。 + * Operational data export service: asynchronously builds a 9-sheet Excel report + * and serves it through a one-time download. *

- * 设计原则(来自 design/operational-data-export.md): - * SQL/Service → API → 自算。优先复现有 Service,其次 Mapper 直查,内存聚合。 + * Data-sourcing strategy: prefer reusing existing service methods, fall back to + * direct mapper queries, then aggregate in memory. */ @Service public class OperationalDataExportService { @@ -206,8 +207,15 @@ public class OperationalDataExportService { throw new IllegalStateException("正在生成中,请等待"); } ExportTask task = new ExportTask(); - tasks.put(task.getTaskId(), task); - CompletableFuture.runAsync(() -> runExport(task, from, to, true)); + try { + tasks.put(task.getTaskId(), task); + CompletableFuture.runAsync(() -> runExport(task, from, to, true)); + } catch (RuntimeException e) { + // Async submission failed, so runExport's finally will never release + // the lock — release it here to avoid wedging the flag permanently. + generating.set(false); + throw e; + } return task; } @@ -232,8 +240,10 @@ public class OperationalDataExportService { 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; + // Atomically claim the one-time download so concurrent requests cannot + // both succeed; a second caller gets null (treated as 410 Gone). + if (!task.claimDownload()) return null; return task; } @@ -579,7 +589,7 @@ public class OperationalDataExportService { 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); + ? u.getLastLoadedAt().toEpochSecond(ZoneOffset.UTC) : 0); } // agent bindings @@ -614,7 +624,7 @@ public class OperationalDataExportService { 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, 8, LocalDateTime.ofEpochSecond(usage[1], 0, ZoneOffset.UTC), dateStyle); createCell(row, 9, usage[0], numStyle); } else { createCell(row, 8, "", null); @@ -658,7 +668,7 @@ public class OperationalDataExportService { 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); + userDuration.merge(key, (long) 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); @@ -798,7 +808,7 @@ public class OperationalDataExportService { (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); + createCell(row, 12, Duration.between(m.getCreateTime(), next.getCreateTime()).toMillis() / 1000.0, numStyle); } } for (int i = 0; i < cols.length; i++) sheet.autoSizeColumn(i); @@ -1129,7 +1139,7 @@ public class OperationalDataExportService { 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); + createCell(row, 4, Duration.between(run.getStartedAt(), run.getFinishedAt()).toMillis() / 1000.0, null); } else { createCell(row, 4, "", null); } @@ -1268,7 +1278,14 @@ public class OperationalDataExportService { } else if (value instanceof String s) { cell.setCellValue(s); } else if (value instanceof Long l) { - cell.setCellValue((double) l); + // Snowflake IDs exceed the 2^53-1 exact-integer range of Excel's + // numeric (double) cells and would be rounded or shown in scientific + // notation; write out-of-range longs as text to preserve precision. + if (l > 9007199254740991L || l < -9007199254740991L) { + cell.setCellValue(String.valueOf(l)); + } else { + cell.setCellValue((double) l); + } } else if (value instanceof Integer i) { cell.setCellValue((double) i); } else if (value instanceof Double d) { diff --git a/mateclaw-ui/src/views/Dashboard.vue b/mateclaw-ui/src/views/Dashboard.vue index b2864093..0cb5c7f2 100644 --- a/mateclaw-ui/src/views/Dashboard.vue +++ b/mateclaw-ui/src/views/Dashboard.vue @@ -232,7 +232,6 @@ v-if="exportStatus === 'idle' || exportStatus === 'failed'" type="primary" :disabled="!exportDateRange" - :loading="exportStatus === 'generating'" @click="doGenerate" > {{ exportStatus === 'failed' ? t('dashboard.regenerating') : t('dashboard.generateReport') }}