mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +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.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");
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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') }}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user