mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(operational): admin gate, atomic one-time download, lock safety and Excel ID precision
This commit is contained in:
parent
c2620720d2
commit
9310335cc8
@ -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");
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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') }}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user