From 676548514d358f94fec9e40ddbe452d051ebe133 Mon Sep 17 00:00:00 2001 From: aplicity <1634594707@qq.com> Date: Tue, 9 Jun 2026 02:05:41 +0800 Subject: [PATCH] feat(v7): close action loop and health readiness --- .../controller/AdminDispatchController.java | 62 ++- .../AdminSystemHealthController.java | 19 + .../admin/controller/HealthController.java | 27 +- .../AdminDispatchExceptionResolveRequest.java | 3 + .../fsd/admin/metrics/ApiRequestMetrics.java | 11 + .../service/AdminDispatchStreamService.java | 2 + .../service/SystemHealthAdminService.java | 3 + .../impl/AdminDispatchStreamServiceImpl.java | 5 + .../impl/SystemHealthAdminServiceImpl.java | 118 +++- .../vo/AdminDetailedMetricsResponse.java | 72 +++ .../admin/vo/AdminHealthTimelineResponse.java | 21 + .../dto/DispatchExceptionResolveRequest.java | 2 + .../service/DispatchExceptionService.java | 2 + .../impl/DispatchExceptionServiceImpl.java | 54 +- docs/ROADMAP-V7.md | 242 ++++++++ front/src/api/order.ts | 4 + front/src/types/exception.d.ts | 2 + front/src/views/exception/Index.vue | 31 +- front/src/views/order/List.vue | 40 +- front/src/views/system/SystemHealth.vue | 523 +++++++++++++++++- front/src/views/task/List.vue | 105 +++- 21 files changed, 1294 insertions(+), 54 deletions(-) create mode 100644 back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminDetailedMetricsResponse.java create mode 100644 back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminHealthTimelineResponse.java create mode 100644 docs/ROADMAP-V7.md diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminDispatchController.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminDispatchController.java index 2fc52cc..681a73a 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminDispatchController.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminDispatchController.java @@ -17,6 +17,7 @@ import com.fsd.admin.service.AdminQueryFacadeService; import com.fsd.admin.vo.AdminBatchTaskResultResponse; import com.fsd.admin.vo.AdminDashboardSummaryResponse; +import com.fsd.common.exception.BusinessException; import com.fsd.common.model.PageResponse; import com.fsd.common.model.ApiResponse; import com.fsd.dispatch.vo.DispatchExceptionListItemResponse; @@ -26,6 +27,7 @@ import com.fsd.dispatch.service.DispatchAdminQueryService; import com.fsd.dispatch.service.DispatchExceptionService; import com.fsd.dispatch.dto.DispatchTaskManualAssignRequest; +import com.fsd.dispatch.entity.DispatchExceptionRecordEntity; import com.fsd.dispatch.service.DispatchTaskService; import com.fsd.dispatch.vo.DispatchTaskAssignResponse; import com.fsd.dispatch.service.MobileOrderAuthService; @@ -45,6 +47,7 @@ import com.fsd.dispatch.vo.ParkStationResponse; import com.fsd.dispatch.vo.ParkVehicleSnapshotResponse; import com.fsd.order.service.OrderAdminQueryService; +import com.fsd.order.service.OrderStateService; import com.fsd.admin.service.OrderAdminDetailService; import com.fsd.admin.service.TaskAdminDetailService; import com.fsd.order.service.OrderQueryService; @@ -77,6 +80,7 @@ public class AdminDispatchController { private final OrderAdminQueryService orderAdminQueryService; private final OrderQueryService orderQueryService; + private final OrderStateService orderStateService; private final OrderAdminDetailService orderAdminDetailService; private final TaskAdminDetailService taskAdminDetailService; @@ -96,6 +100,7 @@ public class AdminDispatchController { public AdminDispatchController(OrderAdminQueryService orderAdminQueryService, OrderQueryService orderQueryService, + OrderStateService orderStateService, OrderAdminDetailService orderAdminDetailService, TaskAdminDetailService taskAdminDetailService, DispatchAdminQueryService dispatchAdminQueryService, @@ -113,6 +118,7 @@ public AdminDispatchController(OrderAdminQueryService orderAdminQueryService, CoordinateTransformService coordinateTransformService) { this.orderAdminQueryService = orderAdminQueryService; this.orderQueryService = orderQueryService; + this.orderStateService = orderStateService; this.orderAdminDetailService = orderAdminDetailService; this.taskAdminDetailService = taskAdminDetailService; this.dispatchAdminQueryService = dispatchAdminQueryService; @@ -154,6 +160,20 @@ public ApiResponse getOrderDetail(@PathVariable Long orderI return ApiResponse.success(orderAdminDetailService.getEnrichedDetail(orderId)); } + @PostMapping("/orders/{orderId}/cancel") + @Operation(summary = "Cancel order") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Order cancelled"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Not authenticated") + }) + public ApiResponse cancelOrder(@PathVariable Long orderId, + @RequestBody(required = false) AdminTaskCancelRequest body, + HttpServletRequest request) { + AdminAuthSupport.requireAuth(request); + orderStateService.markCancelled(orderId); + return ApiResponse.success(null); + } + @GetMapping("/tasks") @Operation(summary = "List all dispatch tasks") public ApiResponse> listTasks(HttpServletRequest request) { @@ -301,6 +321,20 @@ public ApiResponse batchReassign(@Valid @RequestBo return ApiResponse.success(batchTaskAdminService.batchReassign(request, operator.id(), operator.name())); } + @PostMapping("/tasks/batch/unassign") + @Operation(summary = "Batch unassign tasks") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Batch result returned"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Validation error"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Not authenticated") + }) + public ApiResponse batchUnassign(@Valid @RequestBody AdminBatchTaskRequest request, + HttpServletRequest httpRequest) { + AdminAuthSupport.requireAuth(httpRequest); + OperatorIdentity operator = resolveOperator(httpRequest); + return ApiResponse.success(batchTaskAdminService.batchUnassign(request, operator.id(), operator.name())); + } + @GetMapping("/exceptions") @Operation(summary = "List dispatch exceptions") public ApiResponse> listExceptions(HttpServletRequest request) { @@ -355,6 +389,21 @@ public ApiResponse resolveException(@PathVariable Long exceptionId, @Valid @RequestBody AdminDispatchExceptionResolveRequest request, HttpServletRequest httpRequest) { AdminAuthSupport.requireAuth(httpRequest); + DispatchExceptionRecordEntity exception = dispatchExceptionService.getException(exceptionId); + if ("RESOLVED".equals(exception.getExceptionStatus())) { + throw new BusinessException("DISPATCH_EXCEPTION_ALREADY_RESOLVED", "Dispatch exception already resolved"); + } + if ("REASSIGN".equals(request.getAction())) { + if (request.getVehicleId() == null) { + throw new BusinessException("DISPATCH_EXCEPTION_VEHICLE_REQUIRED", "Reassign action requires vehicleId"); + } + DispatchTaskManualAssignRequest assignRequest = new DispatchTaskManualAssignRequest(); + assignRequest.setVehicleId(request.getVehicleId()); + assignRequest.setOperatorId(request.getResolverId()); + assignRequest.setOperatorName(request.getResolverName()); + assignRequest.setRemark(request.getRemark()); + dispatchTaskService.reassignTask(exception.getTaskId(), assignRequest); + } dispatchExceptionService.resolveException(exceptionId, request.toDispatchRequest()); return ApiResponse.success(null); } @@ -393,17 +442,19 @@ public ApiResponse getDashboardSummary( } @GetMapping("/parks") - @Operation(summary = "List parks") + @Operation(summary = "List parks", description = "Admin token or X-Mobile-Api-Key for mobile order page") + @SecurityRequirement(name = "") public ApiResponse> listParks(HttpServletRequest request) { - AdminAuthSupport.requireAuth(request); + requireAdminOrMobileOrderKey(request); return ApiResponse.success(parkPilotService.listParks()); } @GetMapping("/park/layout") @Operation(summary = "Get park layout", description = "Map layout with stations, roads, and vehicle positions") + @SecurityRequirement(name = "") public ApiResponse getParkLayout(@RequestParam(required = false) Long parkId, HttpServletRequest request) { - AdminAuthSupport.requireAuth(request); + requireAdminOrMobileOrderKey(request); if (parkId == null) { return ApiResponse.success(parkPilotService.getLayout()); } @@ -466,10 +517,11 @@ public ApiResponse> listParkVehicles(HttpServl } @GetMapping("/park/geofences") - @Operation(summary = "List park geofences") + @Operation(summary = "List park geofences", description = "Admin token or X-Mobile-Api-Key for mobile order page") + @SecurityRequirement(name = "") public ApiResponse> listParkGeofences(@RequestParam(required = false) Long parkId, HttpServletRequest request) { - AdminAuthSupport.requireAuth(request); + requireAdminOrMobileOrderKey(request); return ApiResponse.success(parkPilotService.listGeofences(parkId)); } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminSystemHealthController.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminSystemHealthController.java index bff93d0..581daeb 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminSystemHealthController.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminSystemHealthController.java @@ -2,6 +2,8 @@ import com.fsd.admin.auth.AdminAuthSupport; import com.fsd.admin.service.SystemHealthAdminService; +import com.fsd.admin.vo.AdminDetailedMetricsResponse; +import com.fsd.admin.vo.AdminHealthTimelineResponse; import com.fsd.admin.vo.AdminSystemHealthResponse; import com.fsd.common.model.ApiResponse; import io.swagger.v3.oas.annotations.Operation; @@ -9,6 +11,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -35,4 +38,20 @@ public ApiResponse health(HttpServletRequest request) AdminAuthSupport.requireAuth(request); return ApiResponse.success(systemHealthAdminService.getHealth()); } + + @GetMapping("/health/metrics") + @Operation(summary = "System detailed metrics") + public ApiResponse metrics(HttpServletRequest request) { + AdminAuthSupport.requireAuth(request); + return ApiResponse.success(systemHealthAdminService.getDetailedMetrics()); + } + + @GetMapping("/health/timeline") + @Operation(summary = "System health timeline") + public ApiResponse timeline(HttpServletRequest request) { + AdminAuthSupport.requireAuth(request); + return ApiResponse.success(AdminHealthTimelineResponse.builder() + .items(List.of()) + .build()); + } } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/HealthController.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/HealthController.java index c02778c..7ef39d5 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/HealthController.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/controller/HealthController.java @@ -1,9 +1,13 @@ package com.fsd.admin.controller; +import com.fsd.admin.service.SystemHealthAdminService; +import com.fsd.admin.vo.AdminSystemHealthResponse; import com.fsd.common.model.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDateTime; import java.util.Map; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -13,12 +17,33 @@ @Tag(name = "Health", description = "Public liveness probe") public class HealthController { - @GetMapping + private final SystemHealthAdminService systemHealthAdminService; + + public HealthController(SystemHealthAdminService systemHealthAdminService) { + this.systemHealthAdminService = systemHealthAdminService; + } + + @GetMapping({"", "/live"}) public ApiResponse> health() { return ApiResponse.success(Map.of( "service", "fsd-core-server", "status", "UP", + "probe", "liveness", "timestamp", LocalDateTime.now().toString() )); } + + @GetMapping("/ready") + public ResponseEntity>> readiness() { + AdminSystemHealthResponse health = systemHealthAdminService.getHealth(); + Map payload = Map.of( + "service", "fsd-core-server", + "status", health.getOverallStatus(), + "probe", "readiness", + "checkedAt", health.getCheckedAt().toString(), + "components", health.getComponents() + ); + HttpStatus httpStatus = "DOWN".equals(health.getOverallStatus()) ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK; + return ResponseEntity.status(httpStatus).body(ApiResponse.success(payload)); + } } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/dto/AdminDispatchExceptionResolveRequest.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/dto/AdminDispatchExceptionResolveRequest.java index ac0cb8a..1bfe645 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/dto/AdminDispatchExceptionResolveRequest.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/dto/AdminDispatchExceptionResolveRequest.java @@ -18,12 +18,15 @@ public class AdminDispatchExceptionResolveRequest { private String remark; + private Long vehicleId; + public DispatchExceptionResolveRequest toDispatchRequest() { DispatchExceptionResolveRequest request = new DispatchExceptionResolveRequest(); request.setResolverId(resolverId); request.setResolverName(resolverName); request.setAction(action); request.setRemark(remark); + request.setVehicleId(vehicleId); return request; } } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/metrics/ApiRequestMetrics.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/metrics/ApiRequestMetrics.java index 3a4ff63..e282434 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/metrics/ApiRequestMetrics.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/metrics/ApiRequestMetrics.java @@ -1,5 +1,6 @@ package com.fsd.admin.metrics; +import java.util.Arrays; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.LongAdder; import org.springframework.stereotype.Component; @@ -50,4 +51,14 @@ public synchronized long getRecentAverageDurationMs() { } return sum / sampleSize; } + + public synchronized long getRecentPercentileDurationMs(double percentile) { + if (sampleSize == 0) { + return 0; + } + long[] samples = Arrays.copyOf(recentSamples, sampleSize); + Arrays.sort(samples); + int index = (int) Math.ceil(percentile * sampleSize) - 1; + return samples[Math.max(0, Math.min(index, sampleSize - 1))]; + } } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/AdminDispatchStreamService.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/AdminDispatchStreamService.java index b06e4dd..e02f7c5 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/AdminDispatchStreamService.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/AdminDispatchStreamService.java @@ -9,4 +9,6 @@ public interface AdminDispatchStreamService { void broadcast(String eventName, Object payload); boolean hasClients(); + + int getActiveConnectionCount(); } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/SystemHealthAdminService.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/SystemHealthAdminService.java index a8eb1d1..529ba68 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/SystemHealthAdminService.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/SystemHealthAdminService.java @@ -1,8 +1,11 @@ package com.fsd.admin.service; +import com.fsd.admin.vo.AdminDetailedMetricsResponse; import com.fsd.admin.vo.AdminSystemHealthResponse; public interface SystemHealthAdminService { AdminSystemHealthResponse getHealth(); + + AdminDetailedMetricsResponse getDetailedMetrics(); } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/AdminDispatchStreamServiceImpl.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/AdminDispatchStreamServiceImpl.java index 1a7cbdd..63cac12 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/AdminDispatchStreamServiceImpl.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/AdminDispatchStreamServiceImpl.java @@ -52,6 +52,11 @@ public boolean hasClients() { return !emitters.isEmpty(); } + @Override + public int getActiveConnectionCount() { + return emitters.size(); + } + private void removeEmitter(SseEmitter emitter) { emitters.remove(emitter); } diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/SystemHealthAdminServiceImpl.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/SystemHealthAdminServiceImpl.java index 1a1646b..00c5a2c 100644 --- a/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/SystemHealthAdminServiceImpl.java +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/SystemHealthAdminServiceImpl.java @@ -2,7 +2,9 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fsd.admin.metrics.ApiRequestMetrics; +import com.fsd.admin.service.AdminDispatchStreamService; import com.fsd.admin.service.SystemHealthAdminService; +import com.fsd.admin.vo.AdminDetailedMetricsResponse; import com.fsd.admin.vo.AdminSystemComponentHealth; import com.fsd.admin.vo.AdminSystemHealthResponse; import com.fsd.dispatch.config.DispatchMessagingConfig; @@ -22,7 +24,9 @@ import org.springframework.amqp.rabbit.core.RabbitAdmin; import org.springframework.beans.factory.ObjectProvider; import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisServerCommands; import org.springframework.stereotype.Service; +import com.zaxxer.hikari.HikariDataSource; @Service public class SystemHealthAdminServiceImpl implements SystemHealthAdminService { @@ -33,19 +37,22 @@ public class SystemHealthAdminServiceImpl implements SystemHealthAdminService { private final ObjectProvider rabbitAdminProvider; private final DispatchEventOutboxMapper outboxMapper; private final ApiRequestMetrics apiRequestMetrics; + private final AdminDispatchStreamService dispatchStreamService; public SystemHealthAdminServiceImpl(DataSource dataSource, ObjectProvider redisConnectionFactoryProvider, ObjectProvider rabbitConnectionFactoryProvider, ObjectProvider rabbitAdminProvider, DispatchEventOutboxMapper outboxMapper, - ApiRequestMetrics apiRequestMetrics) { + ApiRequestMetrics apiRequestMetrics, + AdminDispatchStreamService dispatchStreamService) { this.dataSource = dataSource; this.redisConnectionFactoryProvider = redisConnectionFactoryProvider; this.rabbitConnectionFactoryProvider = rabbitConnectionFactoryProvider; this.rabbitAdminProvider = rabbitAdminProvider; this.outboxMapper = outboxMapper; this.apiRequestMetrics = apiRequestMetrics; + this.dispatchStreamService = dispatchStreamService; } @Override @@ -69,6 +76,115 @@ public AdminSystemHealthResponse getHealth() { .build(); } + @Override + public AdminDetailedMetricsResponse getDetailedMetrics() { + return AdminDetailedMetricsResponse.builder() + .mqBacklogs(getMqBacklogs()) + .dbConnectionPool(getDbConnectionPool()) + .redisMemory(getRedisMemory()) + .sseConnections(AdminDetailedMetricsResponse.SseConnection.builder() + .activeConnections(dispatchStreamService.getActiveConnectionCount()) + .status("OK") + .build()) + .apiP99Latency(getApiLatency()) + .build(); + } + + private List getMqBacklogs() { + RabbitAdmin rabbitAdmin = rabbitAdminProvider.getIfAvailable(); + if (rabbitAdmin == null) { + return List.of(AdminDetailedMetricsResponse.MqQueueBacklog.builder() + .queueName("RabbitMQ 未接入后端") + .backlog(0) + .status("WARNING") + .build()); + } + return List.of( + queueBacklog(rabbitAdmin, DispatchMessagingConfig.DISPATCH_AUDIT_QUEUE), + queueBacklog(rabbitAdmin, DispatchMessagingConfig.DISPATCH_STREAM_QUEUE), + queueBacklog(rabbitAdmin, DispatchMessagingConfig.DISPATCH_WEBHOOK_QUEUE)); + } + + private AdminDetailedMetricsResponse.MqQueueBacklog queueBacklog(RabbitAdmin rabbitAdmin, String queueName) { + int backlog = queueDepth(rabbitAdmin, queueName); + return AdminDetailedMetricsResponse.MqQueueBacklog.builder() + .queueName(queueName) + .backlog(backlog) + .status(backlog > 500 ? "CRITICAL" : backlog > 100 ? "WARNING" : "OK") + .build(); + } + + private AdminDetailedMetricsResponse.DbConnectionPool getDbConnectionPool() { + if (dataSource instanceof HikariDataSource hikariDataSource && hikariDataSource.getHikariPoolMXBean() != null) { + var pool = hikariDataSource.getHikariPoolMXBean(); + int active = pool.getActiveConnections(); + int idle = pool.getIdleConnections(); + int max = hikariDataSource.getMaximumPoolSize(); + double usagePercent = max <= 0 ? 0D : active * 100D / max; + return AdminDetailedMetricsResponse.DbConnectionPool.builder() + .active(active) + .idle(idle) + .max(max) + .usagePercent(usagePercent) + .status(usagePercent > 90 ? "CRITICAL" : usagePercent > 75 ? "WARNING" : "OK") + .build(); + } + return AdminDetailedMetricsResponse.DbConnectionPool.builder() + .active(0) + .idle(0) + .max(0) + .usagePercent(0D) + .status("WARNING") + .build(); + } + + private AdminDetailedMetricsResponse.RedisMemory getRedisMemory() { + RedisConnectionFactory factory = redisConnectionFactoryProvider.getIfAvailable(); + if (factory == null) { + return redisMemoryNotAvailable(); + } + try (var connection = factory.getConnection()) { + Properties memory = connection.serverCommands().info("memory"); + long used = Long.parseLong(memory.getProperty("used_memory", "0")); + long max = Long.parseLong(memory.getProperty("maxmemory", "0")); + double usagePercent = max <= 0 ? 0D : used * 100D / max; + return AdminDetailedMetricsResponse.RedisMemory.builder() + .usedBytes(used) + .maxBytes(max) + .usagePercent(usagePercent) + .status(max <= 0 ? "WARNING" : usagePercent > 90 ? "CRITICAL" : usagePercent > 75 ? "WARNING" : "OK") + .build(); + } catch (Exception ex) { + return redisMemoryNotAvailable(); + } + } + + private AdminDetailedMetricsResponse.RedisMemory redisMemoryNotAvailable() { + return AdminDetailedMetricsResponse.RedisMemory.builder() + .usedBytes(0L) + .maxBytes(0L) + .usagePercent(0D) + .status("WARNING") + .build(); + } + + private AdminDetailedMetricsResponse.ApiP99Latency getApiLatency() { + long p50 = apiRequestMetrics.getRecentPercentileDurationMs(0.50D); + long p95 = apiRequestMetrics.getRecentPercentileDurationMs(0.95D); + long p99 = apiRequestMetrics.getRecentPercentileDurationMs(0.99D); + return AdminDetailedMetricsResponse.ApiP99Latency.builder() + .currentMs(apiRequestMetrics.getRecentAverageDurationMs()) + .p50Ms(p50) + .p95Ms(p95) + .p99Ms(p99) + .status(p99 > 2000 ? "CRITICAL" : p99 > 1000 ? "WARNING" : "OK") + .history(List.of(AdminDetailedMetricsResponse.ApiLatencyHistoryPoint.builder() + .time(LocalDateTime.now().toString()) + .value(p99) + .build())) + .build(); + } + private AdminSystemComponentHealth checkDatabase() { try (var connection = dataSource.getConnection(); var statement = connection.createStatement(); diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminDetailedMetricsResponse.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminDetailedMetricsResponse.java new file mode 100644 index 0000000..3348b68 --- /dev/null +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminDetailedMetricsResponse.java @@ -0,0 +1,72 @@ +package com.fsd.admin.vo; + +import java.util.List; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AdminDetailedMetricsResponse { + + private List mqBacklogs; + + private DbConnectionPool dbConnectionPool; + + private RedisMemory redisMemory; + + private SseConnection sseConnections; + + private ApiP99Latency apiP99Latency; + + @Data + @Builder + public static class MqQueueBacklog { + private String queueName; + private Integer backlog; + private String status; + } + + @Data + @Builder + public static class DbConnectionPool { + private Integer active; + private Integer idle; + private Integer max; + private Double usagePercent; + private String status; + } + + @Data + @Builder + public static class RedisMemory { + private Long usedBytes; + private Long maxBytes; + private Double usagePercent; + private String status; + } + + @Data + @Builder + public static class SseConnection { + private Integer activeConnections; + private String status; + } + + @Data + @Builder + public static class ApiP99Latency { + private Long currentMs; + private Long p50Ms; + private Long p95Ms; + private Long p99Ms; + private String status; + private List history; + } + + @Data + @Builder + public static class ApiLatencyHistoryPoint { + private String time; + private Long value; + } +} diff --git a/back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminHealthTimelineResponse.java b/back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminHealthTimelineResponse.java new file mode 100644 index 0000000..76bd3cf --- /dev/null +++ b/back/fsd-admin-api/src/main/java/com/fsd/admin/vo/AdminHealthTimelineResponse.java @@ -0,0 +1,21 @@ +package com.fsd.admin.vo; + +import java.util.List; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class AdminHealthTimelineResponse { + + private List items; + + @Data + @Builder + public static class AdminHealthTimelineItemResponse { + private String time; + private String component; + private String status; + private String message; + } +} diff --git a/back/fsd-dispatch/src/main/java/com/fsd/dispatch/dto/DispatchExceptionResolveRequest.java b/back/fsd-dispatch/src/main/java/com/fsd/dispatch/dto/DispatchExceptionResolveRequest.java index fe7e93c..bea5456 100644 --- a/back/fsd-dispatch/src/main/java/com/fsd/dispatch/dto/DispatchExceptionResolveRequest.java +++ b/back/fsd-dispatch/src/main/java/com/fsd/dispatch/dto/DispatchExceptionResolveRequest.java @@ -16,4 +16,6 @@ public class DispatchExceptionResolveRequest { private String action; private String remark; + + private Long vehicleId; } diff --git a/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/DispatchExceptionService.java b/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/DispatchExceptionService.java index 7d0ffe4..a15d262 100644 --- a/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/DispatchExceptionService.java +++ b/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/DispatchExceptionService.java @@ -13,6 +13,8 @@ public interface DispatchExceptionService { void resolveException(Long exceptionId, DispatchExceptionResolveRequest request); + DispatchExceptionRecordEntity getException(Long exceptionId); + void resolveOpenExceptionsForTask(Long taskId, String resolverId, String remark); java.util.List listOpenExceptions(); diff --git a/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/impl/DispatchExceptionServiceImpl.java b/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/impl/DispatchExceptionServiceImpl.java index ac45d19..89ede24 100644 --- a/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/impl/DispatchExceptionServiceImpl.java +++ b/back/fsd-dispatch/src/main/java/com/fsd/dispatch/service/impl/DispatchExceptionServiceImpl.java @@ -4,6 +4,7 @@ import com.fsd.common.enums.ExceptionSeverity; import com.fsd.common.exception.BusinessException; import com.fsd.dispatch.dto.DispatchExceptionResolveRequest; +import com.fsd.dispatch.dto.DispatchTaskManualAssignRequest; import com.fsd.dispatch.entity.DispatchExceptionRecordEntity; import com.fsd.dispatch.event.DispatchEventPublisher; import com.fsd.dispatch.event.DispatchEventType; @@ -49,7 +50,12 @@ public void recordException(Long taskId, Long orderId, Long vehicleId, String ex if (ExceptionSeverity.INFO.name().equals(severity)) { return; } - if (hasOpenException(taskId, exceptionType)) { + DispatchExceptionRecordEntity existing = findOpenException(taskId, null, exceptionType); + if (existing != null) { + existing.setAggCount(existing.getAggCount() == null ? 2 : existing.getAggCount() + 1); + existing.setOccurTime(LocalDateTime.now()); + existing.setExceptionMsg(exceptionMsg); + exceptionRecordMapper.updateById(existing); return; } DispatchExceptionRecordEntity entity = new DispatchExceptionRecordEntity(); @@ -61,6 +67,7 @@ public void recordException(Long taskId, Long orderId, Long vehicleId, String ex entity.setExceptionMsg(exceptionMsg); entity.setSeverity(severity); entity.setOccurTime(LocalDateTime.now()); + entity.setAggCount(1); exceptionRecordMapper.insert(entity); eventPublisher.publish(DispatchEventType.EXCEPTION_OPEN, String.valueOf(entity.getId()), buildPayload(entity)); } @@ -68,7 +75,12 @@ public void recordException(Long taskId, Long orderId, Long vehicleId, String ex @Override @Transactional public void recordVehicleException(Long vehicleId, String exceptionType, String exceptionMsg) { - if (hasOpenVehicleException(vehicleId, exceptionType)) { + DispatchExceptionRecordEntity existing = findOpenException(null, vehicleId, exceptionType); + if (existing != null) { + existing.setAggCount(existing.getAggCount() == null ? 2 : existing.getAggCount() + 1); + existing.setOccurTime(LocalDateTime.now()); + existing.setExceptionMsg(exceptionMsg); + exceptionRecordMapper.updateById(existing); return; } DispatchExceptionRecordEntity entity = new DispatchExceptionRecordEntity(); @@ -78,6 +90,7 @@ public void recordVehicleException(Long vehicleId, String exceptionType, String entity.setExceptionMsg(exceptionMsg); entity.setSeverity(ExceptionSeverity.WARN.name()); entity.setOccurTime(LocalDateTime.now()); + entity.setAggCount(1); exceptionRecordMapper.insert(entity); eventPublisher.publish(DispatchEventType.EXCEPTION_OPEN, String.valueOf(entity.getId()), buildPayload(entity)); } @@ -96,6 +109,15 @@ public void resolveException(Long exceptionId, DispatchExceptionResolveRequest r markResolved(entity, request.getResolverId(), request.getAction(), request.getRemark()); } + @Override + public DispatchExceptionRecordEntity getException(Long exceptionId) { + DispatchExceptionRecordEntity entity = exceptionRecordMapper.selectById(exceptionId); + if (entity == null) { + throw new BusinessException("DISPATCH_EXCEPTION_NOT_FOUND", "Dispatch exception not found"); + } + return entity; + } + @Override @Transactional public void resolveOpenExceptionsForTask(Long taskId, String resolverId, String remark) { @@ -143,26 +165,17 @@ private void validateAction(String action) { } } - private boolean hasOpenException(Long taskId, String exceptionType) { - if (taskId == null || exceptionType == null) { - return false; + private DispatchExceptionRecordEntity findOpenException(Long taskId, Long vehicleId, String exceptionType) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(DispatchExceptionRecordEntity::getExceptionStatus, "OPEN") + .eq(DispatchExceptionRecordEntity::getExceptionType, exceptionType); + if (taskId != null) { + wrapper.eq(DispatchExceptionRecordEntity::getTaskId, taskId); } - Long count = exceptionRecordMapper.selectCount(new LambdaQueryWrapper() - .eq(DispatchExceptionRecordEntity::getTaskId, taskId) - .eq(DispatchExceptionRecordEntity::getExceptionType, exceptionType) - .eq(DispatchExceptionRecordEntity::getExceptionStatus, "OPEN")); - return count != null && count > 0; - } - - private boolean hasOpenVehicleException(Long vehicleId, String exceptionType) { - if (vehicleId == null || exceptionType == null) { - return false; + if (vehicleId != null) { + wrapper.eq(DispatchExceptionRecordEntity::getVehicleId, vehicleId); } - Long count = exceptionRecordMapper.selectCount(new LambdaQueryWrapper() - .eq(DispatchExceptionRecordEntity::getVehicleId, vehicleId) - .eq(DispatchExceptionRecordEntity::getExceptionType, exceptionType) - .eq(DispatchExceptionRecordEntity::getExceptionStatus, "OPEN")); - return count != null && count > 0; + return exceptionRecordMapper.selectOne(wrapper); } private Map buildPayload(DispatchExceptionRecordEntity entity) { @@ -175,6 +188,7 @@ private Map buildPayload(DispatchExceptionRecordEntity entity) { payload.put("exceptionStatus", entity.getExceptionStatus()); payload.put("exceptionMsg", entity.getExceptionMsg()); payload.put("severity", entity.getSeverity()); + payload.put("aggCount", entity.getAggCount()); payload.put("resolveAction", entity.getResolveAction()); payload.put("occurTime", entity.getOccurTime()); payload.put("resolvedTime", entity.getResolvedTime()); diff --git a/docs/ROADMAP-V7.md b/docs/ROADMAP-V7.md new file mode 100644 index 0000000..b01937e --- /dev/null +++ b/docs/ROADMAP-V7.md @@ -0,0 +1,242 @@ +# DispatchFlow V7 产品路线图 + +> **继承**:[ROADMAP-V6.md](./ROADMAP-V6.md)(V6 链路可靠性专项) +> **审计来源**:用户视角源码审视(2026-06-08) +> **证据范围**:前端 `front/src/views`、`front/src/api`、`front/scripts/e2e`;后端 `back/fsd-admin-api`、`back/fsd-dispatch`、`back/fsd-bootstrap`;CI 配置 `.github/workflows/ci.yml` +> **最后更新**:2026-06-08 + +--- + +## 〇、V7 交付焦点 + +V6 已把公开下单、mock/fallback、批量撤销、通知跳转、移动端追踪提示、控制台错误和 E2E 门禁纳入修复闭环。V7 聚焦 **真实操作闭环、运营可信度、运行可靠性、质量门禁硬化**。本路线图只纳入源码可直接证明的问题,不纳入未验证推断。 + +| # | 优先级 | 问题 | 用户影响 | 来源 | +|---|--------|------|----------|------| +| 1 | **P0** | 任务列表派单、改派、取消仅提示成功,没有调用已有任务 API | 调度员以为操作已生效,实际任务状态未改变 | 源码审计 | +| 2 | **P0** | 任务列表派单、改派车辆选项硬编码 | 调度员无法选择真实在线空闲车辆 | 源码审计 | +| 3 | **P0** | 订单列表取消订单仅提示成功,前端订单 API 未提供取消方法 | 运营人员以为订单已取消,实际订单状态未改变 | 源码审计 | +| 4 | **P0** | 异常处理“重新派单”未提交用户选择的车辆 | 值班人员选择车辆后,后端无法按所选车辆处理 | 源码审计 | +| 5 | **P1** | 系统健康详细指标返回硬编码正常值 | 值班人员会误判 DB、Redis、SSE、API 延迟状态 | 源码审计 | +| 6 | **P1** | 公开健康接口固定返回 `UP` | 部署和监控无法区分应用存活与依赖可用 | 源码审计 | +| 7 | **P1** | 系统健康页扩展指标加载没有隔离失败 | 单个扩展接口失败会影响整体健康页加载 | 源码审计 | +| 8 | **P1** | SSE 连接无超时、无容量上限、无分区隔离 | 长连接泄漏或集中广播会影响监控稳定性 | 源码审计 | +| 9 | **P1** | 管理 token 可通过 URL query 传递 | 导出、SSE 链路增加 URL 日志泄露面 | 源码审计 | +| 10 | **P1** | Webhook 投递同步串行发送 | 单个订阅慢响应会拖慢后续订阅投递 | 源码审计 | +| 11 | **P2** | Redis 派单锁 TTL 固定 10 秒 | 派单耗时异常时缺少配置与观测空间 | 源码审计 | +| 12 | **P2** | 后端质量 profile 未进入 CI,Checkstyle/SpotBugs 不阻断 | 技术债可持续进入主分支 | 源码审计 | +| 13 | **P2** | 登录页公开展示默认账号密码 | 生产环境降低安全信任感 | 源码审计 | + +### V7 聚焦方向 + +> **从“链路可达”到“操作真实、状态可信、运行可控”** — 先消除用户误导,再补齐健康与可靠性,最后硬化质量门禁。 + +--- + +## 一、V6 继承说明 + +| 项 | 说明 | +|----|------| +| **继承对象** | [`ROADMAP-V6.md`](./ROADMAP-V6.md) | +| **V6 重点** | 链路修复、体验修复、E2E 门禁 | +| **V7 重点** | 操作闭环、健康可信、可靠性治理、质量门禁硬化 | +| **原则** | 不使用 mock 成功,不展示硬编码健康,不让质量检查只产生日志 | + +--- + +## 二、V7-A — 真实操作闭环(P0) + +> **用户痛点**:按钮点击后出现成功提示,但业务状态没有被后端改变。 +> **目标**:所有写操作必须调用真实 API;成功提示必须来自接口成功响应;失败时保留当前状态并展示失败原因。 + +### 2.1 待办 + +- [x] **V7-A1** 任务列表派单接入真实 API + - 问题证据:`front/src/views/task/List.vue:L322-L331` 只显示 `派单成功` 并刷新;`front/src/api/task.ts:L40-L42` 已存在 `manualAssignTask` + - 前端:`handleDispatch` 调用 `manualAssignTask(currentTask.taskId, { vehicleId, remark })` + - 验收:点击任务列表“派单”后,Network 出现 `/api/admin/tasks/{taskId}/manual-assign`;接口失败时不显示成功提示 + +- [x] **V7-A2** 任务列表改派接入真实 API + - 问题证据:`front/src/views/task/List.vue:L350-L363` 只显示 `改派成功` 并刷新;`front/src/api/task.ts:L48-L50` 已存在 `reassignTask` + - 前端:`handleReassign` 调用 `reassignTask(currentTask.taskId, { vehicleId: newVehicleId, remark: reason })` + - 验收:点击任务列表“改派”后,Network 出现 `/api/admin/tasks/{taskId}/reassign`;接口失败时不关闭弹窗 + +- [ ] **V7-A3** 任务列表取消接入真实 API + - 问题证据:`front/src/views/task/List.vue:L371-L373` 只显示 `任务已取消` 并刷新;`front/src/api/task.ts:L44-L46` 已存在 `cancelTask` + - 前端:`handleCancel` 调用 `cancelTask(record.taskId, remark)` + - 验收:点击任务列表“取消”后,Network 出现 `/api/admin/tasks/{taskId}/cancel`;接口失败时不显示成功提示 + +- [ ] **V7-A4** 任务派单与改派车辆选项改为真实可派车辆 + - 问题证据:`front/src/views/task/List.vue:L141-L142`、`front/src/views/task/List.vue:L174` 使用硬编码车辆选项 + - 前端:复用或抽取工作台已有可派车辆来源,禁止继续显示固定 `VH-001`、`VH-002` + - 验收:车辆选项来自后端或已加载车辆状态;无可派车辆时显示空状态并禁止提交 + +- [x] **V7-A5** 订单列表取消接入真实订单取消能力 + - 问题证据:`front/src/views/order/List.vue:L212-L214` 只显示 `订单已取消` 并刷新;`front/src/api/order.ts:L5-L15` 当前仅包含列表、查询、详情 + - 前端:新增订单取消 API 封装并在 `handleCancel` 中调用 + - 后端:如现有后端无对应接口,补充订单取消接口并保持状态机约束 + - 验收:点击订单列表“取消”后,Network 出现订单取消请求;接口失败时不显示成功提示 + +- [x] **V7-A6** 异常处理重新派单提交所选车辆 + - 问题证据:`front/src/views/exception/Index.vue:L155-L162` 允许选择 `vehicleId`;`front/src/views/exception/Index.vue:L320-L325` 提交 payload 未包含 `vehicleId` + - 前端:`REASSIGN` 动作提交 `vehicleId`,并对未选车辆阻断提交 + - 类型:补齐异常处理请求类型字段,禁止使用未声明字段绕过类型检查 + - 验收:选择“重新派单”并提交后,请求体包含用户选择的 `vehicleId` + +--- + +## 三、V7-H — 健康可信与降级展示(P1) + +> **用户痛点**:系统健康页和健康探针存在“看起来正常”的固定值。 +> **目标**:健康数据必须来自真实依赖或明确显示未接入;页面局部失败不拖垮整体健康页。 + +### 3.1 待办 + +- [ ] **V7-H1** 系统健康详细指标接入真实数据 + - 问题证据:`back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminSystemHealthController.java:L46-L56` 返回 DB、Redis、SSE、API 延迟固定 `0` 和 `OK` + - 后端:接入连接池、Redis 内存、SSE 连接数、API 延迟统计 + - 前端:当后端返回未接入状态时显示“未接入后端”,不显示正常值 + - 验收:断开 Redis 或 DB 时,系统健康页对应组件不显示 `OK` + +- [ ] **V7-H2** 公开健康接口拆分 liveness/readiness + - 问题证据:`back/fsd-admin-api/src/main/java/com/fsd/admin/controller/HealthController.java:L16-L22` 固定返回 `status=UP` + - 后端:保留轻量 liveness;新增或改造 readiness 检查 MySQL、Redis、RabbitMQ + - 运维:部署探针使用 readiness 判断依赖可用性 + - 验收:依赖不可用时 readiness 返回非健康状态 + +- [x] **V7-H3** 系统健康页扩展接口失败隔离 + - 问题证据:`front/src/views/system/SystemHealth.vue:L313-L320` 使用 `Promise.all` 同时加载健康、指标、时间线 + - 前端:核心健康、详细指标、时间线分别加载和降级 + - 验收:`/api/admin/system/health/metrics` 返回 404/500 时,核心健康信息仍可展示 + +--- + +## 四、V7-R — 运行可靠性治理(P1/P2) + +> **用户痛点**:长连接、Webhook、派单锁、鉴权 token 的运行边界不清晰。 +> **目标**:关键运行链路可配置、可观测、可隔离,减少单点慢请求和泄露面。 + +### 4.1 待办 + +- [ ] **V7-R1** SSE 连接治理 + - 问题证据:`back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/AdminDispatchStreamServiceImpl.java:L17-L30` 使用 `DEFAULT_TIMEOUT = 0L` 和全局 `CopyOnWriteArrayList` + - 后端:配置连接超时、最大连接数、连接指标;按用户或园区维度隔离广播范围 + - 验收:超过连接上限时返回明确错误;系统健康指标可看到当前 SSE 连接数 + +- [ ] **V7-R2** URL query token 治理 + - 问题证据:`back/fsd-bootstrap/src/main/java/com/fsd/bootstrap/config/AdminAuthInterceptor.java:L100-L105` 从 query 读取 `token`;`front/src/views/analytics/Index.vue:L286-L297` 导出时拼接 token + - 后端:保留兼容前必须明确迁移方案;新增短期一次性导出凭证或服务端下载代理 + - 前端:导出不再把长期管理 token 写入 URL + - 验收:浏览器地址、Network URL、后端访问日志不出现长期管理 token + +- [ ] **V7-R3** 登录失效状态码统一 + - 问题证据:`back/fsd-bootstrap/src/main/java/com/fsd/bootstrap/config/AdminAuthInterceptor.java:L47-L52` 抛出 `ADMIN_AUTH_FAILED`;`back/fsd-bootstrap/src/main/java/com/fsd/bootstrap/config/GlobalExceptionHandler.java:L65-L69` 未映射为 401 + - 后端:将 `ADMIN_AUTH_FAILED` 映射为 `401 Unauthorized` + - 前端:统一触发重新登录和会话清理 + - 验收:无效 token 访问管理接口返回 401,不返回 400 + +- [ ] **V7-R4** Webhook 异步投递与重试治理 + - 问题证据:`back/fsd-dispatch/src/main/java/com/fsd/dispatch/integration/WebhookDeliveryService.java:L46-L86` 在订阅循环内同步发送 + - 后端:改为队列化投递、失败退避、投递耗时指标、失败熔断 + - 验收:单个订阅 5 秒超时不阻塞其他订阅投递 + +- [ ] **V7-R5** 派单 Redis 锁 TTL 配置化与观测 + - 问题证据:`back/fsd-dispatch/src/main/java/com/fsd/dispatch/infra/impl/RedisDispatchLockService.java:L15-L35` 固定 `LOCK_TTL = 10s` + - 后端:将 TTL 放入配置;记录获取锁失败次数和锁持有耗时 + - 验收:配置文件可调整派单锁 TTL;锁冲突可在日志或指标中定位 + +--- + +## 五、V7-Q — 质量门禁硬化(P2) + +> **用户痛点**:真实用户流程和后端质量检查仍存在漏网空间。 +> **目标**:P0 操作闭环进入 E2E;后端质量检查进入 CI 并具备阻断能力。 + +### 5.1 待办 + +- [ ] **V7-Q1** P0 操作闭环 E2E 覆盖 + - 覆盖范围:任务派单、任务改派、任务取消、订单取消、异常重新派单 + - 要求:不能只直接 `fetch` 接口;必须从页面按钮触发用户路径 + - 验收:`npm run test:e2e` 覆盖上述路径并在 CI 执行 + +- [ ] **V7-Q2** 后端 quality profile 接入 CI + - 问题证据:`.github/workflows/ci.yml:L23-L25` 只运行 `mvn -pl fsd-bootstrap -am test -B` + - CI:增加 `mvn -pl fsd-bootstrap -am verify -Pquality -B` + - 验收:CI 日志出现 Checkstyle 与 SpotBugs 执行结果 + +- [ ] **V7-Q3** 关键质量检查改为阻断 + - 问题证据:`back/pom.xml:L133-L144` 配置 `failOnViolation=false` 和 `failOnError=false` + - 后端:逐步将新增问题设为阻断;历史问题先记录基线,不让新增问题进入主分支 + - 验收:新增高风险 SpotBugs 或 Checkstyle 违规会导致 CI 失败 + +- [ ] **V7-Q4** 移除生产登录默认账号提示 + - 问题证据:`front/src/views/auth/Login.vue:L74-L76` 显示 `默认账号:admin / admin123` + - 前端:仅开发环境展示默认账号提示,生产构建不展示 + - 验收:生产环境登录页不出现默认账号密码 + +--- + +## 六、建议实施顺序 + +```text +Phase 1(P0 操作闭环): + V7-A1 → V7-A2 → V7-A3 → V7-A4 → V7-A5 → V7-A6 + +Phase 2(P1 健康可信): + V7-H1 → V7-H2 → V7-H3 + +Phase 3(P1/P2 可靠性治理): + V7-R1 → V7-R2 → V7-R3 → V7-R4 → V7-R5 + +Phase 4(P2 门禁硬化): + V7-Q1 → V7-Q2 → V7-Q3 → V7-Q4 +``` + +--- + +## 七、代码锚点 + +| 主题 | 路径 | +|------|------| +| 任务派单/改派/取消 | `front/src/views/task/List.vue` · `front/src/api/task.ts` | +| 订单取消 | `front/src/views/order/List.vue` · `front/src/api/order.ts` | +| 异常重新派单 | `front/src/views/exception/Index.vue` · `front/src/types/exception.d.ts` | +| 系统健康详细指标 | `back/fsd-admin-api/src/main/java/com/fsd/admin/controller/AdminSystemHealthController.java` | +| 公开健康接口 | `back/fsd-admin-api/src/main/java/com/fsd/admin/controller/HealthController.java` | +| 健康页加载 | `front/src/views/system/SystemHealth.vue` | +| SSE 连接治理 | `back/fsd-admin-api/src/main/java/com/fsd/admin/service/impl/AdminDispatchStreamServiceImpl.java` | +| URL token | `back/fsd-bootstrap/src/main/java/com/fsd/bootstrap/config/AdminAuthInterceptor.java` · `front/src/views/analytics/Index.vue` | +| 登录失效状态码 | `back/fsd-bootstrap/src/main/java/com/fsd/bootstrap/config/GlobalExceptionHandler.java` | +| Webhook 投递 | `back/fsd-dispatch/src/main/java/com/fsd/dispatch/integration/WebhookDeliveryService.java` | +| 派单 Redis 锁 | `back/fsd-dispatch/src/main/java/com/fsd/dispatch/infra/impl/RedisDispatchLockService.java` | +| CI 门禁 | `.github/workflows/ci.yml` · `back/pom.xml` | +| 登录默认账号提示 | `front/src/views/auth/Login.vue` | + +--- + +## 八、Release 判定(草案) + +| 版本 | 条件 | +|------|------| +| **v6.1.0** | V7-A1 至 V7-A6(P0 操作闭环) | +| **v6.2.0** | + V7-H1 至 V7-H3(健康可信) | +| **v6.3.0** | + V7-R1 至 V7-R5(可靠性治理) | +| **v7.0.0** | + V7-Q1 至 V7-Q4(质量门禁硬化) | + +--- + +## 九、验收证据清单 + +| 验收项 | 必须提供的证据 | +|--------|----------------| +| 任务派单/改派/取消 | 浏览器 Network 请求、接口响应、列表刷新后的状态 | +| 订单取消 | 浏览器 Network 请求、接口响应、订单详情状态 | +| 异常重新派单 | 请求体中的 `vehicleId`、异常处理结果、任务状态 | +| 系统健康 | `/api/health`、`/api/admin/system/health`、`/api/admin/system/health/metrics` 响应样本 | +| SSE 治理 | 连接数指标、超限响应、断连回收日志 | +| Webhook 投递 | 投递日志、失败重试记录、慢订阅隔离结果 | +| CI 门禁 | GitHub Actions 日志、Checkstyle/SpotBugs 执行结果、E2E 报告 | + +--- + +**维护**:V7 开始后,当前路线图应以 [`ROADMAP-V7.md`](./ROADMAP-V7.md) 为准。PR 前缀:`[V7-Action]`、`[V7-Health]`、`[V7-Reliability]`、`[V7-Quality]`。 diff --git a/front/src/api/order.ts b/front/src/api/order.ts index 3821c54..682a1cf 100644 --- a/front/src/api/order.ts +++ b/front/src/api/order.ts @@ -13,3 +13,7 @@ export function queryOrders(data: OrderQueryRequest) { export function getOrderDetail(orderId: number) { return request.get>(`/admin/orders/${orderId}`) } + +export function cancelOrder(orderId: number, remark?: string) { + return request.post>(`/admin/orders/${orderId}/cancel`, { remark }) +} diff --git a/front/src/types/exception.d.ts b/front/src/types/exception.d.ts index c0616ef..524c0e3 100644 --- a/front/src/types/exception.d.ts +++ b/front/src/types/exception.d.ts @@ -29,6 +29,7 @@ export interface ExceptionAdminListItem { resolvedTime: string | null resolverId: string | null resolveRemark: string | null + aggCount?: number createdAt: string updatedAt: string } @@ -38,4 +39,5 @@ export interface ResolveExceptionRequest { resolverName: string action: string remark: string + vehicleId?: number } diff --git a/front/src/views/exception/Index.vue b/front/src/views/exception/Index.vue index 2164eaf..aacc759 100644 --- a/front/src/views/exception/Index.vue +++ b/front/src/views/exception/Index.vue @@ -4,6 +4,9 @@ 刷新 + + 导出 +