Просмотр исходного кода

feat(cockpit): add read-only foundation API (#18)

Kazerin 2 дней назад
Родитель
Сommit
06496ecb20
17 измененных файлов с 760 добавлено и 1 удалено
  1. 259 0
      docs/cockpit/foundation-demo.html
  2. 47 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitFoundationController.java
  3. 40 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitAuthenticationEntryPointTest.java
  4. 107 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFoundationControllerTest.java
  5. 24 1
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/SecurityConfig.java
  6. 52 0
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/security/handle/CockpitAuthenticationEntryPoint.java
  7. 15 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitClockConfiguration.java
  8. 6 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java
  9. 31 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorPayload.java
  10. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationAdapter.java
  11. 38 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationResponse.java
  12. 47 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationService.java
  13. 17 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationSnapshot.java
  14. 25 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFreshness.java
  15. 29 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitMapFrame.java
  16. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitReadException.java
  17. 11 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/EmptyCockpitFoundationAdapter.java

+ 259 - 0
docs/cockpit/foundation-demo.html

@@ -0,0 +1,259 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>Cockpit API Foundation Demo</title>
+  <style>
+    body {
+      margin: 0;
+      min-height: 100vh;
+      display: grid;
+      place-items: center;
+      background: #0b1622;
+      color: #e8f1f8;
+      font: 15px/1.5 "Microsoft YaHei", "PingFang SC", Arial, sans-serif;
+    }
+
+    main {
+      width: min(920px, calc(100vw - 32px));
+      padding: 24px;
+      border: 1px solid #21465f;
+      background: #102030;
+    }
+
+    h1 {
+      margin: 0 0 4px;
+      font-size: 22px;
+    }
+
+    .endpoint {
+      margin: 0 0 18px;
+      color: #78a2b8;
+      font-family: Consolas, monospace;
+    }
+
+    .toolbar {
+      display: flex;
+      gap: 8px;
+      flex-wrap: wrap;
+      margin-bottom: 18px;
+    }
+
+    button {
+      border: 1px solid #35708d;
+      border-radius: 4px;
+      padding: 7px 12px;
+      color: #dff0fa;
+      background: #173a4d;
+      cursor: pointer;
+    }
+
+    button:hover {
+      background: #1d4d67;
+    }
+
+    .state {
+      display: none;
+      border: 1px solid #2a5a75;
+      padding: 16px;
+      background: #0d1922;
+    }
+
+    .state.active {
+      display: block;
+    }
+
+    .state h2 {
+      margin: 0 0 10px;
+      font-size: 18px;
+    }
+
+    .state.success h2 {
+      color: #61d095;
+    }
+
+    .state.unauthorized h2 {
+      color: #f0ad4e;
+    }
+
+    .state.server-error h2 {
+      color: #ff6b6b;
+    }
+
+    dl {
+      margin: 0 0 12px;
+      display: grid;
+      grid-template-columns: 140px 1fr;
+      gap: 4px 12px;
+    }
+
+    dt {
+      color: #8fb3c7;
+    }
+
+    dd {
+      margin: 0;
+      overflow-wrap: anywhere;
+    }
+
+    pre {
+      margin: 0;
+      padding: 12px;
+      max-height: 320px;
+      overflow: auto;
+      background: #07111a;
+      color: #c9e7f7;
+      font-size: 13px;
+    }
+  </style>
+</head>
+<body>
+<main>
+  <h1>城市生命线驾驶舱基础契约演示</h1>
+  <p class="endpoint">GET /api/cockpit/v1/foundation</p>
+
+  <div class="toolbar">
+    <button type="button" data-mode="success">成功空地图</button>
+    <button type="button" data-mode="unauthorized">未认证 401</button>
+    <button type="button" data-mode="server-error">服务端错误 500</button>
+    <button type="button" data-mode="live">请求真实接口</button>
+  </div>
+
+  <section id="state" class="state">
+    <h2 id="state-title"></h2>
+    <dl id="state-fields"></dl>
+    <pre id="state-payload"></pre>
+  </section>
+</main>
+
+<script>
+  var states = {
+    'success': {
+      title: '成功:空地图帧',
+      className: 'state success',
+      payload: {
+        code: 200,
+        msg: '操作成功',
+        data: {
+          requestId: 'demo-foundation-empty',
+          apiVersion: 'v1',
+          readOnly: true,
+          mapFrame: { state: 'EMPTY', renderable: false, reason: '驾驶舱基础数据为空' },
+          freshness: {
+            status: 'FRESH',
+            serverTime: '2026-09-02T10:00:00+08:00',
+            dataTime: '2026-09-02T10:00:00+08:00'
+          }
+        }
+      }
+    },
+    'unauthorized': {
+      title: '失败:驾驶舱身份未认证',
+      className: 'state unauthorized',
+      payload: {
+        code: 401,
+        msg: '驾驶舱接口需要认证',
+        data: {
+          category: 'UNAUTHORIZED',
+          message: '驾驶舱接口需要认证',
+          requestId: 'demo-foundation-unauthorized',
+          serverTime: '2026-09-02T10:00:01+08:00'
+        }
+      }
+    },
+    'server-error': {
+      title: '失败:基础数据服务暂不可用',
+      className: 'state server-error',
+      payload: {
+        code: 500,
+        msg: '驾驶舱基础数据暂不可用',
+        data: {
+          category: 'SERVER_ERROR',
+          message: '驾驶舱基础数据暂不可用',
+          requestId: 'demo-foundation-server-error',
+          serverTime: '2026-09-02T10:00:02+08:00'
+        }
+      }
+    }
+  };
+
+  function render(mode, payload, title, className) {
+    var section = document.getElementById('state');
+    var response = payload || states[mode].payload;
+    var fields = {
+      'HTTP 状态': String(response.code),
+      '消息': response.msg,
+      'requestId': response.data && response.data.requestId,
+      'readOnly': response.data && response.data.readOnly,
+      '地图帧': response.data && response.data.mapFrame && response.data.mapFrame.state,
+      '数据新鲜度': response.data && response.data.freshness && response.data.freshness.status,
+      '错误类型': response.data && response.data.category
+    };
+    var list = document.getElementById('state-fields');
+    list.innerHTML = '';
+    Object.keys(fields).forEach(function (key) {
+      if (fields[key] === undefined || fields[key] === null) {
+        return;
+      }
+      var term = document.createElement('dt');
+      term.textContent = key;
+      var value = document.createElement('dd');
+      value.textContent = String(fields[key]);
+      list.appendChild(term);
+      list.appendChild(value);
+    });
+    document.getElementById('state-title').textContent =
+      title || (states[mode] && states[mode].title) || '接口返回';
+    section.className = className || (states[mode] && states[mode].className) || 'state';
+    document.getElementById('state-payload').textContent = JSON.stringify(response, null, 2);
+    section.classList.add('active');
+  }
+
+  function loadLive() {
+    fetch('/api/cockpit/v1/foundation', { headers: { 'X-Request-Id': 'demo-foundation-live' } })
+      .then(function (response) {
+        return response.json().then(function (payload) {
+          render('success', payload,
+            response.ok ? '真实接口:' + payload.msg : '真实接口 HTTP ' + response.status,
+            response.ok ? 'state success' : (response.status === 401 ? 'state unauthorized' : 'state server-error'));
+        });
+      })
+      .catch(function (error) {
+        render('server-error', {
+          code: 500,
+          msg: '驾驶舱基础数据暂不可用',
+          data: {
+            category: 'SERVER_ERROR',
+            message: String(error),
+            requestId: 'demo-foundation-live',
+            serverTime: ''
+          }
+        });
+      });
+  }
+
+  document.querySelectorAll('button[data-mode]').forEach(function (button) {
+    button.addEventListener('click', function () {
+      var mode = button.getAttribute('data-mode');
+      if (mode === 'live') {
+        loadLive();
+      } else {
+        render(mode);
+      }
+    });
+  });
+
+  var initialMode = location.search.indexOf('mode=unauthorized') >= 0 ? 'unauthorized'
+    : location.search.indexOf('mode=server-error') >= 0 ? 'server-error'
+    : location.search.indexOf('mode=live') >= 0 ? 'live'
+    : 'success';
+
+  if (initialMode === 'live') {
+    loadLive();
+  } else {
+    render(initialMode);
+  }
+</script>
+</body>
+</html>

+ 47 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitFoundationController.java

@@ -0,0 +1,47 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.cockpit.foundation.CockpitFoundationService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+
+@RestController
+@RequestMapping("/api/cockpit/v1")
+public class CockpitFoundationController {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+    private final CockpitFoundationService foundationService;
+
+    public CockpitFoundationController(CockpitFoundationService foundationService) {
+        this.foundationService = foundationService;
+    }
+
+    @GetMapping("/foundation")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<AjaxResult> foundation(@RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
+        return ResponseEntity.ok(AjaxResult.success(foundationService.load(resolveRequestId(requestId))));
+    }
+
+    @ExceptionHandler(CockpitReadException.class)
+    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
+        CockpitErrorPayload payload = foundationService.serverError(resolveRequestId(request.getHeader(REQUEST_ID_HEADER)));
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(AjaxResult.error(payload.getMessage(), payload));
+    }
+
+    private String resolveRequestId(String requestId) {
+        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
+    }
+}

+ 40 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitAuthenticationEntryPointTest.java

@@ -0,0 +1,40 @@
+package com.zksy.web.controller.cockpit;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.zksy.framework.security.handle.CockpitAuthenticationEntryPoint;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.BadCredentialsException;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CockpitAuthenticationEntryPointTest {
+
+    @Test
+    void unauthenticatedCockpitRequestReturnsUnauthorizedHttpStatusAndTypedError() throws Exception {
+        MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/cockpit/v1/foundation");
+        request.addHeader("X-Request-Id", "foundation-request-401");
+        MockHttpServletResponse response = new MockHttpServletResponse();
+        CockpitAuthenticationEntryPoint entryPoint = new CockpitAuthenticationEntryPoint(
+                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
+
+        entryPoint.commence(request, response, new BadCredentialsException("invalid token"));
+
+        assertEquals(401, response.getStatus());
+        assertTrue(response.getContentType().startsWith("application/json"));
+        JSONObject body = JSON.parseObject(response.getContentAsString());
+        assertEquals(401, body.getIntValue("code"));
+        assertEquals("驾驶舱接口需要认证", body.getString("msg"));
+        assertEquals("UNAUTHORIZED", body.getJSONObject("data").getString("category"));
+        assertEquals("驾驶舱接口需要认证", body.getJSONObject("data").getString("message"));
+        assertEquals("foundation-request-401", body.getJSONObject("data").getString("requestId"));
+        assertEquals("2026-09-02T10:00:00+08:00", body.getJSONObject("data").getString("serverTime"));
+    }
+}

+ 107 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitFoundationControllerTest.java

@@ -0,0 +1,107 @@
+package com.zksy.web.controller.cockpit;
+
+import com.zksy.cockpit.foundation.CockpitFoundationAdapter;
+import com.zksy.cockpit.foundation.CockpitFoundationService;
+import com.zksy.cockpit.foundation.CockpitReadException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+
+import static org.hamcrest.Matchers.notNullValue;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+class CockpitFoundationControllerTest {
+
+    private CockpitFoundationAdapter adapter;
+    private CockpitFoundationController controller;
+    private MockMvc mockMvc;
+
+    @BeforeEach
+    void setUp() {
+        adapter = mock(CockpitFoundationAdapter.class);
+        controller = new CockpitFoundationController(
+                new CockpitFoundationService(adapter, Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC)));
+        mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
+    }
+
+    @Test
+    void foundationResponseUsesAjaxResultCamelCaseFreshnessAndReadOnlyContract() throws Exception {
+        mockMvc.perform(get("/api/cockpit/v1/foundation").header("X-Request-Id", "foundation-request-001"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(200))
+                .andExpect(jsonPath("$.msg").value("操作成功"))
+                .andExpect(jsonPath("$.data.requestId").value("foundation-request-001"))
+                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
+                .andExpect(jsonPath("$.data.readOnly").value(true))
+                .andExpect(jsonPath("$.data.mapFrame.state").value("EMPTY"))
+                .andExpect(jsonPath("$.data.mapFrame.renderable").value(false))
+                .andExpect(jsonPath("$.data.freshness.status").value("FRESH"))
+                .andExpect(jsonPath("$.data.freshness.serverTime").value("2026-09-02T10:00:00+08:00"))
+                .andExpect(jsonPath("$.data.freshness.dataTime").value("2026-09-02T10:00:00+08:00"));
+
+        verify(adapter).load();
+        verifyNoMoreInteractions(adapter);
+    }
+
+    @Test
+    void serverFailureReturnsTypedErrorInsteadOfPretendingToBeEmpty() throws Exception {
+        when(adapter.load()).thenThrow(new CockpitReadException("upstream unavailable"));
+
+        mockMvc.perform(get("/api/cockpit/v1/foundation").header("X-Request-Id", "foundation-request-002"))
+                .andExpect(status().isInternalServerError())
+                .andExpect(jsonPath("$.code").value(500))
+                .andExpect(jsonPath("$.msg").value("驾驶舱基础数据暂不可用"))
+                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
+                .andExpect(jsonPath("$.data.message").value("驾驶舱基础数据暂不可用"))
+                .andExpect(jsonPath("$.data.requestId").value("foundation-request-002"))
+                .andExpect(jsonPath("$.data.serverTime", notNullValue()));
+    }
+
+    @Test
+    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
+        mockMvc.perform(post("/api/cockpit/v1/foundation"))
+                .andExpect(status().isMethodNotAllowed());
+
+        verifyNoInteractions(adapter);
+    }
+
+    @Test
+    void foundationContractOnlyExposesAuthenticatedReadOperations() {
+        assertTrue(Arrays.stream(controller.getClass().getDeclaredMethods())
+                .filter(method -> method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                        || method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class))
+                .allMatch(CockpitFoundationControllerTest::isReadOnlyMappedMethod));
+
+        assertTrue(Arrays.stream(controller.getClass().getDeclaredMethods())
+                .anyMatch(method -> method.isAnnotationPresent(org.springframework.security.access.prepost.PreAuthorize.class)
+                        && method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value().equals("isAuthenticated()")));
+    }
+
+    private static boolean isReadOnlyMappedMethod(Method method) {
+        return method.isAnnotationPresent(org.springframework.web.bind.annotation.GetMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PostMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PutMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.PatchMapping.class)
+                && !method.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class);
+    }
+}

+ 24 - 1
pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/SecurityConfig.java

@@ -4,23 +4,30 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.http.HttpMethod;
+import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.ProviderManager;
 import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
 import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.AuthenticationEntryPoint;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 import org.springframework.security.web.authentication.logout.LogoutFilter;
 import org.springframework.web.filter.CorsFilter;
 import com.zksy.framework.config.properties.PermitAllUrlProperties;
 import com.zksy.framework.security.filter.JwtAuthenticationTokenFilter;
+import com.zksy.framework.security.handle.CockpitAuthenticationEntryPoint;
 import com.zksy.framework.security.handle.AuthenticationEntryPointImpl;
 import com.zksy.framework.security.handle.LogoutSuccessHandlerImpl;
 
+import java.util.LinkedHashMap;
+
 /**
  * spring security配置
  * 
@@ -42,6 +49,12 @@ public class SecurityConfig
     @Autowired
     private AuthenticationEntryPointImpl unauthorizedHandler;
 
+    /**
+     * 驾驶舱专用未认证处理
+     */
+    @Autowired
+    private CockpitAuthenticationEntryPoint cockpitUnauthorizedHandler;
+
     /**
      * 退出处理类
      */
@@ -104,7 +117,7 @@ public class SecurityConfig
                 headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
             })
             // 认证失败处理类
-            .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
+            .exceptionHandling(exception -> exception.authenticationEntryPoint(delegatingAuthenticationEntryPoint()))
             // 基于token,所以不需要session
             .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
             // 注解标记允许匿名访问的url
@@ -136,4 +149,14 @@ public class SecurityConfig
     {
         return new BCryptPasswordEncoder();
     }
+
+    private DelegatingAuthenticationEntryPoint delegatingAuthenticationEntryPoint()
+    {
+        LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
+        entryPoints.put(new AntPathRequestMatcher("/api/cockpit/**"), cockpitUnauthorizedHandler);
+        DelegatingAuthenticationEntryPoint delegatingEntryPoint =
+                new DelegatingAuthenticationEntryPoint(entryPoints);
+        delegatingEntryPoint.setDefaultEntryPoint(unauthorizedHandler);
+        return delegatingEntryPoint;
+    }
 }

+ 52 - 0
pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/security/handle/CockpitAuthenticationEntryPoint.java

@@ -0,0 +1,52 @@
+package com.zksy.framework.security.handle;
+
+import com.alibaba.fastjson2.JSON;
+import com.zksy.cockpit.foundation.CockpitErrorCategory;
+import com.zksy.cockpit.foundation.CockpitErrorPayload;
+import com.zksy.common.constant.HttpStatus;
+import com.zksy.common.core.domain.AjaxResult;
+import com.zksy.common.utils.StringUtils;
+import com.zksy.common.utils.uuid.IdUtils;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+
+@Component
+public class CockpitAuthenticationEntryPoint implements AuthenticationEntryPoint {
+    private static final String REQUEST_ID_HEADER = "X-Request-Id";
+    private static final String UNAUTHORIZED_MESSAGE = "驾驶舱接口需要认证";
+    private static final DateTimeFormatter BUSINESS_TIME = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
+
+    private final Clock clock;
+
+    public CockpitAuthenticationEntryPoint(Clock clock) {
+        this.clock = clock;
+    }
+
+    @Override
+    public void commence(HttpServletRequest request, HttpServletResponse response,
+            AuthenticationException authenticationException) throws IOException {
+        String requestId = request.getHeader(REQUEST_ID_HEADER);
+        if (StringUtils.isEmpty(requestId)) {
+            requestId = IdUtils.fastSimpleUUID();
+        }
+        String serverTime = BUSINESS_TIME.format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
+        CockpitErrorPayload payload = new CockpitErrorPayload(
+                CockpitErrorCategory.UNAUTHORIZED,
+                UNAUTHORIZED_MESSAGE,
+                requestId,
+                serverTime);
+        AjaxResult result = new AjaxResult(HttpStatus.UNAUTHORIZED, UNAUTHORIZED_MESSAGE, payload);
+        response.setStatus(HttpStatus.UNAUTHORIZED);
+        response.setContentType("application/json");
+        response.setCharacterEncoding("UTF-8");
+        response.getWriter().print(JSON.toJSONString(result));
+    }
+}

+ 15 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitClockConfiguration.java

@@ -0,0 +1,15 @@
+package com.zksy.cockpit.foundation;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Clock;
+import java.time.ZoneId;
+
+@Configuration
+public class CockpitClockConfiguration {
+    @Bean
+    public Clock cockpitClock() {
+        return Clock.system(ZoneId.of("Asia/Shanghai"));
+    }
+}

+ 6 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorCategory.java

@@ -0,0 +1,6 @@
+package com.zksy.cockpit.foundation;
+
+public enum CockpitErrorCategory {
+    UNAUTHORIZED,
+    SERVER_ERROR
+}

+ 31 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitErrorPayload.java

@@ -0,0 +1,31 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitErrorPayload {
+    private final CockpitErrorCategory category;
+    private final String message;
+    private final String requestId;
+    private final String serverTime;
+
+    public CockpitErrorPayload(CockpitErrorCategory category, String message, String requestId, String serverTime) {
+        this.category = category;
+        this.message = message;
+        this.requestId = requestId;
+        this.serverTime = serverTime;
+    }
+
+    public CockpitErrorCategory getCategory() {
+        return category;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public String getRequestId() {
+        return requestId;
+    }
+
+    public String getServerTime() {
+        return serverTime;
+    }
+}

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationAdapter.java

@@ -0,0 +1,5 @@
+package com.zksy.cockpit.foundation;
+
+public interface CockpitFoundationAdapter {
+    CockpitFoundationSnapshot load();
+}

+ 38 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationResponse.java

@@ -0,0 +1,38 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitFoundationResponse {
+    private final String requestId;
+    private final String apiVersion;
+    private final boolean readOnly;
+    private final CockpitMapFrame mapFrame;
+    private final CockpitFreshness freshness;
+
+    public CockpitFoundationResponse(String requestId, String apiVersion, boolean readOnly,
+            CockpitMapFrame mapFrame, CockpitFreshness freshness) {
+        this.requestId = requestId;
+        this.apiVersion = apiVersion;
+        this.readOnly = readOnly;
+        this.mapFrame = mapFrame;
+        this.freshness = freshness;
+    }
+
+    public String getRequestId() {
+        return requestId;
+    }
+
+    public String getApiVersion() {
+        return apiVersion;
+    }
+
+    public boolean isReadOnly() {
+        return readOnly;
+    }
+
+    public CockpitMapFrame getMapFrame() {
+        return mapFrame;
+    }
+
+    public CockpitFreshness getFreshness() {
+        return freshness;
+    }
+}

+ 47 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationService.java

@@ -0,0 +1,47 @@
+package com.zksy.cockpit.foundation;
+
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Locale;
+
+@Service
+public class CockpitFoundationService {
+    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
+    private static final DateTimeFormatter BUSINESS_TIME =
+            DateTimeFormatter.ISO_OFFSET_DATE_TIME.withLocale(Locale.CHINA);
+
+    private final CockpitFoundationAdapter adapter;
+    private final Clock clock;
+
+    public CockpitFoundationService(CockpitFoundationAdapter adapter, Clock clock) {
+        this.adapter = adapter;
+        this.clock = clock;
+    }
+
+    public CockpitFoundationResponse load(String requestId) {
+        CockpitFoundationSnapshot snapshot = adapter.load();
+        if (snapshot == null) {
+            snapshot = CockpitFoundationSnapshot.empty();
+        }
+        String now = businessTime(clock.instant());
+        return new CockpitFoundationResponse(
+                requestId,
+                "v1",
+                true,
+                snapshot.getMapFrame(),
+                new CockpitFreshness("FRESH", now, now));
+    }
+
+    public CockpitErrorPayload serverError(String requestId) {
+        String message = "驾驶舱基础数据暂不可用";
+        return new CockpitErrorPayload(CockpitErrorCategory.SERVER_ERROR, message, requestId, businessTime(clock.instant()));
+    }
+
+    public String businessTime(Instant instant) {
+        return BUSINESS_TIME.format(instant.atZone(BUSINESS_ZONE));
+    }
+}

+ 17 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFoundationSnapshot.java

@@ -0,0 +1,17 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitFoundationSnapshot {
+    private final CockpitMapFrame mapFrame;
+
+    public CockpitFoundationSnapshot(CockpitMapFrame mapFrame) {
+        this.mapFrame = mapFrame;
+    }
+
+    public static CockpitFoundationSnapshot empty() {
+        return new CockpitFoundationSnapshot(CockpitMapFrame.empty());
+    }
+
+    public CockpitMapFrame getMapFrame() {
+        return mapFrame;
+    }
+}

+ 25 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitFreshness.java

@@ -0,0 +1,25 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitFreshness {
+    private final String status;
+    private final String serverTime;
+    private final String dataTime;
+
+    public CockpitFreshness(String status, String serverTime, String dataTime) {
+        this.status = status;
+        this.serverTime = serverTime;
+        this.dataTime = dataTime;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public String getServerTime() {
+        return serverTime;
+    }
+
+    public String getDataTime() {
+        return dataTime;
+    }
+}

+ 29 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitMapFrame.java

@@ -0,0 +1,29 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitMapFrame {
+    private final String state;
+    private final boolean renderable;
+    private final String reason;
+
+    public CockpitMapFrame(String state, boolean renderable, String reason) {
+        this.state = state;
+        this.renderable = renderable;
+        this.reason = reason;
+    }
+
+    public static CockpitMapFrame empty() {
+        return new CockpitMapFrame("EMPTY", false, "驾驶舱基础数据为空");
+    }
+
+    public String getState() {
+        return state;
+    }
+
+    public boolean isRenderable() {
+        return renderable;
+    }
+
+    public String getReason() {
+        return reason;
+    }
+}

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/CockpitReadException.java

@@ -0,0 +1,7 @@
+package com.zksy.cockpit.foundation;
+
+public class CockpitReadException extends RuntimeException {
+    public CockpitReadException(String message) {
+        super(message);
+    }
+}

+ 11 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/foundation/EmptyCockpitFoundationAdapter.java

@@ -0,0 +1,11 @@
+package com.zksy.cockpit.foundation;
+
+import org.springframework.stereotype.Component;
+
+@Component
+public class EmptyCockpitFoundationAdapter implements CockpitFoundationAdapter {
+    @Override
+    public CockpitFoundationSnapshot load() {
+        return CockpitFoundationSnapshot.empty();
+    }
+}