chore: initial import of lingniu-vehicle-ingest
Multi-module Spring Boot ingest service for vehicle telemetry. Modules: - ingest-api / ingest-core / ingest-codec-common: shared SPI, dispatcher, Disruptor event bus, BCC/BCD codec helpers - protocol-gb32960: GB/T 32960.3 inbound (Netty + per-version parser packages v2016/v2025), platform login auth, VIN whitelist, idle handler - protocol-jt808 / protocol-jt1078 / protocol-jsatl12: JT/T inbound - inbound-mqtt / inbound-xinda-push: alternative ingest channels - session-core: per-channel session state - sink-archive / sink-mq: persistence sinks (local file / Kafka) - command-gateway: terminal control command gateway - bootstrap-all: aggregator Spring Boot app - observability: Micrometer / Actuator wiring Includes hex-dump golden samples under protocol-gb32960/src/test/resources and the GB/T 32960.3-2016 / 2025 reference PDFs under reference/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.lingniu.ingest.gateway;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.web.servlet.DispatcherServlet;
|
||||
|
||||
@AutoConfiguration
|
||||
@ConditionalOnClass(DispatcherServlet.class)
|
||||
@ConditionalOnProperty(prefix = "lingniu.ingest.command-gateway", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||
@ComponentScan(basePackageClasses = TerminalCommandController.class)
|
||||
public class CommandGatewayAutoConfiguration {
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.lingniu.ingest.gateway;
|
||||
|
||||
/**
|
||||
* 统一命令响应 envelope。
|
||||
*
|
||||
* @param success 是否成功
|
||||
* @param code 业务状态码(0=成功,其它=失败原因)
|
||||
* @param message 人类可读消息
|
||||
* @param data 可选负载(设备应答对象、JSON 节点等)
|
||||
*/
|
||||
public record CommandResult(boolean success, int code, String message, Object data) {
|
||||
|
||||
public static CommandResult ok(Object data) {
|
||||
return new CommandResult(true, 0, "ok", data);
|
||||
}
|
||||
|
||||
public static CommandResult notImplemented(String hint) {
|
||||
return new CommandResult(false, 501, "not_implemented: " + hint, null);
|
||||
}
|
||||
|
||||
public static CommandResult failure(int code, String message) {
|
||||
return new CommandResult(false, code, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.lingniu.ingest.gateway;
|
||||
|
||||
import com.lingniu.ingest.protocol.jt808.downlink.Jt808Commands;
|
||||
import com.lingniu.ingest.protocol.jt808.model.Jt808Message;
|
||||
import com.lingniu.ingest.session.CommandDispatcher;
|
||||
import com.lingniu.ingest.session.DeviceSession;
|
||||
import com.lingniu.ingest.session.SessionStore;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 下行命令 REST 入口。命令通过 {@link CommandDispatcher} 发往协议模块,
|
||||
* 由 protocol-jt808 的 {@code Jt808CommandDispatcher} 实际发送到终端。
|
||||
*
|
||||
* <p>路径参数 {@code clientId} 可以是 sessionId / phone / vin,
|
||||
* 由 {@link SessionStore} 三索引解析。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/terminal")
|
||||
public class TerminalCommandController {
|
||||
|
||||
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(20);
|
||||
|
||||
private final SessionStore sessions;
|
||||
private final CommandDispatcher dispatcher;
|
||||
|
||||
public TerminalCommandController(SessionStore sessions, CommandDispatcher dispatcher) {
|
||||
this.sessions = sessions;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
@GetMapping("/session")
|
||||
public CommandResult session(@RequestParam String clientId) {
|
||||
Optional<DeviceSession> s = resolveSession(clientId);
|
||||
return s.<CommandResult>map(CommandResult::ok)
|
||||
.orElseGet(() -> CommandResult.failure(404, "session not found: " + clientId));
|
||||
}
|
||||
|
||||
@GetMapping("/location")
|
||||
public CommandResult queryLocation(@RequestParam String clientId) {
|
||||
return awaitSync(clientId, Jt808Commands.queryLocation(), "query-location");
|
||||
}
|
||||
|
||||
@GetMapping("/parameters")
|
||||
public CommandResult queryParameters(@RequestParam String clientId) {
|
||||
return awaitSync(clientId, Jt808Commands.queryParameters(), "query-parameters");
|
||||
}
|
||||
|
||||
@PutMapping("/parameters")
|
||||
public CommandResult setParameters(@RequestParam String clientId,
|
||||
@RequestBody Map<String, String> body) {
|
||||
List<Jt808Commands.ParamItem> items = body.entrySet().stream()
|
||||
.map(e -> new Jt808Commands.ParamItem(
|
||||
parseId(e.getKey()),
|
||||
e.getValue() == null ? new byte[0] : e.getValue().getBytes(StandardCharsets.UTF_8)))
|
||||
.collect(Collectors.toList());
|
||||
return awaitSync(clientId, Jt808Commands.setParameters(items), "set-parameters");
|
||||
}
|
||||
|
||||
@PostMapping("/control")
|
||||
public CommandResult terminalControl(@RequestParam String clientId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
int word = Integer.parseInt(body.getOrDefault("command", "0").toString());
|
||||
String params = body.getOrDefault("params", "").toString();
|
||||
return awaitSync(clientId, Jt808Commands.terminalControl(word, params), "terminal-control");
|
||||
}
|
||||
|
||||
@PostMapping("/ack")
|
||||
public CommandResult ack(@RequestParam String clientId, @RequestBody Map<String, Integer> body) {
|
||||
int ackSerial = body.getOrDefault("ackSerial", 0);
|
||||
int ackMsgId = body.getOrDefault("ackMsgId", 0);
|
||||
int result = body.getOrDefault("result", 0);
|
||||
return fireAndForget(clientId, Jt808Commands.platformAck(ackSerial, ackMsgId, result), "platform-ack");
|
||||
}
|
||||
|
||||
@GetMapping("/attributes")
|
||||
public CommandResult attributes(@RequestParam String clientId) {
|
||||
return CommandResult.notImplemented("0x8107 query-attributes");
|
||||
}
|
||||
|
||||
@DeleteMapping("/area")
|
||||
public CommandResult deleteArea(@RequestParam String clientId, @RequestParam String ids) {
|
||||
return CommandResult.notImplemented("0x8601 delete-area");
|
||||
}
|
||||
|
||||
@PostMapping("/alarm_ack")
|
||||
public CommandResult alarmAck(@RequestParam String clientId, @RequestBody Map<String, Object> body) {
|
||||
return CommandResult.notImplemented("0x8203 alarm-ack");
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
|
||||
private Optional<DeviceSession> resolveSession(String clientId) {
|
||||
return sessions.findBySessionId(clientId)
|
||||
.or(() -> sessions.findByPhone(clientId))
|
||||
.or(() -> sessions.findByVin(clientId));
|
||||
}
|
||||
|
||||
private CommandResult awaitSync(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
|
||||
CompletableFuture<Jt808Message> future =
|
||||
dispatcher.request(clientId, cmd, Jt808Message.class, DEFAULT_TIMEOUT);
|
||||
try {
|
||||
Jt808Message resp = future.join();
|
||||
return CommandResult.ok(Map.of(
|
||||
"messageId", "0x" + Integer.toHexString(resp.header().messageId()),
|
||||
"serial", resp.header().serialNo(),
|
||||
"phone", resp.header().phone()));
|
||||
} catch (CompletionException e) {
|
||||
Throwable cause = e.getCause() != null ? e.getCause() : e;
|
||||
return CommandResult.failure(500, op + " failed: " + cause.getMessage());
|
||||
} catch (Exception e) {
|
||||
return CommandResult.failure(500, op + " failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private CommandResult fireAndForget(String clientId, Jt808Commands.DownlinkCommand cmd, String op) {
|
||||
try {
|
||||
dispatcher.notify(clientId, cmd).join();
|
||||
return CommandResult.ok(Map.of("op", op));
|
||||
} catch (Exception e) {
|
||||
return CommandResult.failure(500, op + " failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static long parseId(String key) {
|
||||
if (key == null || key.isBlank()) return 0;
|
||||
String k = key.startsWith("0x") ? key.substring(2) : key;
|
||||
return Long.parseLong(k, 16);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user