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.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<Map<String, Object>> generate(
@RequestParam LocalDate startDate,
@RequestParam LocalDate endDate) {
@ -54,7 +54,7 @@ public class OperationalDataController {
* 查询生成进度驱动圆形进度条
*/
@GetMapping("/progress")
@PreAuthorize("hasRole('ADMIN')")
@RequireGlobalAdmin
public ResponseEntity<Map<String, Object>> 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<Resource> 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");

View File

@ -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; }

View File

@ -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.
* <p>
* 设计原则来自 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) {

View File

@ -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') }}