feat(cli): project-level CLI framework with operational data export command

This commit is contained in:
MIST 2026-06-25 09:22:32 +08:00 committed by matevip
parent 9013f5d780
commit 1d1c35aadf
3 changed files with 287 additions and 0 deletions

View File

@ -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.
*
* <p>Writes the ZIP bytes to stdout so the caller can redirect:</p>
* <pre>{@code
* java -jar app.jar --cli.command=export \
* --cli.start=2026-01-01 --cli.end=2026-06-30 > report.zip
* }</pre>
*/
@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);
}
}

View File

@ -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:
* <ul>
* <li>{@link CliCommand} interface for pluggable commands</li>
* <li>{@link CliContext} argument parsing, output formatting, lifecycle</li>
* <li>{@link CliRunner} auto-discovery dispatcher</li>
* </ul>
*
* <h3>Usage</h3>
* <pre>{@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
* }</pre>
*
* <h3>Adding a new command</h3>
* <pre>{@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);
* }
* }
* }</pre>
*/
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<String, CliCommand> registry;
public CliRunner(List<CliCommand> commands, ApplicationContext springCtx) {
var tmp = new TreeMap<String, CliCommand>();
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=<name> [options]");
System.out.println(" Docker: docker exec <container> java -jar /app/app.jar --cli.command=<name> [options]");
System.out.println("\n Global options:");
System.out.println(" --cli.command=<name> 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;
}
}
}

View File

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