From 1d1c35aadf838cffda7e4308b1ab087498e45edd Mon Sep 17 00:00:00 2001 From: MIST <52695829+MISTLXC@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:22:32 +0800 Subject: [PATCH] feat(cli): project-level CLI framework with operational data export command --- .../main/java/vip/mate/cli/ExportCommand.java | 80 ++++++++ .../main/java/vip/mate/cli/MateClawCli.java | 178 ++++++++++++++++++ .../service/OperationalDataExportService.java | 29 +++ 3 files changed, 287 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java create mode 100644 mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java diff --git a/mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java b/mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java new file mode 100644 index 00000000..cb6b9b81 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/cli/ExportCommand.java @@ -0,0 +1,80 @@ +package vip.mate.cli; + +import org.springframework.stereotype.Component; +import vip.mate.operational.service.OperationalDataExportService; + +import java.io.IOException; +import java.time.LocalDate; + +/** + * {@code --cli.command=export} — generate a 9-sheet operational data report. + * + *

Writes the ZIP bytes to stdout so the caller can redirect:

+ *
{@code
+ *   java -jar app.jar --cli.command=export \
+ *     --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
+ * }
+ */ +@Component +public class ExportCommand implements MateClawCli.CliCommand { + + private final OperationalDataExportService exportService; + + public ExportCommand(OperationalDataExportService exportService) { + this.exportService = exportService; + } + + @Override public String name() { return "export"; } + @Override public String description() { return "Generate the operational data report (9-sheet Excel, written to stdout)"; } + + @Override public String usage() { + return """ + \s + export — generate the operational data report; ZIP bytes are written to stdout + \s + Required: + --cli.start=YYYY-MM-DD start date (inclusive) + --cli.end=YYYY-MM-DD end date (inclusive) + \s + Optional: + --cli.dry-run dry-run mode + \s + Example: + java -jar app.jar --cli.command=export \\ + \s --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip + \s"""; + } + + @Override + public void execute(MateClawCli.CliContext ctx) { + LocalDate start = ctx.requireDate("cli.start"); + LocalDate end = ctx.requireDate("cli.end"); + + if (ctx.isDryRun()) { + ctx.header("Export dry-run"); + ctx.info("Date range", start + " ~ " + end); + ctx.info("Result", "ZIP bytes would be written to stdout (not executed)"); + ctx.done("Dry-run complete"); + ctx.exit(0); + return; + } + + byte[] zip = exportService.exportBackendBytes(start, end); + + try { + // Diagnostics go to stderr so stdout stays a clean binary stream for redirection. + System.err.println("=== Operational data export ==="); + System.err.printf(" Date range : %s ~ %s%n", start, end); + System.err.printf(" Size : %d KB%n", zip.length / 1024); + System.err.printf(" File name : ops_data_%s_%s.zip%n", start, end); + System.err.println("\n=== Writing to stdout (redirect: ... > report.zip) ==="); + System.out.write(zip); + System.out.flush(); + System.err.println("=== Export complete ==="); + } catch (IOException e) { + ctx.error("Failed to write to stdout: " + e.getMessage()); + } + + ctx.exit(0); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java b/mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java new file mode 100644 index 00000000..7acdd517 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java @@ -0,0 +1,178 @@ +package vip.mate.cli; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.ExitCodeGenerator; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * MateClaw CLI framework — single-file core containing: + * + * + *

Usage

+ *
{@code
+ *   java -jar app.jar --cli.command=help
+ *   java -jar app.jar --cli.command=export \
+ *     --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
+ * }
+ * + *

Adding a new command

+ *
{@code
+ *   @Component
+ *   public class MyCommand implements MateClawCli.CliCommand {
+ *       public String name()        { return "mycmd"; }
+ *       public String description() { return "does something"; }
+ *       public String usage()       { return "  --cli.x=...\n  example: ..."; }
+ *       public void execute(MateClawCli.CliContext ctx) {
+ *           ctx.exit(0);
+ *       }
+ *   }
+ * }
+ */ +public final class MateClawCli { private MateClawCli() { /* namespace */ } + + // ═══ CliCommand — interface for pluggable commands ═══ + + public interface CliCommand { + String name(); + String description(); + default String usage() { return ""; } + void execute(CliContext ctx); + } + + // ═══ CliContext — argument parsing & output ═══ + + public static class CliContext { + private static final Logger log = LoggerFactory.getLogger(CliContext.class); + private final ApplicationArguments args; + private final ApplicationContext springCtx; + private final boolean dryRun; + + public CliContext(ApplicationArguments args, ApplicationContext springCtx) { + this.args = args; + this.springCtx = springCtx; + this.dryRun = args.getOptionNames().contains("cli.dry-run"); + } + + /** Read optional string param (null if absent). */ + public String arg(String key) { + var vals = args.getOptionValues(key); + return vals != null && !vals.isEmpty() ? vals.get(0) : null; + } + + /** Read required string param. Absent = error + exit. */ + public String requireArg(String key) { + String val = arg(key); + if (val == null || val.isBlank()) error("Missing required parameter: --" + key); + return val; + } + + /** Read required date param (YYYY-MM-DD). Bad format = error + exit. */ + public LocalDate requireDate(String key) { + String raw = requireArg(key); + try { return LocalDate.parse(raw); } + catch (DateTimeParseException e) { error("Invalid date format: --" + key + "=" + raw + " (expected YYYY-MM-DD)"); return null; } + } + + /** True when {@code --cli.dry-run} was passed. */ + public boolean isDryRun() { return dryRun; } + + // —— output —————————————————————————————————————————— + public void header(String title) { System.out.println(); System.out.println("=== " + title + " ==="); } + public void info(String key, Object val) { System.out.printf(" %-12s : %s%n", key, val); } + public void done(String msg) { System.out.println(); System.out.println("=== " + msg + " ==="); System.out.println(); } + public void warn(String msg) { log.warn(msg); System.err.println("[WARN] " + msg); } + public void error(String msg) { log.error(msg); System.err.println("[ERROR] " + msg); exit(1); } + + public void exit(int code) { + System.out.flush(); System.err.flush(); + try { Thread.sleep(200); } catch (InterruptedException ignored) {} + SpringApplication.exit(springCtx, (ExitCodeGenerator) () -> code); + System.exit(code); + } + } + + // ═══ CliRunner — auto-discovery ApplicationRunner ═══ + + @Component + @Order(9999) + public static class CliRunner implements ApplicationRunner { + private static final Logger log = LoggerFactory.getLogger(CliRunner.class); + private final ApplicationContext springCtx; + private final Map registry; + + public CliRunner(List commands, ApplicationContext springCtx) { + var tmp = new TreeMap(); + for (var c : commands) { + if (tmp.containsKey(c.name())) throw new IllegalStateException("Duplicate CLI command name: '" + c.name() + "'"); + tmp.put(c.name(), c); + } + this.registry = Collections.unmodifiableMap(tmp); + this.springCtx = springCtx; + log.info("CLI ready, registered {} command(s): {}", registry.size(), registry.keySet()); + } + + @Override public void run(ApplicationArguments args) { + String cmdName = arg(args, "cli.command"); + // No CLI command requested: normal web startup, stay inert. + if (cmdName == null) return; + + CliContext ctx = new CliContext(args, springCtx); + if ("help".equalsIgnoreCase(cmdName)) { printHelp(); ctx.exit(0); return; } + + CliCommand cmd = registry.get(cmdName.toLowerCase()); + if (cmd == null) { + System.err.println("[ERROR] Unknown command: " + cmdName); + System.err.println(" Available commands: " + String.join(", ", registry.keySet())); + ctx.exit(1); return; + } + try { + log.info("CLI executing: {}", cmd.name()); + cmd.execute(ctx); + } catch (Exception e) { + log.error("Command '{}' failed", cmd.name(), e); + System.err.println("\n[ERROR] Command '" + cmd.name() + "' threw an exception"); + System.err.println(" " + e.getClass().getSimpleName() + ": " + e.getMessage()); + var trace = e.getStackTrace(); + for (int i = 0; i < Math.min(8, trace.length); i++) System.err.println(" at " + trace[i]); + ctx.exit(1); + } + } + + private void printHelp() { + System.out.println("\n MateClaw CLI\n ═══════════════════════════════════════"); + System.out.println(" java -jar app.jar --cli.command= [options]"); + System.out.println(" Docker: docker exec java -jar /app/app.jar --cli.command= [options]"); + System.out.println("\n Global options:"); + System.out.println(" --cli.command= command to run"); + System.out.println(" --cli.dry-run dry-run mode"); + System.out.println("\n Available commands:"); + System.out.printf(" %-12s %s%n", "help", "show this help"); + for (var c : registry.values()) System.out.printf(" %-12s %s%n", c.name(), c.description()); + System.out.println("\n Detailed usage:"); + for (var c : registry.values()) { String u = c.usage(); if (!u.isBlank()) System.out.println(u); } + System.out.println(" Spring Boot options may be appended directly (--spring.profiles.active, etc.)\n"); + } + + static String arg(ApplicationArguments args, String key) { + var vals = args.getOptionValues(key); + return vals != null && !vals.isEmpty() ? vals.get(0) : null; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java index 8957e5c8..2a56ae54 100644 --- a/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java +++ b/mateclaw-server/src/main/java/vip/mate/operational/service/OperationalDataExportService.java @@ -230,6 +230,35 @@ public class OperationalDataExportService { return task; } + /** + * Backend-only export entry point for the CLI: no 90-day cap and no timeout, + * returns the ZIP bytes directly instead of writing to disk. Reachable only + * from the operator CLI, never over HTTP. + */ + public byte[] exportBackendBytes(LocalDate start, LocalDate end) { + if (!generating.compareAndSet(false, true)) { + throw new IllegalStateException("前端或后台已有生成任务在运行"); + } + try { + long t0 = System.currentTimeMillis(); + byte[] zip = doExport(start, end, step -> {}, false); + log.info("CLI export completed: {} KB, {}ms", zip.length / 1024, + System.currentTimeMillis() - t0); + try { + auditEventService.recordSync("EXPORT", "OPERATIONAL_DATA", + start + "~" + end, "运营数据导出 9 sheets (CLI)", null); + } catch (Exception e) { + log.warn("Failed to write audit for CLI export: {}", e.getMessage()); + } + return zip; + } catch (Exception e) { + log.error("CLI export failed", e); + throw new RuntimeException("导出失败: " + e.getMessage(), e); + } finally { + generating.set(false); + } + } + /** 查询任务进度 */ public ExportTask getProgress(String taskId) { return tasks.get(taskId);