mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(cli): project-level CLI framework with operational data export command
This commit is contained in:
parent
9013f5d780
commit
1d1c35aadf
@ -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);
|
||||
}
|
||||
}
|
||||
178
mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java
Normal file
178
mateclaw-server/src/main/java/vip/mate/cli/MateClawCli.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user