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

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()));
}
}