fix(operational): admin gate, atomic one-time download, lock safety and Excel ID precision

This commit is contained in:
matevip 2026-06-24 17:48:42 +08:00
parent c2620720d2
commit 9310335cc8
4 changed files with 39 additions and 21 deletions

View File

@ -5,10 +5,10 @@ import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import vip.mate.operational.model.ExportTask; import vip.mate.operational.model.ExportTask;
import vip.mate.operational.service.OperationalDataExportService; import vip.mate.operational.service.OperationalDataExportService;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
@ -32,7 +32,7 @@ public class OperationalDataController {
} }
@PostMapping("/generate") @PostMapping("/generate")
@PreAuthorize("hasRole('ADMIN')") @RequireGlobalAdmin
public ResponseEntity<Map<String, Object>> generate( public ResponseEntity<Map<String, Object>> generate(
@RequestParam LocalDate startDate, @RequestParam LocalDate startDate,
@RequestParam LocalDate endDate) { @RequestParam LocalDate endDate) {
@ -54,7 +54,7 @@ public class OperationalDataController {
* 查询生成进度驱动圆形进度条 * 查询生成进度驱动圆形进度条
*/ */
@GetMapping("/progress") @GetMapping("/progress")
@PreAuthorize("hasRole('ADMIN')") @RequireGlobalAdmin
public ResponseEntity<Map<String, Object>> progress(@RequestParam String taskId) { public ResponseEntity<Map<String, Object>> progress(@RequestParam String taskId) {
ExportTask task = exportService.getProgress(taskId); ExportTask task = exportService.getProgress(taskId);
if (task == null) { if (task == null) {
@ -81,7 +81,7 @@ public class OperationalDataController {
* 下载已生成的文件一次有效 downloadToken * 下载已生成的文件一次有效 downloadToken
*/ */
@GetMapping("/download") @GetMapping("/download")
@PreAuthorize("hasRole('ADMIN')") @RequireGlobalAdmin
public ResponseEntity<Resource> download( public ResponseEntity<Resource> download(
@RequestParam String taskId, @RequestParam String taskId,
@RequestParam String token) { @RequestParam String token) {
@ -97,7 +97,7 @@ public class OperationalDataController {
return ResponseEntity.status(HttpStatus.GONE).build(); 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())); InputStreamResource resource = new InputStreamResource(Files.newInputStream(task.getFilePath()));
String encodedName = new String(task.getFileName().getBytes("UTF-8"), "ISO-8859-1"); String encodedName = new String(task.getFileName().getBytes("UTF-8"), "ISO-8859-1");

View File

@ -2,6 +2,7 @@ package vip.mate.operational.model;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
/** /**
* 运营数据导出任务异步生成 + 一次下载模型 * 运营数据导出任务异步生成 + 一次下载模型
@ -14,7 +15,7 @@ public class ExportTask {
private volatile Path filePath; private volatile Path filePath;
private volatile long completedAt; private volatile long completedAt;
private volatile String downloadToken; private volatile String downloadToken;
private volatile boolean downloaded; private final AtomicBoolean downloaded = new AtomicBoolean(false);
private volatile String errorMessage; private volatile String errorMessage;
public ExportTask() { public ExportTask() {
@ -62,8 +63,9 @@ public class ExportTask {
public String getDownloadToken() { return downloadToken; } public String getDownloadToken() { return downloadToken; }
public void setDownloadToken(String downloadToken) { this.downloadToken = downloadToken; } public void setDownloadToken(String downloadToken) { this.downloadToken = downloadToken; }
public boolean isDownloaded() { return downloaded; } public boolean isDownloaded() { return downloaded.get(); }
public void setDownloaded(boolean downloaded) { this.downloaded = downloaded; } /** 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 String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }

View File

@ -70,10 +70,11 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream; 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.
* <p> * <p>
* 设计原则来自 design/operational-data-export.md * Data-sourcing strategy: prefer reusing existing service methods, fall back to
* SQL/Service API 自算优先复现有 Service其次 Mapper 直查内存聚合 * direct mapper queries, then aggregate in memory.
*/ */
@Service @Service
public class OperationalDataExportService { public class OperationalDataExportService {
@ -206,8 +207,15 @@ public class OperationalDataExportService {
throw new IllegalStateException("正在生成中,请等待"); throw new IllegalStateException("正在生成中,请等待");
} }
ExportTask task = new ExportTask(); ExportTask task = new ExportTask();
tasks.put(task.getTaskId(), task); try {
CompletableFuture.runAsync(() -> runExport(task, from, to, true)); 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; return task;
} }
@ -232,8 +240,10 @@ public class OperationalDataExportService {
ExportTask task = tasks.get(taskId); ExportTask task = tasks.get(taskId);
if (task == null) return null; if (task == null) return null;
if (!token.equals(task.getDownloadToken())) return null; if (!token.equals(task.getDownloadToken())) return null;
if (task.isDownloaded()) return null;
if (!"completed".equals(task.getStatus())) 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; return task;
} }
@ -579,7 +589,7 @@ public class OperationalDataExportService {
long[] acc = usageByName.computeIfAbsent(u.getSkillName(), k -> new long[]{0, 0}); long[] acc = usageByName.computeIfAbsent(u.getSkillName(), k -> new long[]{0, 0});
acc[0] += u.getLoadCount() != null ? u.getLoadCount() : 0; acc[0] += u.getLoadCount() != null ? u.getLoadCount() : 0;
acc[1] = Math.max(acc[1], u.getLastLoadedAt() != null 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 // agent bindings
@ -614,7 +624,7 @@ public class OperationalDataExportService {
createCell(row, 7, s.getDescription(), null); createCell(row, 7, s.getDescription(), null);
long[] usage = usageByName.get(s.getName()); long[] usage = usageByName.get(s.getName());
if (usage != null && usage[1] > 0) { 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); createCell(row, 9, usage[0], numStyle);
} else { } else {
createCell(row, 8, "", null); createCell(row, 8, "", null);
@ -658,7 +668,7 @@ public class OperationalDataExportService {
String key = (c.getWorkspaceId() != null ? c.getWorkspaceId() : 0) + "|" + (c.getUsername() != null ? c.getUsername() : "-"); String key = (c.getWorkspaceId() != null ? c.getWorkspaceId() : 0) + "|" + (c.getUsername() != null ? c.getUsername() : "-");
userConvIds.computeIfAbsent(key, k -> new HashSet<>()).add(c.getConversationId()); userConvIds.computeIfAbsent(key, k -> new HashSet<>()).add(c.getConversationId());
if (c.getCreateTime() != null && c.getLastActiveTime() != null) { 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) { if (c.getLastActiveTime() != null) {
userLastActive.merge(key, c.getLastActiveTime(), (a, b) -> a.isAfter(b) ? a : b); 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); (long)(next.getCompletionTokens() != null ? next.getCompletionTokens() : 0), numStyle);
createCell(row, 10, next.getRuntimeModel(), null); createCell(row, 10, next.getRuntimeModel(), null);
createCell(row, 11, next.getRuntimeProvider(), 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); 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, 2, run.getTriggerType(), null);
createCell(row, 3, run.getStatus(), null); createCell(row, 3, run.getStatus(), null);
if (run.getStartedAt() != null && run.getFinishedAt() != 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 { } else {
createCell(row, 4, "", null); createCell(row, 4, "", null);
} }
@ -1268,7 +1278,14 @@ public class OperationalDataExportService {
} else if (value instanceof String s) { } else if (value instanceof String s) {
cell.setCellValue(s); cell.setCellValue(s);
} else if (value instanceof Long l) { } 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) { } else if (value instanceof Integer i) {
cell.setCellValue((double) i); cell.setCellValue((double) i);
} else if (value instanceof Double d) { } else if (value instanceof Double d) {

View File

@ -232,7 +232,6 @@
v-if="exportStatus === 'idle' || exportStatus === 'failed'" v-if="exportStatus === 'idle' || exportStatus === 'failed'"
type="primary" type="primary"
:disabled="!exportDateRange" :disabled="!exportDateRange"
:loading="exportStatus === 'generating'"
@click="doGenerate" @click="doGenerate"
> >
{{ exportStatus === 'failed' ? t('dashboard.regenerating') : t('dashboard.generateReport') }} {{ exportStatus === 'failed' ? t('dashboard.regenerating') : t('dashboard.generateReport') }}