|
|
@@ -0,0 +1,177 @@
|
|
|
+package com.guanlan.backend.web;
|
|
|
+
|
|
|
+import io.swagger.annotations.Api;
|
|
|
+import io.swagger.annotations.ApiOperation;
|
|
|
+import org.springframework.beans.factory.annotation.Value;
|
|
|
+import org.springframework.http.HttpHeaders;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.web.bind.annotation.*;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.*;
|
|
|
+import java.time.Instant;
|
|
|
+import java.time.ZoneId;
|
|
|
+import java.time.format.DateTimeFormatter;
|
|
|
+import java.util.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 交付文档与数据重算(用户令 2026-10-03:门户的「交付文档」「数据重算」迁到工作台页签)。
|
|
|
+ *
|
|
|
+ * <p>`GET /api/delivery_docs` 列产品目录 `docs/` 下的交付文档;`GET /api/delivery_doc?name=` 原样回字节(docx)。
|
|
|
+ * <p>`GET /api/recalc` 看重算是否在跑 + 日志尾部;`POST /api/recalc_start` 后台起重算链。
|
|
|
+ * <p>重算链命令取 `guanlan.recalc-cmd`(默认 `python scripts/rebuild_all.py`),工作目录 = 产品目录。
|
|
|
+ */
|
|
|
+@Api(tags = "system")
|
|
|
+@RestController
|
|
|
+public class DeliveryRecalcController {
|
|
|
+
|
|
|
+ private static final DateTimeFormatter FMT =
|
|
|
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault());
|
|
|
+
|
|
|
+ private final Path productDir;
|
|
|
+ private final String recalcCmd;
|
|
|
+ private volatile Process running;
|
|
|
+ private volatile String startedAt = "";
|
|
|
+
|
|
|
+ public DeliveryRecalcController(@Value("${guanlan.product-dir:.}") String productDir,
|
|
|
+ @Value("${guanlan.recalc-cmd:}") String recalcCmd) {
|
|
|
+ this.productDir = Paths.get(productDir).toAbsolutePath().normalize();
|
|
|
+ this.recalcCmd = recalcCmd;
|
|
|
+ }
|
|
|
+
|
|
|
+ @ApiOperation("交付文档清单")
|
|
|
+ @GetMapping("/api/delivery_docs")
|
|
|
+ public Map<String, Object> docs() {
|
|
|
+ List<Map<String, Object>> out = new ArrayList<Map<String, Object>>();
|
|
|
+ Path dir = productDir.resolve("docs");
|
|
|
+ Map<String, Object> res = new LinkedHashMap<String, Object>();
|
|
|
+ if (!Files.isDirectory(dir)) {
|
|
|
+ res.put("docs", out);
|
|
|
+ res.put("error", "交付文档目录不存在: " + dir);
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ List<Path> files = new ArrayList<Path>();
|
|
|
+ DirectoryStream<Path> ds = Files.newDirectoryStream(dir);
|
|
|
+ try {
|
|
|
+ for (Path p : ds) {
|
|
|
+ String n = p.getFileName().toString();
|
|
|
+ if (Files.isRegularFile(p) && (n.endsWith(".docx") || n.endsWith(".pdf") || n.endsWith(".md"))) {
|
|
|
+ files.add(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ ds.close();
|
|
|
+ }
|
|
|
+ Collections.sort(files);
|
|
|
+ for (Path p : files) {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<String, Object>();
|
|
|
+ m.put("name", p.getFileName().toString());
|
|
|
+ m.put("size", Files.size(p));
|
|
|
+ m.put("mtime", FMT.format(Files.getLastModifiedTime(p).toInstant()));
|
|
|
+ m.put("url", "/api/delivery_doc?name=" + p.getFileName().toString());
|
|
|
+ out.add(m);
|
|
|
+ }
|
|
|
+ } catch (IOException e) {
|
|
|
+ res.put("error", "读取交付文档失败: " + e.getMessage());
|
|
|
+ }
|
|
|
+ res.put("docs", out);
|
|
|
+ return res;
|
|
|
+ }
|
|
|
+
|
|
|
+ @ApiOperation("下载交付文档(字节)")
|
|
|
+ @GetMapping("/api/delivery_doc")
|
|
|
+ public ResponseEntity<byte[]> doc(@RequestParam("name") String name) throws IOException {
|
|
|
+ Path f = productDir.resolve("docs").resolve(name).normalize();
|
|
|
+ if (!f.startsWith(productDir.resolve("docs")) || !Files.isRegularFile(f)) {
|
|
|
+ return ResponseEntity.notFound().build();
|
|
|
+ }
|
|
|
+ byte[] b = Files.readAllBytes(f);
|
|
|
+ String lower = name.toLowerCase(Locale.ROOT);
|
|
|
+ String mime = lower.endsWith(".pdf") ? "application/pdf"
|
|
|
+ : lower.endsWith(".docx") ? "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
|
+ : "application/octet-stream";
|
|
|
+ return ResponseEntity.ok()
|
|
|
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + name)
|
|
|
+ .contentType(MediaType.parseMediaType(mime)).body(b);
|
|
|
+ }
|
|
|
+
|
|
|
+ @ApiOperation("重算状态与日志尾部")
|
|
|
+ @GetMapping("/api/recalc")
|
|
|
+ public Map<String, Object> recalc() {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<String, Object>();
|
|
|
+ boolean run = running != null && running.isAlive();
|
|
|
+ m.put("running", run);
|
|
|
+ m.put("started_at", startedAt);
|
|
|
+ m.put("pid", run ? pidOf(running) : null);
|
|
|
+ m.put("log", tailOfLog());
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ @ApiOperation("触发数据重算")
|
|
|
+ @PostMapping("/api/recalc_start")
|
|
|
+ public Map<String, Object> start() {
|
|
|
+ Map<String, Object> m = new LinkedHashMap<String, Object>();
|
|
|
+ if (running != null && running.isAlive()) {
|
|
|
+ m.put("started", false);
|
|
|
+ m.put("error", "重算已在运行");
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+ String cmd = (recalcCmd == null || recalcCmd.isEmpty())
|
|
|
+ ? "python scripts/rebuild_all.py" : recalcCmd;
|
|
|
+ try {
|
|
|
+ ProcessBuilder pb = new ProcessBuilder(splitCmd(cmd));
|
|
|
+ pb.directory(productDir.toFile());
|
|
|
+ pb.redirectErrorStream(true);
|
|
|
+ pb.redirectOutput(ProcessBuilder.Redirect.appendTo(
|
|
|
+ productDir.resolve("logs").resolve("recalc.log").toFile()));
|
|
|
+ running = pb.start();
|
|
|
+ startedAt = FMT.format(Instant.now());
|
|
|
+ m.put("started", true);
|
|
|
+ m.put("started_at", startedAt);
|
|
|
+ } catch (Exception e) {
|
|
|
+ m.put("started", false);
|
|
|
+ m.put("error", String.valueOf(e.getMessage()));
|
|
|
+ }
|
|
|
+ return m;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<String> splitCmd(String cmd) {
|
|
|
+ List<String> parts = new ArrayList<String>();
|
|
|
+ for (String s : cmd.split("\\s+")) {
|
|
|
+ if (!s.isEmpty()) {
|
|
|
+ parts.add(s);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return parts;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String pidOf(Process p) {
|
|
|
+ try {
|
|
|
+ java.lang.reflect.Method m = p.getClass().getMethod("pid");
|
|
|
+ return String.valueOf(m.invoke(p));
|
|
|
+ } catch (Exception e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private String tailOfLog() {
|
|
|
+ Path log = productDir.resolve("logs").resolve("recalc.log");
|
|
|
+ if (!Files.isRegularFile(log)) {
|
|
|
+ return "";
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ List<String> lines = Files.readAllLines(log, StandardCharsets.UTF_8);
|
|
|
+ int from = Math.max(0, lines.size() - 40);
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (int i = from; i < lines.size(); i++) {
|
|
|
+ sb.append(lines.get(i)).append('\n');
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ } catch (IOException e) {
|
|
|
+ return "(读取失败: " + e.getMessage() + ")";
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|