Initial commit

This commit is contained in:
2026-04-23 14:23:42 +08:00
commit 9ceba6f9e3
141 changed files with 2886 additions and 0 deletions

66
.gitignore vendored Normal file
View File

@@ -0,0 +1,66 @@
target/
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store
/.idea/
# ---> Java
# Compiled class file
*.class
# Log file
*.log
# BlueJ files
*.ctxt
# Mobile Tools for Java (J2ME)
.mtj.tmp/
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*

18
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="ota-server" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="ota-server" options="-parameters" />
</option>
</component>
</project>

6
.idea/encodings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
</component>
</project>

View File

@@ -0,0 +1,5 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
</profile>
</component>

20
.idea/jarRepositories.xml generated Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

12
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

42
README.md Normal file
View File

@@ -0,0 +1,42 @@
# 云端 OTA Server
这是一个基于 `Spring Boot 3.2.1 + Maven + JDK 17 + MySQL + MyBatis-Plus` 的最小可用云端 OTA 管理服务面向“云端发布、车端检测、安卓提示、人工确认、Docker 执行”的半自动升级模式。
## 当前能力
- 发布 OTA 版本清单
- 注册车辆
- 为指定车辆分配可见版本
- 提供 Agent 心跳、检查更新、结果上报接口
- 维护任务状态:`AVAILABLE``WAITING_CONFIRM``UPGRADING``SUCCESS``FAILED``ROLLED_BACK``SKIPPED`
- 数据持久化到 MySQL
## 启动前准备
1. 安装 JDK 17、Maven、MySQL 8.x
2. 创建数据库并执行初始化脚本:`src/main/resources/schema.sql`
3. 按实际环境修改 `src/main/resources/application.yml` 中的数据库连接
## 启动方式
1. 在项目根目录运行:
`mvn spring-boot:run`
2. 默认端口:`8080`
## 鉴权
Agent 侧接口要求请求头:
- `X-OTA-TOKEN: dev-token`
可在 `src/main/resources/application.yml` 中修改。
## 管理端接口
- `POST /api/admin/releases`
- `GET /api/admin/releases`
- `POST /api/admin/vehicles`
- `GET /api/admin/vehicles`
- `POST /api/admin/assignments`
- `GET /api/admin/assignments`
## Agent 接口
- `POST /api/agent/heartbeat`
- `POST /api/agent/update-check`
- `POST /api/agent/report`
- `POST /api/agent/confirm`
- `POST /api/agent/postpone`

80
pom.xml Normal file
View File

@@ -0,0 +1,80 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.1</version>
<relativePath/>
</parent>
<groupId>com.noblelift</groupId>
<artifactId>ota-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>ota-server</name>
<description>Cloud OTA management platform for vehicle docker upgrades</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<sa-token.version>1.39.0</sa-token.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,12 @@
package com.noblelift.ota;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OtaServerApplication {
public static void main(String[] args) {
SpringApplication.run(OtaServerApplication.class, args);
}
}

View File

@@ -0,0 +1,45 @@
package com.noblelift.ota.api;
import com.noblelift.ota.common.dto.ApiMessage;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiMessage handleIllegalArgument(IllegalArgumentException ex) {
return new ApiMessage("BAD_REQUEST", ex.getMessage(), Instant.now());
}
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiMessage handleDataIntegrityViolation(DataIntegrityViolationException ex) {
String message = ex.getMostSpecificCause() == null ? ex.getMessage() : ex.getMostSpecificCause().getMessage();
if (message != null && message.contains("fk_assignment_release_version")) {
message = "该版本已被任务引用,无法删除或修改版本号";
} else if (message != null && message.contains("fk_assignment_vehicle_id")) {
message = "该车辆存在任务记录,无法删除或修改车辆 ID";
} else {
message = "数据被关联引用,当前操作无法完成";
}
return new ApiMessage("BAD_REQUEST", message, Instant.now());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiMessage handleValidation(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getField() + " " + error.getDefaultMessage())
.orElse("Validation failed");
return new ApiMessage("VALIDATION_ERROR", message, Instant.now());
}
}

View File

@@ -0,0 +1,13 @@
package com.noblelift.ota.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
String value() default "";
}

View File

@@ -0,0 +1,61 @@
package com.noblelift.ota.common.aspect;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.noblelift.ota.common.annotation.Log;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.Arrays;
@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class LogAspect {
private final ObjectMapper objectMapper;
@Around("@annotation(logAnnotation)")
public Object around(ProceedingJoinPoint joinPoint, Log logAnnotation) throws Throwable {
long start = System.currentTimeMillis();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes instanceof ServletRequestAttributes servletRequestAttributes
? servletRequestAttributes.getRequest()
: null;
String requestUri = request == null ? "N/A" : request.getRequestURI();
String requestMethod = request == null ? "N/A" : request.getMethod();
String params = serializeArgs(joinPoint.getArgs());
String operation = logAnnotation.value().isBlank() ? signature.getMethod().getName() : logAnnotation.value();
log.info("[API-REQUEST] operation={}, method={}, uri={}, params={}", operation, requestMethod, requestUri, params);
try {
Object result = joinPoint.proceed();
log.info("[API-RESPONSE] operation={}, duration={}ms", operation, System.currentTimeMillis() - start);
return result;
} catch (Throwable ex) {
log.error("[API-ERROR] operation={}, duration={}ms, message={}", operation, System.currentTimeMillis() - start, ex.getMessage(), ex);
throw ex;
}
}
private String serializeArgs(Object[] args) {
Object[] filtered = Arrays.stream(args)
.filter(arg -> !(arg instanceof HttpServletRequest))
.toArray();
try {
return objectMapper.writeValueAsString(filtered);
} catch (JsonProcessingException e) {
return Arrays.toString(filtered);
}
}
}

View File

@@ -0,0 +1,19 @@
package com.noblelift.ota.common.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ApiMessage {
private String code;
private String message;
private Instant timestamp;
}

View File

@@ -0,0 +1,24 @@
package com.noblelift.ota.common.model;
import com.noblelift.ota.domain.UpgradeMode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReleaseManifest {
private String releaseVersion;
private String releaseNotes;
private UpgradeMode upgradeMode;
private Instant publishedAt;
private Boolean parkingRequired;
private Map<String, Object> components;
}

View File

@@ -0,0 +1,26 @@
package com.noblelift.ota.common.model;
import com.noblelift.ota.domain.TaskStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VehicleAssignment {
private String vehicleId;
private String releaseVersion;
private TaskStatus taskStatus;
private Instant promptedAt;
private Instant confirmedAt;
private Instant startedAt;
private Instant finishedAt;
private Integer postponeCount;
private String lastMessage;
}

View File

@@ -0,0 +1,31 @@
package com.noblelift.ota.common.model;
import com.noblelift.ota.domain.AgentStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VehicleInfo {
private String vehicleId;
private String vin;
private String currentRelease;
private String currentBackendVersion;
private String currentFrontendVersion;
private String currentRosVersion;
private Instant lastSeenAt;
private AgentStatus agentStatus;
private String targetRelease;
private String lastResult;
private Map<String, String> images;
private String backupFile;
private Boolean online;
}

View File

@@ -0,0 +1,42 @@
package com.noblelift.ota.common.util;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class ReleaseVersionComparator {
private static final Pattern TOKEN_PATTERN = Pattern.compile("\\d+");
private ReleaseVersionComparator() {
}
public static int compare(String left, String right) {
List<String> leftTokens = tokenize(left);
List<String> rightTokens = tokenize(right);
int size = Math.max(leftTokens.size(), rightTokens.size());
for (int i = 0; i < size; i++) {
String leftToken = i < leftTokens.size() ? leftTokens.get(i) : "0";
String rightToken = i < rightTokens.size() ? rightTokens.get(i) : "0";
int result = new BigInteger(leftToken).compareTo(new BigInteger(rightToken));
if (result != 0) {
return result;
}
}
return 0;
}
private static List<String> tokenize(String value) {
List<String> tokens = new ArrayList<>();
if (value == null || value.isBlank()) {
return tokens;
}
Matcher matcher = TOKEN_PATTERN.matcher(value);
while (matcher.find()) {
tokens.add(matcher.group());
}
return tokens;
}
}

View File

@@ -0,0 +1,52 @@
package com.noblelift.ota.config;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
@Component
public class AgentTokenAuthenticator {
private static final String TOKEN_HEADER = "X-OTA-TOKEN";
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
private final OtaProperties otaProperties;
public AgentTokenAuthenticator(OtaProperties otaProperties) {
this.otaProperties = otaProperties;
}
public void verify(HttpServletRequest request) {
String expectedToken = trimToNull(otaProperties.getAuthToken());
if (expectedToken == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "OTA agent token is not configured");
}
String providedToken = extractToken(request);
if (!expectedToken.equals(providedToken)) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid OTA agent token");
}
}
private String extractToken(HttpServletRequest request) {
String token = trimToNull(request.getHeader(TOKEN_HEADER));
if (token != null) {
return token;
}
String authorization = trimToNull(request.getHeader(AUTHORIZATION_HEADER));
if (authorization != null && authorization.regionMatches(true, 0, BEARER_PREFIX, 0, BEARER_PREFIX.length())) {
return trimToNull(authorization.substring(BEARER_PREFIX.length()));
}
return null;
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}

View File

@@ -0,0 +1,26 @@
package com.noblelift.ota.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
@EnableConfigurationProperties(OtaProperties.class)
public class OtaConfiguration {
@Bean
public CorsFilter corsFilter(OtaProperties otaProperties) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(otaProperties.getCors().getAllowedOrigins());
configuration.setAllowedMethods(otaProperties.getCors().getAllowedMethods());
configuration.setAllowedHeaders(otaProperties.getCors().getAllowedHeaders());
configuration.setAllowCredentials(otaProperties.getCors().isAllowCredentials());
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return new CorsFilter(source);
}
}

View File

@@ -0,0 +1,77 @@
package com.noblelift.ota.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@ConfigurationProperties(prefix = "ota")
public class OtaProperties {
private String authToken = "dev-token";
private long heartbeatTimeoutSeconds = 120;
private Cors cors = new Cors();
public String getAuthToken() {
return authToken;
}
public void setAuthToken(String authToken) {
this.authToken = authToken;
}
public long getHeartbeatTimeoutSeconds() {
return heartbeatTimeoutSeconds;
}
public void setHeartbeatTimeoutSeconds(long heartbeatTimeoutSeconds) {
this.heartbeatTimeoutSeconds = heartbeatTimeoutSeconds;
}
public Cors getCors() {
return cors;
}
public void setCors(Cors cors) {
this.cors = cors;
}
public static class Cors {
private List<String> allowedOrigins = new ArrayList<>(List.of("http://localhost:5173", "http://127.0.0.1:5173"));
private List<String> allowedMethods = new ArrayList<>(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
private List<String> allowedHeaders = new ArrayList<>(List.of("*"));
private boolean allowCredentials = true;
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public void setAllowedOrigins(List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
public List<String> getAllowedMethods() {
return allowedMethods;
}
public void setAllowedMethods(List<String> allowedMethods) {
this.allowedMethods = allowedMethods;
}
public List<String> getAllowedHeaders() {
return allowedHeaders;
}
public void setAllowedHeaders(List<String> allowedHeaders) {
this.allowedHeaders = allowedHeaders;
}
public boolean isAllowCredentials() {
return allowCredentials;
}
public void setAllowCredentials(boolean allowCredentials) {
this.allowCredentials = allowCredentials;
}
}
}

View File

@@ -0,0 +1,18 @@
package com.noblelift.ota.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class SaTokenConfigure implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handler -> StpUtil.checkLogin()))
.addPathPatterns("/api/releases/**", "/api/vehicles/**", "/api/assignments/**")
.excludePathPatterns("/api/auth/login", "/error");
}
}

View File

@@ -0,0 +1,27 @@
package com.noblelift.ota.config;
import cn.dev33.satoken.exception.NotLoginException;
import cn.dev33.satoken.exception.NotRoleException;
import com.noblelift.ota.common.dto.ApiMessage;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
@RestControllerAdvice
public class SaTokenExceptionHandler {
@ExceptionHandler(NotLoginException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ApiMessage handleNotLogin(NotLoginException ex) {
return new ApiMessage("UNAUTHORIZED", ex.getMessage(), Instant.now());
}
@ExceptionHandler(NotRoleException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiMessage handleNotRole(NotRoleException ex) {
return new ApiMessage("FORBIDDEN", ex.getMessage(), Instant.now());
}
}

View File

@@ -0,0 +1,15 @@
package com.noblelift.ota.domain;
public enum AgentStatus {
IDLE,
HAS_UPDATE,
WAIT_USER_CONFIRM,
BACKING_UP_DATABASE,
PULLING_IMAGE,
RESTARTING_SERVICE,
HEALTH_CHECKING,
SUCCESS,
FAILED,
ROLLBACKING,
ROLLED_BACK
}

View File

@@ -0,0 +1,12 @@
package com.noblelift.ota.domain;
public enum TaskStatus {
PUBLISHED,
AVAILABLE,
WAITING_CONFIRM,
UPGRADING,
SUCCESS,
FAILED,
ROLLED_BACK,
SKIPPED
}

View File

@@ -0,0 +1,7 @@
package com.noblelift.ota.domain;
public enum UpgradeMode {
MANUAL_CONFIRM,
FORCE_CONFIRM,
AUTO
}

View File

@@ -0,0 +1,65 @@
package com.noblelift.ota.module.agent.controller;
import com.noblelift.ota.common.annotation.Log;
import com.noblelift.ota.common.dto.ApiMessage;
import com.noblelift.ota.config.AgentTokenAuthenticator;
import com.noblelift.ota.module.agent.dto.HeartbeatResponse;
import com.noblelift.ota.module.agent.dto.UpdateCheckResponse;
import com.noblelift.ota.module.agent.param.HeartbeatParam;
import com.noblelift.ota.module.agent.param.ReportParam;
import com.noblelift.ota.module.agent.param.UpdateCheckParam;
import com.noblelift.ota.module.agent.service.AgentService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/agent")
@RequiredArgsConstructor
public class AgentController {
private final AgentService agentService;
private final AgentTokenAuthenticator agentTokenAuthenticator;
@Log("Agent心跳")
@PostMapping("/heartbeat")
public HeartbeatResponse heartbeat(HttpServletRequest request, @Valid @RequestBody HeartbeatParam param) {
agentTokenAuthenticator.verify(request);
return agentService.heartbeat(param);
}
@Log("Agent检查更新")
@PostMapping("/update-check")
public UpdateCheckResponse updateCheck(HttpServletRequest request, @Valid @RequestBody UpdateCheckParam param) {
agentTokenAuthenticator.verify(request);
return agentService.updateCheck(param);
}
@Log("Agent上报结果")
@PostMapping("/report")
public ApiMessage report(HttpServletRequest request, @Valid @RequestBody ReportParam param) {
agentTokenAuthenticator.verify(request);
return agentService.report(param);
}
@Log("Agent确认升级")
@PostMapping("/confirm")
public ApiMessage confirmVehicleUpgrade(HttpServletRequest request, @Valid @RequestBody UpdateCheckParam param) {
agentTokenAuthenticator.verify(request);
return agentService.confirmUpgrade(param);
}
@Log("Agent稍后升级")
@PostMapping("/postpone")
@ResponseStatus(HttpStatus.ACCEPTED)
public ApiMessage postponeVehicleUpgrade(HttpServletRequest request, @Valid @RequestBody UpdateCheckParam param) {
agentTokenAuthenticator.verify(request);
return agentService.postponeUpgrade(param);
}
}

View File

@@ -0,0 +1,17 @@
package com.noblelift.ota.module.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class HeartbeatResponse {
private boolean accepted;
private Instant serverTime;
private String message;
}

View File

@@ -0,0 +1,16 @@
package com.noblelift.ota.module.agent.dto;
import com.noblelift.ota.common.model.ReleaseManifest;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UpdateCheckResponse {
private boolean hasUpdate;
private ReleaseManifest manifest;
private String message;
}

View File

@@ -0,0 +1,40 @@
package com.noblelift.ota.module.agent.param;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.noblelift.ota.domain.AgentStatus;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class HeartbeatParam {
@NotBlank
@JsonAlias("vehicle_id")
private String vehicleId;
@JsonAlias("vin")
private String vin;
@NotBlank
@JsonAlias("current_release")
private String currentRelease;
@JsonAlias({"agent_status", "status"})
private AgentStatus agentStatus;
@JsonAlias({"target_release", "release_version"})
private String targetRelease;
@JsonAlias("last_result")
private String lastResult;
private Map<String, String> images;
@JsonAlias("backup_file")
private String backupFile;
@JsonAlias("ip_address")
private String ipAddress;
}

View File

@@ -0,0 +1,38 @@
package com.noblelift.ota.module.agent.param;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.noblelift.ota.domain.AgentStatus;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class ReportParam {
@NotBlank
@JsonAlias("vehicle_id")
private String vehicleId;
@JsonAlias("vin")
private String vin;
@JsonAlias("current_release")
private String currentRelease;
@JsonAlias({"release_version", "target_release"})
private String releaseVersion;
@JsonAlias({"agent_status", "status"})
private AgentStatus agentStatus;
private boolean success;
@JsonAlias({"message", "detail"})
private String message;
private Map<String, String> images;
@JsonAlias("backup_file")
private String backupFile;
}

View File

@@ -0,0 +1,20 @@
package com.noblelift.ota.module.agent.param;
import com.fasterxml.jackson.annotation.JsonAlias;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateCheckParam {
@NotBlank
@JsonAlias("vehicle_id")
private String vehicleId;
@JsonAlias("vin")
private String vin;
@NotBlank
@JsonAlias("current_release")
private String currentRelease;
}

View File

@@ -0,0 +1,21 @@
package com.noblelift.ota.module.agent.service;
import com.noblelift.ota.common.dto.ApiMessage;
import com.noblelift.ota.module.agent.dto.HeartbeatResponse;
import com.noblelift.ota.module.agent.dto.UpdateCheckResponse;
import com.noblelift.ota.module.agent.param.HeartbeatParam;
import com.noblelift.ota.module.agent.param.ReportParam;
import com.noblelift.ota.module.agent.param.UpdateCheckParam;
public interface AgentService {
HeartbeatResponse heartbeat(HeartbeatParam param);
UpdateCheckResponse updateCheck(UpdateCheckParam param);
ApiMessage report(ReportParam param);
ApiMessage confirmUpgrade(UpdateCheckParam param);
ApiMessage postponeUpgrade(UpdateCheckParam param);
}

View File

@@ -0,0 +1,177 @@
package com.noblelift.ota.module.agent.service.impl;
import com.noblelift.ota.common.dto.ApiMessage;
import com.noblelift.ota.common.model.ReleaseManifest;
import com.noblelift.ota.common.model.VehicleAssignment;
import com.noblelift.ota.common.model.VehicleInfo;
import com.noblelift.ota.common.util.ReleaseVersionComparator;
import com.noblelift.ota.domain.AgentStatus;
import com.noblelift.ota.domain.TaskStatus;
import com.noblelift.ota.module.agent.dto.HeartbeatResponse;
import com.noblelift.ota.module.agent.dto.UpdateCheckResponse;
import com.noblelift.ota.module.agent.param.HeartbeatParam;
import com.noblelift.ota.module.agent.param.ReportParam;
import com.noblelift.ota.module.agent.param.UpdateCheckParam;
import com.noblelift.ota.module.agent.service.AgentService;
import com.noblelift.ota.module.assignment.service.AssignmentService;
import com.noblelift.ota.module.release.service.ReleaseService;
import com.noblelift.ota.module.vehicle.service.VehicleService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
@Service
@RequiredArgsConstructor
public class AgentServiceImpl implements AgentService {
private static final int MAX_LAST_MESSAGE_LENGTH = 500;
private final VehicleService vehicleService;
private final AssignmentService assignmentService;
private final ReleaseService releaseService;
@Override
public HeartbeatResponse heartbeat(HeartbeatParam param) {
AgentStatus agentStatus = resolveAgentStatus(param.getAgentStatus());
vehicleService.saveHeartbeat(
param.getVehicleId(),
param.getVin(),
param.getCurrentRelease(),
agentStatus,
param.getTargetRelease(),
param.getLastResult(),
param.getImages(),
param.getBackupFile()
);
return new HeartbeatResponse(true, Instant.now(), "heartbeat accepted");
}
@Override
public UpdateCheckResponse updateCheck(UpdateCheckParam param) {
VehicleAssignment assignment;
try {
assignment = assignmentService.getAssignment(param.getVehicleId());
} catch (IllegalArgumentException ex) {
return new UpdateCheckResponse(false, null, "No release assigned");
}
ReleaseManifest manifest = releaseService.getRelease(assignment.getReleaseVersion());
int versionCompare = ReleaseVersionComparator.compare(manifest.getReleaseVersion(), param.getCurrentRelease());
if (versionCompare == 0) {
return new UpdateCheckResponse(false, null, "Already on latest assigned release");
}
if (versionCompare < 0) {
return new UpdateCheckResponse(false, null, "Assigned release is lower than current release");
}
if (assignment.getTaskStatus() == TaskStatus.AVAILABLE || assignment.getTaskStatus() == TaskStatus.SKIPPED) {
assignment.setTaskStatus(TaskStatus.WAITING_CONFIRM);
assignment.setPromptedAt(assignment.getPromptedAt() == null ? Instant.now() : assignment.getPromptedAt());
assignment.setLastMessage("Update is visible to vehicle");
assignmentService.saveAssignment(assignment);
}
return new UpdateCheckResponse(true, manifest, "Update available");
}
@Override
public ApiMessage report(ReportParam param) {
VehicleAssignment assignment = assignmentService.getAssignment(param.getVehicleId());
AgentStatus agentStatus = resolveAgentStatus(param.getAgentStatus());
boolean success = resolveSuccess(param, agentStatus);
TaskStatus status = mapTaskStatus(success, agentStatus);
Instant now = Instant.now();
String releaseVersion = resolveReleaseVersion(param, assignment);
assignment.setReleaseVersion(releaseVersion);
assignment.setTaskStatus(status);
if (assignment.getConfirmedAt() == null && status == TaskStatus.UPGRADING) {
assignment.setConfirmedAt(now);
}
if (assignment.getStartedAt() == null && status == TaskStatus.UPGRADING) {
assignment.setStartedAt(now);
}
if (status == TaskStatus.SUCCESS || status == TaskStatus.FAILED || status == TaskStatus.ROLLED_BACK) {
assignment.setFinishedAt(now);
}
assignment.setLastMessage(truncateLastMessage(param.getMessage()));
assignmentService.saveAssignment(assignment);
vehicleService.saveHeartbeat(
param.getVehicleId(),
param.getVin(),
resolveCurrentRelease(param, success, releaseVersion),
agentStatus,
releaseVersion,
truncateLastMessage(param.getMessage()),
param.getImages(),
param.getBackupFile()
);
return new ApiMessage("OK", "report accepted", Instant.now());
}
@Override
public ApiMessage confirmUpgrade(UpdateCheckParam param) {
assignmentService.markConfirmed(param.getVehicleId());
return new ApiMessage("OK", "vehicle upgrade confirmed", Instant.now());
}
@Override
public ApiMessage postponeUpgrade(UpdateCheckParam param) {
assignmentService.markPostponed(param.getVehicleId(), "vehicle postponed upgrade");
return new ApiMessage("OK", "vehicle upgrade postponed", Instant.now());
}
private TaskStatus mapTaskStatus(boolean success, AgentStatus agentStatus) {
if (success && agentStatus == AgentStatus.SUCCESS) {
return TaskStatus.SUCCESS;
}
if (agentStatus == AgentStatus.ROLLBACKING || agentStatus == AgentStatus.ROLLED_BACK) {
return TaskStatus.ROLLED_BACK;
}
if (agentStatus == AgentStatus.BACKING_UP_DATABASE
|| agentStatus == AgentStatus.PULLING_IMAGE
|| agentStatus == AgentStatus.RESTARTING_SERVICE
|| agentStatus == AgentStatus.HEALTH_CHECKING) {
return TaskStatus.UPGRADING;
}
return success ? TaskStatus.SUCCESS : TaskStatus.FAILED;
}
private AgentStatus resolveAgentStatus(AgentStatus agentStatus) {
return agentStatus == null ? AgentStatus.IDLE : agentStatus;
}
private boolean resolveSuccess(ReportParam param, AgentStatus agentStatus) {
if (agentStatus == AgentStatus.SUCCESS) {
return true;
}
if (agentStatus == AgentStatus.FAILED || agentStatus == AgentStatus.ROLLBACKING || agentStatus == AgentStatus.ROLLED_BACK) {
return false;
}
return param.isSuccess();
}
private String resolveReleaseVersion(ReportParam param, VehicleAssignment assignment) {
if (param.getReleaseVersion() != null && !param.getReleaseVersion().isBlank()) {
return param.getReleaseVersion();
}
if (param.getCurrentRelease() != null && !param.getCurrentRelease().isBlank()) {
return param.getCurrentRelease();
}
return assignment.getReleaseVersion();
}
private String resolveCurrentRelease(ReportParam param, boolean success, String releaseVersion) {
if (param.getCurrentRelease() != null && !param.getCurrentRelease().isBlank()) {
return param.getCurrentRelease();
}
return success ? releaseVersion : null;
}
private String truncateLastMessage(String message) {
if (message == null || message.length() <= MAX_LAST_MESSAGE_LENGTH) {
return message;
}
return message.substring(0, MAX_LAST_MESSAGE_LENGTH - 3) + "...";
}
}

View File

@@ -0,0 +1,73 @@
package com.noblelift.ota.module.assignment.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.noblelift.ota.common.annotation.Log;
import com.noblelift.ota.module.assignment.dto.AssignmentView;
import com.noblelift.ota.module.assignment.param.AssignReleaseParam;
import com.noblelift.ota.module.assignment.service.AssignmentService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/assignments")
@RequiredArgsConstructor
public class AssignmentController {
private final AssignmentService assignmentService;
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("分配版本")
@PostMapping
public List<AssignmentView> assignRelease(@Valid @RequestBody AssignReleaseParam param) {
return assignmentService.assignRelease(param.getReleaseVersion(), param.getVehicleIds()).stream()
.map(this::toView)
.toList();
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("修改任务")
@PutMapping("/{vehicleId}")
public AssignmentView updateAssignment(@PathVariable String vehicleId, @Valid @RequestBody AssignReleaseParam param) {
return toView(assignmentService.updateAssignment(vehicleId, param));
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("删除任务")
@DeleteMapping("/{vehicleId}")
public void deleteAssignment(@PathVariable String vehicleId) {
assignmentService.deleteAssignment(vehicleId);
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("查询任务列表")
@GetMapping
public List<AssignmentView> listAssignments() {
return assignmentService.listAssignments().stream()
.map(this::toView)
.toList();
}
private AssignmentView toView(com.noblelift.ota.common.model.VehicleAssignment item) {
return new AssignmentView(
item.getVehicleId(),
item.getReleaseVersion(),
item.getTaskStatus(),
item.getPromptedAt(),
item.getConfirmedAt(),
item.getStartedAt(),
item.getFinishedAt(),
item.getPostponeCount(),
item.getLastMessage()
);
}
}

View File

@@ -0,0 +1,24 @@
package com.noblelift.ota.module.assignment.dto;
import com.noblelift.ota.domain.TaskStatus;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AssignmentView {
private String vehicleId;
private String releaseVersion;
private TaskStatus taskStatus;
private Instant promptedAt;
private Instant confirmedAt;
private Instant startedAt;
private Instant finishedAt;
private Integer postponeCount;
private String lastMessage;
}

View File

@@ -0,0 +1,25 @@
package com.noblelift.ota.module.assignment.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("ota_vehicle_assignment")
public class VehicleAssignmentEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String vehicleId;
private String releaseVersion;
private String taskStatus;
private Instant promptedAt;
private Instant confirmedAt;
private Instant startedAt;
private Instant finishedAt;
private Integer postponeCount;
private String lastMessage;
}

View File

@@ -0,0 +1,9 @@
package com.noblelift.ota.module.assignment.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.noblelift.ota.module.assignment.entity.VehicleAssignmentEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VehicleAssignmentMapper extends BaseMapper<VehicleAssignmentEntity> {
}

View File

@@ -0,0 +1,15 @@
package com.noblelift.ota.module.assignment.param;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.List;
@Data
public class AssignReleaseParam {
@NotBlank
private String releaseVersion;
private List<String> vehicleIds;
}

View File

@@ -0,0 +1,25 @@
package com.noblelift.ota.module.assignment.service;
import com.noblelift.ota.common.model.VehicleAssignment;
import com.noblelift.ota.module.assignment.param.AssignReleaseParam;
import java.util.List;
public interface AssignmentService {
List<VehicleAssignment> listAssignments();
List<VehicleAssignment> assignRelease(String releaseVersion, List<String> vehicleIds);
VehicleAssignment updateAssignment(String vehicleId, AssignReleaseParam param);
void deleteAssignment(String vehicleId);
VehicleAssignment getAssignment(String vehicleId);
VehicleAssignment markPostponed(String vehicleId, String reason);
VehicleAssignment markConfirmed(String vehicleId);
VehicleAssignment saveAssignment(VehicleAssignment assignment);
}

View File

@@ -0,0 +1,170 @@
package com.noblelift.ota.module.assignment.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.noblelift.ota.common.model.VehicleAssignment;
import com.noblelift.ota.common.model.VehicleInfo;
import com.noblelift.ota.common.util.ReleaseVersionComparator;
import com.noblelift.ota.domain.TaskStatus;
import com.noblelift.ota.module.assignment.entity.VehicleAssignmentEntity;
import com.noblelift.ota.module.assignment.mapper.VehicleAssignmentMapper;
import com.noblelift.ota.module.assignment.param.AssignReleaseParam;
import com.noblelift.ota.module.assignment.service.AssignmentService;
import com.noblelift.ota.module.release.service.ReleaseService;
import com.noblelift.ota.module.vehicle.service.VehicleService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AssignmentServiceImpl implements AssignmentService {
private final VehicleAssignmentMapper vehicleAssignmentMapper;
private final VehicleService vehicleService;
private final ReleaseService releaseService;
@Override
public List<VehicleAssignment> listAssignments() {
return vehicleAssignmentMapper.selectList(new LambdaQueryWrapper<VehicleAssignmentEntity>()
.orderByAsc(VehicleAssignmentEntity::getVehicleId))
.stream()
.map(this::toModel)
.toList();
}
@Override
public List<VehicleAssignment> assignRelease(String releaseVersion, List<String> vehicleIds) {
releaseService.getRelease(releaseVersion);
return vehicleIds.stream()
.map(vehicleId -> saveAssignment(buildAssignment(releaseVersion, vehicleId)))
.toList();
}
@Override
public VehicleAssignment updateAssignment(String vehicleId, AssignReleaseParam param) {
VehicleAssignmentEntity existing = getEntity(vehicleId);
VehicleAssignment assignment = buildAssignment(param.getReleaseVersion(), vehicleId);
assignment.setTaskStatus(existing.getTaskStatus() == null ? TaskStatus.AVAILABLE : TaskStatus.valueOf(existing.getTaskStatus()));
assignment.setPromptedAt(existing.getPromptedAt());
assignment.setConfirmedAt(existing.getConfirmedAt());
assignment.setStartedAt(existing.getStartedAt());
assignment.setFinishedAt(existing.getFinishedAt());
assignment.setPostponeCount(existing.getPostponeCount() == null ? 0 : existing.getPostponeCount());
if (existing.getLastMessage() != null && !existing.getLastMessage().isBlank()) {
assignment.setLastMessage(existing.getLastMessage());
}
return saveAssignment(assignment, existing.getId());
}
@Override
public void deleteAssignment(String vehicleId) {
VehicleAssignmentEntity existing = getEntity(vehicleId);
vehicleAssignmentMapper.deleteById(existing.getId());
}
@Override
public VehicleAssignment getAssignment(String vehicleId) {
return toModel(getEntity(vehicleId));
}
@Override
public VehicleAssignment markPostponed(String vehicleId, String reason) {
VehicleAssignment assignment = getAssignment(vehicleId);
assignment.setTaskStatus(TaskStatus.SKIPPED);
assignment.setPromptedAt(assignment.getPromptedAt() == null ? Instant.now() : assignment.getPromptedAt());
assignment.setPostponeCount((assignment.getPostponeCount() == null ? 0 : assignment.getPostponeCount()) + 1);
assignment.setLastMessage(reason);
return saveAssignment(assignment);
}
@Override
public VehicleAssignment markConfirmed(String vehicleId) {
VehicleAssignment assignment = getAssignment(vehicleId);
Instant now = Instant.now();
assignment.setTaskStatus(TaskStatus.UPGRADING);
assignment.setPromptedAt(assignment.getPromptedAt() == null ? now : assignment.getPromptedAt());
assignment.setConfirmedAt(now);
assignment.setStartedAt(now);
assignment.setLastMessage("User confirmed upgrade");
return saveAssignment(assignment);
}
@Override
public VehicleAssignment saveAssignment(VehicleAssignment assignment) {
VehicleAssignmentEntity existing = vehicleAssignmentMapper.selectOne(new LambdaQueryWrapper<VehicleAssignmentEntity>()
.eq(VehicleAssignmentEntity::getVehicleId, assignment.getVehicleId())
.last("limit 1"));
return saveAssignment(assignment, existing == null ? null : existing.getId());
}
private VehicleAssignment saveAssignment(VehicleAssignment assignment, Long existingId) {
VehicleAssignmentEntity entity = toEntity(assignment);
if (existingId == null) {
vehicleAssignmentMapper.insert(entity);
} else {
entity.setId(existingId);
vehicleAssignmentMapper.updateById(entity);
}
return assignment;
}
private VehicleAssignment buildAssignment(String releaseVersion, String vehicleId) {
VehicleInfo vehicle = vehicleService.listVehicles().stream()
.filter(item -> item.getVehicleId().equals(vehicleId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Vehicle not found: " + vehicleId));
int versionCompare = ReleaseVersionComparator.compare(releaseVersion, vehicle.getCurrentRelease());
if (versionCompare <= 0) {
throw new IllegalArgumentException("Assigned release must be higher than current release for vehicle: " + vehicleId);
}
return VehicleAssignment.builder()
.vehicleId(vehicleId)
.releaseVersion(releaseVersion)
.taskStatus(TaskStatus.AVAILABLE)
.postponeCount(0)
.lastMessage("Release available for upgrade")
.build();
}
private VehicleAssignmentEntity getEntity(String vehicleId) {
VehicleAssignmentEntity entity = vehicleAssignmentMapper.selectOne(new LambdaQueryWrapper<VehicleAssignmentEntity>()
.eq(VehicleAssignmentEntity::getVehicleId, vehicleId)
.last("limit 1"));
if (entity == null) {
throw new IllegalArgumentException("Assignment not found for vehicle: " + vehicleId);
}
return entity;
}
private VehicleAssignment toModel(VehicleAssignmentEntity entity) {
return VehicleAssignment.builder()
.vehicleId(entity.getVehicleId())
.releaseVersion(entity.getReleaseVersion())
.taskStatus(TaskStatus.valueOf(entity.getTaskStatus()))
.promptedAt(entity.getPromptedAt())
.confirmedAt(entity.getConfirmedAt())
.startedAt(entity.getStartedAt())
.finishedAt(entity.getFinishedAt())
.postponeCount(entity.getPostponeCount() == null ? 0 : entity.getPostponeCount())
.lastMessage(entity.getLastMessage())
.build();
}
private VehicleAssignmentEntity toEntity(VehicleAssignment assignment) {
VehicleAssignmentEntity entity = new VehicleAssignmentEntity();
entity.setVehicleId(assignment.getVehicleId());
entity.setReleaseVersion(assignment.getReleaseVersion());
entity.setTaskStatus(assignment.getTaskStatus().name());
entity.setPromptedAt(assignment.getPromptedAt());
entity.setConfirmedAt(assignment.getConfirmedAt());
entity.setStartedAt(assignment.getStartedAt());
entity.setFinishedAt(assignment.getFinishedAt());
entity.setPostponeCount(assignment.getPostponeCount());
entity.setLastMessage(assignment.getLastMessage());
return entity;
}
}

View File

@@ -0,0 +1,53 @@
package com.noblelift.ota.module.auth.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.stp.StpUtil;
import com.noblelift.ota.common.annotation.Log;
import com.noblelift.ota.common.dto.ApiMessage;
import com.noblelift.ota.module.auth.dto.LoginResponse;
import com.noblelift.ota.module.auth.param.LoginParam;
import com.noblelift.ota.module.auth.service.AuthService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@Log("用户登录")
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginParam param) {
return authService.login(param);
}
@Log("用户退出")
@SaCheckLogin
@PostMapping("/logout")
public ApiMessage logout() {
authService.logout();
return new ApiMessage("OK", "logout success", Instant.now());
}
@Log("查询当前用户")
@SaCheckLogin
@GetMapping("/me")
public Map<String, Object> currentUser() {
Map<String, Object> result = new HashMap<>();
result.put("loginId", StpUtil.getLoginIdAsLong());
result.put("roles", authService.getCurrentRoles());
result.put("token", StpUtil.getTokenValue());
return result;
}
}

View File

@@ -0,0 +1,19 @@
package com.noblelift.ota.module.auth.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String token;
private Long userId;
private String username;
private String nickname;
private List<String> roles;
}

View File

@@ -0,0 +1,21 @@
package com.noblelift.ota.module.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("sys_role")
public class SysRoleEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String roleCode;
private String roleName;
private Integer status;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,22 @@
package com.noblelift.ota.module.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("sys_user")
public class SysUserEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String nickname;
private Integer status;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,19 @@
package com.noblelift.ota.module.auth.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("sys_user_role")
public class SysUserRoleEntity {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private Long roleId;
private Instant createdAt;
}

View File

@@ -0,0 +1,9 @@
package com.noblelift.ota.module.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.noblelift.ota.module.auth.entity.SysRoleEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysRoleMapper extends BaseMapper<SysRoleEntity> {
}

View File

@@ -0,0 +1,9 @@
package com.noblelift.ota.module.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.noblelift.ota.module.auth.entity.SysUserEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysUserMapper extends BaseMapper<SysUserEntity> {
}

View File

@@ -0,0 +1,9 @@
package com.noblelift.ota.module.auth.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.noblelift.ota.module.auth.entity.SysUserRoleEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysUserRoleMapper extends BaseMapper<SysUserRoleEntity> {
}

View File

@@ -0,0 +1,14 @@
package com.noblelift.ota.module.auth.param;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginParam {
@NotBlank
private String username;
@NotBlank
private String password;
}

View File

@@ -0,0 +1,15 @@
package com.noblelift.ota.module.auth.service;
import com.noblelift.ota.module.auth.dto.LoginResponse;
import com.noblelift.ota.module.auth.param.LoginParam;
import java.util.List;
public interface AuthService {
LoginResponse login(LoginParam param);
void logout();
List<String> getCurrentRoles();
}

View File

@@ -0,0 +1,76 @@
package com.noblelift.ota.module.auth.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.noblelift.ota.module.auth.dto.LoginResponse;
import com.noblelift.ota.module.auth.entity.SysRoleEntity;
import com.noblelift.ota.module.auth.entity.SysUserEntity;
import com.noblelift.ota.module.auth.entity.SysUserRoleEntity;
import com.noblelift.ota.module.auth.mapper.SysRoleMapper;
import com.noblelift.ota.module.auth.mapper.SysUserMapper;
import com.noblelift.ota.module.auth.mapper.SysUserRoleMapper;
import com.noblelift.ota.module.auth.param.LoginParam;
import com.noblelift.ota.module.auth.service.AuthService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
@Override
public LoginResponse login(LoginParam param) {
SysUserEntity user = sysUserMapper.selectOne(new LambdaQueryWrapper<SysUserEntity>()
.eq(SysUserEntity::getUsername, param.getUsername())
.last("limit 1"));
if (user == null || user.getStatus() == null || user.getStatus() != 1) {
throw new IllegalArgumentException("用户不存在或已禁用");
}
String encodedPassword = DigestUtils.md5DigestAsHex(param.getPassword().getBytes(StandardCharsets.UTF_8));
if (!encodedPassword.equals(user.getPassword())) {
throw new IllegalArgumentException("用户名或密码错误");
}
StpUtil.login(user.getId());
List<String> roles = getRolesByUserId(user.getId());
return new LoginResponse(StpUtil.getTokenValue(), user.getId(), user.getUsername(), user.getNickname(), roles);
}
@Override
public void logout() {
StpUtil.logout();
}
@Override
public List<String> getCurrentRoles() {
Object loginId = StpUtil.getLoginIdDefaultNull();
if (loginId == null) {
return Collections.emptyList();
}
return getRolesByUserId(Long.valueOf(String.valueOf(loginId)));
}
private List<String> getRolesByUserId(Long userId) {
List<Long> roleIds = sysUserRoleMapper.selectList(new LambdaQueryWrapper<SysUserRoleEntity>()
.eq(SysUserRoleEntity::getUserId, userId))
.stream()
.map(SysUserRoleEntity::getRoleId)
.toList();
if (roleIds.isEmpty()) {
return Collections.emptyList();
}
return sysRoleMapper.selectBatchIds(roleIds).stream()
.map(SysRoleEntity::getRoleCode)
.toList();
}
}

View File

@@ -0,0 +1,42 @@
package com.noblelift.ota.module.auth.service.impl;
import cn.dev33.satoken.stp.StpInterface;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.noblelift.ota.module.auth.entity.SysRoleEntity;
import com.noblelift.ota.module.auth.entity.SysUserRoleEntity;
import com.noblelift.ota.module.auth.mapper.SysRoleMapper;
import com.noblelift.ota.module.auth.mapper.SysUserRoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
@Component
@RequiredArgsConstructor
public class StpInterfaceImpl implements StpInterface {
private final SysUserRoleMapper sysUserRoleMapper;
private final SysRoleMapper sysRoleMapper;
@Override
public List<String> getPermissionList(Object loginId, String loginType) {
return Collections.emptyList();
}
@Override
public List<String> getRoleList(Object loginId, String loginType) {
Long userId = Long.valueOf(String.valueOf(loginId));
List<Long> roleIds = sysUserRoleMapper.selectList(new LambdaQueryWrapper<SysUserRoleEntity>()
.eq(SysUserRoleEntity::getUserId, userId))
.stream()
.map(SysUserRoleEntity::getRoleId)
.toList();
if (roleIds.isEmpty()) {
return Collections.emptyList();
}
return sysRoleMapper.selectBatchIds(roleIds).stream()
.map(SysRoleEntity::getRoleCode)
.toList();
}
}

View File

@@ -0,0 +1,57 @@
package com.noblelift.ota.module.release.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.noblelift.ota.common.annotation.Log;
import com.noblelift.ota.module.release.dto.ReleaseView;
import com.noblelift.ota.module.release.param.CreateReleaseParam;
import com.noblelift.ota.module.release.service.ReleaseService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/releases")
@RequiredArgsConstructor
public class ReleaseController {
private final ReleaseService releaseService;
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("创建版本")
@PostMapping
public ReleaseView createRelease(@Valid @RequestBody CreateReleaseParam param) {
return new ReleaseView(releaseService.createRelease(param));
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("修改版本")
@PutMapping("/{releaseVersion}")
public ReleaseView updateRelease(@PathVariable String releaseVersion, @Valid @RequestBody CreateReleaseParam param) {
return new ReleaseView(releaseService.updateRelease(releaseVersion, param));
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("删除版本")
@DeleteMapping("/{releaseVersion}")
public void deleteRelease(@PathVariable String releaseVersion) {
releaseService.deleteRelease(releaseVersion);
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("查询版本列表")
@GetMapping
public List<ReleaseView> listReleases() {
return releaseService.listReleases().stream()
.map(ReleaseView::new)
.toList();
}
}

View File

@@ -0,0 +1,14 @@
package com.noblelift.ota.module.release.dto;
import com.noblelift.ota.common.model.ReleaseManifest;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ReleaseView {
private ReleaseManifest manifest;
}

View File

@@ -0,0 +1,22 @@
package com.noblelift.ota.module.release.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("ota_release")
public class ReleaseEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String releaseVersion;
private String releaseNotes;
private String upgradeMode;
private Instant publishedAt;
private Boolean parkingRequired;
private String componentsJson;
}

View File

@@ -0,0 +1,9 @@
package com.noblelift.ota.module.release.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.noblelift.ota.module.release.entity.ReleaseEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReleaseMapper extends BaseMapper<ReleaseEntity> {
}

View File

@@ -0,0 +1,25 @@
package com.noblelift.ota.module.release.param;
import com.noblelift.ota.domain.UpgradeMode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.Map;
@Data
public class CreateReleaseParam {
@NotBlank
private String releaseVersion;
@NotBlank
private String releaseNotes;
private UpgradeMode upgradeMode;
private Boolean parkingRequired;
@NotEmpty
private Map<String, Object> components;
}

View File

@@ -0,0 +1,19 @@
package com.noblelift.ota.module.release.service;
import com.noblelift.ota.common.model.ReleaseManifest;
import com.noblelift.ota.module.release.param.CreateReleaseParam;
import java.util.List;
public interface ReleaseService {
ReleaseManifest createRelease(CreateReleaseParam param);
ReleaseManifest updateRelease(String releaseVersion, CreateReleaseParam param);
void deleteRelease(String releaseVersion);
List<ReleaseManifest> listReleases();
ReleaseManifest getRelease(String releaseVersion);
}

View File

@@ -0,0 +1,158 @@
package com.noblelift.ota.module.release.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.noblelift.ota.common.model.ReleaseManifest;
import com.noblelift.ota.domain.UpgradeMode;
import com.noblelift.ota.module.assignment.entity.VehicleAssignmentEntity;
import com.noblelift.ota.module.assignment.mapper.VehicleAssignmentMapper;
import com.noblelift.ota.module.release.entity.ReleaseEntity;
import com.noblelift.ota.module.release.mapper.ReleaseMapper;
import com.noblelift.ota.module.release.param.CreateReleaseParam;
import com.noblelift.ota.module.release.service.ReleaseService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class ReleaseServiceImpl implements ReleaseService {
private final ReleaseMapper releaseMapper;
private final VehicleAssignmentMapper vehicleAssignmentMapper;
private final ObjectMapper objectMapper;
@Override
public ReleaseManifest createRelease(CreateReleaseParam param) {
ReleaseManifest manifest = ReleaseManifest.builder()
.releaseVersion(param.getReleaseVersion())
.releaseNotes(param.getReleaseNotes())
.upgradeMode(param.getUpgradeMode() == null ? UpgradeMode.MANUAL_CONFIRM : param.getUpgradeMode())
.publishedAt(Instant.now())
.parkingRequired(Boolean.TRUE.equals(param.getParkingRequired()))
.components(param.getComponents())
.build();
ReleaseEntity existing = releaseMapper.selectOne(new LambdaQueryWrapper<ReleaseEntity>()
.eq(ReleaseEntity::getReleaseVersion, manifest.getReleaseVersion())
.last("limit 1"));
ReleaseEntity entity = toEntity(manifest);
if (existing == null) {
releaseMapper.insert(entity);
} else {
entity.setId(existing.getId());
releaseMapper.updateById(entity);
}
return manifest;
}
@Override
public ReleaseManifest updateRelease(String releaseVersion, CreateReleaseParam param) {
ReleaseEntity existing = getEntity(releaseVersion);
ReleaseManifest manifest = ReleaseManifest.builder()
.releaseVersion(param.getReleaseVersion())
.releaseNotes(param.getReleaseNotes())
.upgradeMode(param.getUpgradeMode() == null ? UpgradeMode.MANUAL_CONFIRM : param.getUpgradeMode())
.publishedAt(existing.getPublishedAt())
.parkingRequired(Boolean.TRUE.equals(param.getParkingRequired()))
.components(param.getComponents())
.build();
ReleaseEntity duplicate = releaseMapper.selectOne(new LambdaQueryWrapper<ReleaseEntity>()
.eq(ReleaseEntity::getReleaseVersion, manifest.getReleaseVersion())
.last("limit 1"));
if (duplicate != null && !duplicate.getId().equals(existing.getId())) {
throw new IllegalArgumentException("版本号已存在:" + manifest.getReleaseVersion());
}
ReleaseEntity entity = toEntity(manifest);
entity.setId(existing.getId());
releaseMapper.updateById(entity);
return manifest;
}
@Override
public void deleteRelease(String releaseVersion) {
ReleaseEntity entity = getEntity(releaseVersion);
VehicleAssignmentEntity assignment = vehicleAssignmentMapper.selectOne(new LambdaQueryWrapper<VehicleAssignmentEntity>()
.eq(VehicleAssignmentEntity::getReleaseVersion, releaseVersion)
.last("limit 1"));
if (assignment != null) {
throw new IllegalArgumentException("该版本已被任务引用,无法删除:" + releaseVersion);
}
releaseMapper.deleteById(entity.getId());
}
@Override
public List<ReleaseManifest> listReleases() {
return releaseMapper.selectList(new LambdaQueryWrapper<ReleaseEntity>()
.orderByDesc(ReleaseEntity::getPublishedAt))
.stream()
.map(this::toModel)
.toList();
}
@Override
public ReleaseManifest getRelease(String releaseVersion) {
return toModel(getEntity(releaseVersion));
}
private ReleaseEntity getEntity(String releaseVersion) {
ReleaseEntity entity = releaseMapper.selectOne(new LambdaQueryWrapper<ReleaseEntity>()
.eq(ReleaseEntity::getReleaseVersion, releaseVersion)
.last("limit 1"));
if (entity == null) {
throw new IllegalArgumentException("Release not found: " + releaseVersion);
}
return entity;
}
private ReleaseManifest toModel(ReleaseEntity entity) {
return ReleaseManifest.builder()
.releaseVersion(entity.getReleaseVersion())
.releaseNotes(entity.getReleaseNotes())
.upgradeMode(UpgradeMode.valueOf(entity.getUpgradeMode()))
.publishedAt(entity.getPublishedAt())
.parkingRequired(Boolean.TRUE.equals(entity.getParkingRequired()))
.components(readComponents(entity.getComponentsJson()))
.build();
}
private ReleaseEntity toEntity(ReleaseManifest manifest) {
ReleaseEntity entity = new ReleaseEntity();
entity.setReleaseVersion(manifest.getReleaseVersion());
entity.setReleaseNotes(manifest.getReleaseNotes());
entity.setUpgradeMode(manifest.getUpgradeMode().name());
entity.setPublishedAt(manifest.getPublishedAt());
entity.setParkingRequired(manifest.getParkingRequired());
entity.setComponentsJson(writeComponents(manifest.getComponents()));
return entity;
}
private Map<String, Object> readComponents(String json) {
if (json == null || json.isBlank()) {
return Collections.emptyMap();
}
try {
return objectMapper.readValue(json, new TypeReference<>() {
});
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to parse componentsJson", e);
}
}
private String writeComponents(Map<String, Object> components) {
try {
return objectMapper.writeValueAsString(components == null ? Collections.emptyMap() : components);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize componentsJson", e);
}
}
}

View File

@@ -0,0 +1,75 @@
package com.noblelift.ota.module.vehicle.controller;
import cn.dev33.satoken.annotation.SaCheckRole;
import com.noblelift.ota.common.annotation.Log;
import com.noblelift.ota.module.vehicle.dto.VehicleView;
import com.noblelift.ota.module.vehicle.param.RegisterVehicleParam;
import com.noblelift.ota.module.vehicle.service.VehicleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/vehicles")
@RequiredArgsConstructor
public class VehicleController {
private final VehicleService vehicleService;
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("注册车辆")
@PostMapping
public VehicleView registerVehicle(@Valid @RequestBody RegisterVehicleParam param) {
return toView(vehicleService.registerVehicle(param));
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("修改车辆")
@PutMapping("/{vehicleId}")
public VehicleView updateVehicle(@PathVariable String vehicleId, @Valid @RequestBody RegisterVehicleParam param) {
return toView(vehicleService.updateVehicle(vehicleId, param));
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("删除车辆")
@DeleteMapping("/{vehicleId}")
public void deleteVehicle(@PathVariable String vehicleId) {
vehicleService.deleteVehicle(vehicleId);
}
@SaCheckRole(value = {"SUPER_ADMIN", "ADMIN"}, mode = cn.dev33.satoken.annotation.SaMode.OR)
@Log("查询车辆列表")
@GetMapping
public List<VehicleView> listVehicles() {
return vehicleService.listVehicles().stream()
.map(this::toView)
.toList();
}
private VehicleView toView(com.noblelift.ota.common.model.VehicleInfo vehicle) {
return new VehicleView(
vehicle.getVehicleId(),
vehicle.getVin(),
vehicle.getCurrentRelease(),
vehicle.getCurrentBackendVersion(),
vehicle.getCurrentFrontendVersion(),
vehicle.getCurrentRosVersion(),
vehicle.getLastSeenAt(),
vehicle.getAgentStatus(),
vehicle.getTargetRelease(),
vehicle.getLastResult(),
vehicle.getImages(),
vehicle.getBackupFile(),
vehicle.getOnline()
);
}
}

View File

@@ -0,0 +1,29 @@
package com.noblelift.ota.module.vehicle.dto;
import com.noblelift.ota.domain.AgentStatus;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
import java.util.Map;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class VehicleView {
private String vehicleId;
private String vin;
private String currentRelease;
private String currentBackendVersion;
private String currentFrontendVersion;
private String currentRosVersion;
private Instant lastSeenAt;
private AgentStatus agentStatus;
private String targetRelease;
private String lastResult;
private Map<String, String> images;
private String backupFile;
private Boolean online;
}

View File

@@ -0,0 +1,28 @@
package com.noblelift.ota.module.vehicle.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
@Data
@TableName("ota_vehicle")
public class VehicleEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String vehicleId;
private String vin;
private String currentRelease;
private String currentBackendVersion;
private String currentFrontendVersion;
private String currentRosVersion;
private Instant lastSeenAt;
private String agentStatus;
private String targetRelease;
private String lastResult;
private String imagesJson;
private String backupFile;
}

View File

@@ -0,0 +1,9 @@
package com.noblelift.ota.module.vehicle.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.noblelift.ota.module.vehicle.entity.VehicleEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface VehicleMapper extends BaseMapper<VehicleEntity> {
}

View File

@@ -0,0 +1,21 @@
package com.noblelift.ota.module.vehicle.param;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class RegisterVehicleParam {
@NotBlank
private String vehicleId;
@NotBlank
private String vin;
@NotBlank
private String currentRelease;
private String currentBackendVersion;
private String currentFrontendVersion;
private String currentRosVersion;
}

View File

@@ -0,0 +1,28 @@
package com.noblelift.ota.module.vehicle.service;
import com.noblelift.ota.common.model.VehicleInfo;
import com.noblelift.ota.domain.AgentStatus;
import com.noblelift.ota.module.vehicle.param.RegisterVehicleParam;
import java.util.List;
import java.util.Map;
public interface VehicleService {
VehicleInfo registerVehicle(RegisterVehicleParam param);
VehicleInfo updateVehicle(String vehicleId, RegisterVehicleParam param);
void deleteVehicle(String vehicleId);
List<VehicleInfo> listVehicles();
VehicleInfo saveHeartbeat(String vehicleId,
String vin,
String currentRelease,
AgentStatus agentStatus,
String targetRelease,
String lastResult,
Map<String, String> images,
String backupFile);
}

View File

@@ -0,0 +1,235 @@
package com.noblelift.ota.module.vehicle.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.noblelift.ota.common.model.VehicleInfo;
import com.noblelift.ota.config.OtaProperties;
import com.noblelift.ota.domain.AgentStatus;
import com.noblelift.ota.module.assignment.entity.VehicleAssignmentEntity;
import com.noblelift.ota.module.assignment.mapper.VehicleAssignmentMapper;
import com.noblelift.ota.module.vehicle.entity.VehicleEntity;
import com.noblelift.ota.module.vehicle.mapper.VehicleMapper;
import com.noblelift.ota.module.vehicle.param.RegisterVehicleParam;
import com.noblelift.ota.module.vehicle.service.VehicleService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class VehicleServiceImpl implements VehicleService {
private static final TypeReference<Map<String, String>> STRING_MAP_TYPE = new TypeReference<>() {
};
private final VehicleMapper vehicleMapper;
private final VehicleAssignmentMapper vehicleAssignmentMapper;
private final OtaProperties otaProperties;
private final ObjectMapper objectMapper;
@Override
public VehicleInfo registerVehicle(RegisterVehicleParam param) {
VehicleEntity current = vehicleMapper.selectOne(new LambdaQueryWrapper<VehicleEntity>()
.eq(VehicleEntity::getVehicleId, param.getVehicleId())
.last("limit 1"));
VehicleInfo vehicleInfo = VehicleInfo.builder()
.vehicleId(param.getVehicleId())
.vin(param.getVin())
.currentRelease(param.getCurrentRelease())
.currentBackendVersion(param.getCurrentBackendVersion())
.currentFrontendVersion(param.getCurrentFrontendVersion())
.currentRosVersion(param.getCurrentRosVersion())
.lastSeenAt(current == null ? null : current.getLastSeenAt())
.agentStatus(current == null || current.getAgentStatus() == null ? AgentStatus.IDLE : AgentStatus.valueOf(current.getAgentStatus()))
.targetRelease(current == null ? null : current.getTargetRelease())
.lastResult(current == null ? null : current.getLastResult())
.images(current == null ? Map.of() : parseImages(current.getImagesJson()))
.backupFile(current == null ? null : current.getBackupFile())
.online(current != null && isOnline(current.getLastSeenAt()))
.build();
saveVehicle(vehicleInfo);
return vehicleInfo;
}
@Override
public VehicleInfo updateVehicle(String vehicleId, RegisterVehicleParam param) {
VehicleEntity existing = getEntity(vehicleId);
VehicleEntity duplicate = vehicleMapper.selectOne(new LambdaQueryWrapper<VehicleEntity>()
.eq(VehicleEntity::getVehicleId, param.getVehicleId())
.last("limit 1"));
if (duplicate != null && !duplicate.getId().equals(existing.getId())) {
throw new IllegalArgumentException("车辆 ID 已存在:" + param.getVehicleId());
}
VehicleInfo vehicleInfo = VehicleInfo.builder()
.vehicleId(param.getVehicleId())
.vin(param.getVin())
.currentRelease(param.getCurrentRelease())
.currentBackendVersion(param.getCurrentBackendVersion())
.currentFrontendVersion(param.getCurrentFrontendVersion())
.currentRosVersion(param.getCurrentRosVersion())
.lastSeenAt(existing.getLastSeenAt())
.agentStatus(existing.getAgentStatus() == null ? AgentStatus.IDLE : AgentStatus.valueOf(existing.getAgentStatus()))
.targetRelease(existing.getTargetRelease())
.lastResult(existing.getLastResult())
.images(parseImages(existing.getImagesJson()))
.backupFile(existing.getBackupFile())
.online(isOnline(existing.getLastSeenAt()))
.build();
saveVehicle(vehicleInfo, existing.getId());
return vehicleInfo;
}
@Override
public void deleteVehicle(String vehicleId) {
VehicleEntity existing = getEntity(vehicleId);
VehicleAssignmentEntity assignment = vehicleAssignmentMapper.selectOne(new LambdaQueryWrapper<VehicleAssignmentEntity>()
.eq(VehicleAssignmentEntity::getVehicleId, vehicleId)
.last("limit 1"));
if (assignment != null) {
throw new IllegalArgumentException("该车辆存在任务记录,无法删除:" + vehicleId);
}
vehicleMapper.deleteById(existing.getId());
}
@Override
public List<VehicleInfo> listVehicles() {
return vehicleMapper.selectList(new LambdaQueryWrapper<VehicleEntity>()
.orderByAsc(VehicleEntity::getVehicleId))
.stream()
.map(this::toModel)
.toList();
}
@Override
public VehicleInfo saveHeartbeat(String vehicleId,
String vin,
String currentRelease,
AgentStatus agentStatus,
String targetRelease,
String lastResult,
Map<String, String> images,
String backupFile) {
VehicleEntity current = vehicleMapper.selectOne(new LambdaQueryWrapper<VehicleEntity>()
.eq(VehicleEntity::getVehicleId, vehicleId)
.last("limit 1"));
VehicleInfo info = VehicleInfo.builder()
.vehicleId(vehicleId)
.vin(vin)
.currentRelease(currentRelease)
.currentBackendVersion(current == null ? null : current.getCurrentBackendVersion())
.currentFrontendVersion(current == null ? null : current.getCurrentFrontendVersion())
.currentRosVersion(current == null ? null : current.getCurrentRosVersion())
.lastSeenAt(Instant.now())
.agentStatus(agentStatus == null
? (current == null || current.getAgentStatus() == null ? AgentStatus.IDLE : AgentStatus.valueOf(current.getAgentStatus()))
: agentStatus)
.targetRelease(targetRelease == null && current != null ? current.getTargetRelease() : targetRelease)
.lastResult(lastResult == null && current != null ? current.getLastResult() : lastResult)
.images(images == null ? (current == null ? Map.of() : parseImages(current.getImagesJson())) : new LinkedHashMap<>(images))
.backupFile(backupFile == null && current != null ? current.getBackupFile() : backupFile)
.online(true)
.build();
saveVehicle(info, current == null ? null : current.getId());
return info;
}
private void saveVehicle(VehicleInfo vehicleInfo, Long existingId) {
VehicleEntity entity = toEntity(vehicleInfo);
if (existingId == null) {
vehicleMapper.insert(entity);
} else {
entity.setId(existingId);
vehicleMapper.updateById(entity);
}
}
private void saveVehicle(VehicleInfo vehicleInfo) {
VehicleEntity existing = vehicleMapper.selectOne(new LambdaQueryWrapper<VehicleEntity>()
.eq(VehicleEntity::getVehicleId, vehicleInfo.getVehicleId())
.last("limit 1"));
saveVehicle(vehicleInfo, existing == null ? null : existing.getId());
}
private VehicleEntity getEntity(String vehicleId) {
VehicleEntity entity = vehicleMapper.selectOne(new LambdaQueryWrapper<VehicleEntity>()
.eq(VehicleEntity::getVehicleId, vehicleId)
.last("limit 1"));
if (entity == null) {
throw new IllegalArgumentException("Vehicle not found: " + vehicleId);
}
return entity;
}
private VehicleInfo toModel(VehicleEntity entity) {
return VehicleInfo.builder()
.vehicleId(entity.getVehicleId())
.vin(entity.getVin())
.currentRelease(entity.getCurrentRelease())
.currentBackendVersion(entity.getCurrentBackendVersion())
.currentFrontendVersion(entity.getCurrentFrontendVersion())
.currentRosVersion(entity.getCurrentRosVersion())
.lastSeenAt(entity.getLastSeenAt())
.agentStatus(entity.getAgentStatus() == null ? AgentStatus.IDLE : AgentStatus.valueOf(entity.getAgentStatus()))
.targetRelease(entity.getTargetRelease())
.lastResult(entity.getLastResult())
.images(parseImages(entity.getImagesJson()))
.backupFile(entity.getBackupFile())
.online(isOnline(entity.getLastSeenAt()))
.build();
}
private VehicleEntity toEntity(VehicleInfo vehicleInfo) {
VehicleEntity entity = new VehicleEntity();
entity.setVehicleId(vehicleInfo.getVehicleId());
entity.setVin(vehicleInfo.getVin());
entity.setCurrentRelease(vehicleInfo.getCurrentRelease());
entity.setCurrentBackendVersion(vehicleInfo.getCurrentBackendVersion());
entity.setCurrentFrontendVersion(vehicleInfo.getCurrentFrontendVersion());
entity.setCurrentRosVersion(vehicleInfo.getCurrentRosVersion());
entity.setLastSeenAt(vehicleInfo.getLastSeenAt());
entity.setAgentStatus(vehicleInfo.getAgentStatus() == null ? null : vehicleInfo.getAgentStatus().name());
entity.setTargetRelease(vehicleInfo.getTargetRelease());
entity.setLastResult(vehicleInfo.getLastResult());
entity.setImagesJson(writeImages(vehicleInfo.getImages()));
entity.setBackupFile(vehicleInfo.getBackupFile());
return entity;
}
private Map<String, String> parseImages(String imagesJson) {
if (imagesJson == null || imagesJson.isBlank()) {
return Map.of();
}
try {
return objectMapper.readValue(imagesJson, STRING_MAP_TYPE);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to parse imagesJson", e);
}
}
private String writeImages(Map<String, String> images) {
if (images == null || images.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(images);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to serialize imagesJson", e);
}
}
private boolean isOnline(Instant lastSeenAt) {
return lastSeenAt != null
&& lastSeenAt.isAfter(Instant.now().minusSeconds(otaProperties.getHeartbeatTimeoutSeconds()));
}
}

View File

@@ -0,0 +1,55 @@
spring:
application:
name: ota-server
datasource:
url: jdbc:mysql://127.0.0.1:3306/ota_server?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
server:
port: 8080
ota:
auth-token: f47ac10b-58cc-4372-a567-0e02b2c3d479
heartbeat-timeout-seconds: 120
cors:
allowed-origins:
- http://localhost:5173
- http://127.0.0.1:5173
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers:
- "*"
allow-credentials: true
sa-token:
token-name: X-OTA-TOKEN
token-prefix: ""
timeout: 1800
active-timeout: -1
is-concurrent: true
is-share: true
is-log: true
token-style: uuid
is-read-header: true
is-read-cookie: false
is-read-body: false
is-read-param: false
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
management:
endpoints:
web:
exposure:
include: health,info

View File

@@ -0,0 +1,90 @@
CREATE DATABASE IF NOT EXISTS ota_server DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE ota_server;
CREATE TABLE IF NOT EXISTS ota_release (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
release_version VARCHAR(128) NOT NULL UNIQUE,
release_notes TEXT NOT NULL,
upgrade_mode VARCHAR(32) NOT NULL,
published_at DATETIME(6) NOT NULL,
parking_required TINYINT(1) NOT NULL DEFAULT 0,
components_json JSON NOT NULL
);
CREATE TABLE IF NOT EXISTS ota_vehicle (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
vehicle_id VARCHAR(64) NOT NULL UNIQUE,
vin VARCHAR(64) NOT NULL,
current_release VARCHAR(128) NOT NULL,
current_backend_version VARCHAR(128) NULL,
current_frontend_version VARCHAR(128) NULL,
current_ros_version VARCHAR(128) NULL,
last_seen_at DATETIME(6) NULL,
agent_status VARCHAR(32) NULL,
target_release VARCHAR(128) NULL,
last_result VARCHAR(512) NULL,
images_json JSON NULL,
backup_file VARCHAR(512) NULL
);
CREATE TABLE IF NOT EXISTS ota_vehicle_assignment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
vehicle_id VARCHAR(64) NOT NULL UNIQUE,
release_version VARCHAR(128) NOT NULL,
task_status VARCHAR(32) NOT NULL,
prompted_at DATETIME(6) NULL,
confirmed_at DATETIME(6) NULL,
started_at DATETIME(6) NULL,
finished_at DATETIME(6) NULL,
postpone_count INT NOT NULL DEFAULT 0,
last_message VARCHAR(512) NULL,
CONSTRAINT fk_assignment_release_version FOREIGN KEY (release_version) REFERENCES ota_release(release_version)
);
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
nickname VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
);
CREATE TABLE IF NOT EXISTS sys_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
role_code VARCHAR(64) NOT NULL UNIQUE,
role_name VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL
);
CREATE TABLE IF NOT EXISTS sys_user_role (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
created_at DATETIME(6) NOT NULL,
UNIQUE KEY uk_user_role (user_id, role_id),
CONSTRAINT fk_user_role_user FOREIGN KEY (user_id) REFERENCES sys_user(id),
CONSTRAINT fk_user_role_role FOREIGN KEY (role_id) REFERENCES sys_role(id)
);
INSERT INTO sys_role (id, role_code, role_name, status, created_at, updated_at)
VALUES
(1, 'SUPER_ADMIN', '系统管理员', 1, NOW(6), NOW(6)),
(2, 'ADMIN', '运维管理员', 1, NOW(6), NOW(6)),
(3, 'AGENT', '车端代理', 1, NOW(6), NOW(6))
ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), status = VALUES(status), updated_at = NOW(6);
INSERT INTO sys_user (id, username, password, nickname, status, created_at, updated_at)
VALUES
(1, 'admin', 'e10adc3949ba59abbe56e057f20f883e', '超级管理员', 1, NOW(6), NOW(6)),
(2, 'agent', 'e10adc3949ba59abbe56e057f20f883e', '车端代理', 1, NOW(6), NOW(6))
ON DUPLICATE KEY UPDATE password = VALUES(password), nickname = VALUES(nickname), status = VALUES(status), updated_at = NOW(6);
INSERT INTO sys_user_role (user_id, role_id, created_at)
VALUES
(1, 1, NOW(6)),
(2, 3, NOW(6))
ON DUPLICATE KEY UPDATE created_at = VALUES(created_at);

View File

@@ -0,0 +1,55 @@
spring:
application:
name: ota-server
datasource:
url: jdbc:mysql://127.0.0.1:3306/ota_server?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
server:
port: 8080
ota:
auth-token: f47ac10b-58cc-4372-a567-0e02b2c3d479
heartbeat-timeout-seconds: 120
cors:
allowed-origins:
- http://localhost:5173
- http://127.0.0.1:5173
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers:
- "*"
allow-credentials: true
sa-token:
token-name: X-OTA-TOKEN
token-prefix: ""
timeout: 1800
active-timeout: -1
is-concurrent: true
is-share: true
is-log: true
token-style: uuid
is-read-header: true
is-read-cookie: false
is-read-body: false
is-read-param: false
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
global-config:
db-config:
id-type: auto
management:
endpoints:
web:
exposure:
include: health,info

Some files were not shown because too many files have changed in this diff Show More