From 06b0f4ed23c8a0fa3be6263d4805d8d32c601b54 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 23 Sep 2026 00:12:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=BD=91=E5=85=B3=E6=B2=BB?= =?UTF-8?q?=E7=90=86=E5=B0=8F=E9=A1=B5=E5=90=8E=E7=AB=AF=E2=80=94=E2=80=94?= =?UTF-8?q?=E8=B7=AF=E7=94=B1/=E7=9B=AE=E6=A0=87/=E5=8F=B0=E8=B4=A6?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E4=B8=8E=E8=A1=8C=E5=86=85=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/internal/admin/**(RoutingFilter 旁路前缀,零过滤器改动): - GET page(routes/targets/logs) + PUT routes/{id} / targets/{id} - 值域白名单(visibility/authMode/cacheScope)非法 400 不落库;写后热重载注册表 - OpenApiLogRepository.pageLatest 两可空过滤+倒序分页 - 10 用例(page/列表/合法编辑/三态 400/limit 截断),全量 75/75 绿 --- .../admin/InternalAdminController.java | 226 ++++++++++++++++++ .../repository/OpenApiLogRepository.java | 15 ++ src/main/resources/static/admin.html | 5 + .../admin/InternalAdminControllerTest.java | 196 +++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java create mode 100644 src/main/resources/static/admin.html create mode 100644 src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java diff --git a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java new file mode 100644 index 0000000..68a4782 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java @@ -0,0 +1,226 @@ +package com.yunhai.platform.gateway.admin; + +import com.yunhai.platform.gateway.entity.OpenApiLog; +import com.yunhai.platform.gateway.entity.OpenApiRoute; +import com.yunhai.platform.gateway.entity.OpenApiTarget; +import com.yunhai.platform.gateway.repository.OpenApiLogRepository; +import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; +import com.yunhai.platform.gateway.route.RouteRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.util.StreamUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 网关治理小页后端(/api/internal/admin/**,D5 最小治理面):路由/目标地址的查询与行内编辑、 + * 台账倒序查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets), + * 无须重启网关。与 {@code RouteRefreshController} 同前缀,被 RoutingFilter 旁路(不进 oapi 链)。 + *

+ * 收网前提示:本前缀当前宿主端口可达(10013 未绑 127.0.0.1),页面用于 observe 期 + * 治理与排障;T0.2 端口收网随下次重新部署自然收紧(届时仅容器网内可达)。 + * 值域白名单与 {@code docs/sql/01_schema.sql} 注释对齐:visibility∈{INTERNAL,EXTERNAL}、 + * authMode∈{SIGNED,ANONYMOUS}、cacheScope∈{CALLER,GLOBAL};非法值 400 且不落库。 + */ +@RestController +@RequestMapping("/api/internal/admin") +public class InternalAdminController { + + private static final Set VISIBILITY = new HashSet<>(Arrays.asList("INTERNAL", "EXTERNAL")); + private static final Set AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANONYMOUS")); + private static final Set CACHE_SCOPE = new HashSet<>(Arrays.asList("CALLER", "GLOBAL")); + + @Autowired + private OpenApiRouteRepository routeRepo; + + @Autowired + private OpenApiTargetRepository targetRepo; + + @Autowired + private OpenApiLogRepository logRepo; + + @Autowired + private RouteRegistry routeRegistry; + + /** 测试/显式装配入口 */ + void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo, + OpenApiLogRepository logRepo, RouteRegistry routeRegistry) { + this.routeRepo = routeRepo; + this.targetRepo = targetRepo; + this.logRepo = logRepo; + this.routeRegistry = routeRegistry; + } + + /** 治理小页本体(classpath:/static/admin.html,N03 产出) */ + @GetMapping(value = "/page", produces = MediaType.TEXT_HTML_VALUE) + public String page() throws IOException { + return StreamUtils.copyToString( + new ClassPathResource("static/admin.html").getInputStream(), StandardCharsets.UTF_8); + } + + /** 路由表(治理对象全量,含禁用行;排序稳定便于表格展示) */ + @GetMapping("/routes") + public List routes() { + List all = routeRepo.findAll(); + all.sort((a, b) -> { + int c = a.getTargetCode().compareTo(b.getTargetCode()); + return c != 0 ? c : a.getPathPattern().compareTo(b.getPathPattern()); + }); + return all; + } + + /** + * 行内编辑路由治理旋钮。只接受治理字段(不可改 target/pattern/method——那是路由身份, + * 改动应走 SQL 迁移评审);enabled 可整体停用一行。成功后热重载并返回加载数。 + */ + @PutMapping("/routes/{id}") + public Map updateRoute(@PathVariable("id") Long id, + @RequestBody Map body) { + OpenApiRoute route = routeRepo.findById(id).orElse(null); + if (route == null) { + throw new IllegalArgumentException("路由不存在:" + id); + } + if (body.containsKey("visibility")) { + String v = text(body.get("visibility")); + if (!VISIBILITY.contains(v)) { + throw new IllegalArgumentException("visibility 非法:" + v + "(合法 " + VISIBILITY + ")"); + } + route.setVisibility(v); + } + if (body.containsKey("authMode")) { + String v = text(body.get("authMode")); + if (!AUTH_MODE.contains(v)) { + throw new IllegalArgumentException("authMode 非法:" + v + "(合法 " + AUTH_MODE + ")"); + } + route.setAuthMode(v); + } + if (body.containsKey("cacheTtlSeconds")) { + int v = intVal(body.get("cacheTtlSeconds"), "cacheTtlSeconds"); + if (v < 0) { + throw new IllegalArgumentException("cacheTtlSeconds 不可为负:" + v); + } + route.setCacheTtlSeconds(v); + } + if (body.containsKey("cacheScope")) { + String v = text(body.get("cacheScope")); + if (!CACHE_SCOPE.contains(v)) { + throw new IllegalArgumentException("cacheScope 非法:" + v + "(合法 " + CACHE_SCOPE + ")"); + } + route.setCacheScope(v); + } + if (body.containsKey("rateLimitQps")) { + int v = intVal(body.get("rateLimitQps"), "rateLimitQps"); + if (v < 0) { + throw new IllegalArgumentException("rateLimitQps 不可为负:" + v); + } + route.setRateLimitQps(v); + } + if (body.containsKey("enabled")) { + route.setEnabled(Boolean.TRUE.equals(body.get("enabled"))); + } + if (body.containsKey("remark")) { + route.setRemark(text(body.get("remark"))); + } + route.setGmtModified(LocalDateTime.now()); + routeRepo.save(route); + Map out = new LinkedHashMap<>(); + out.put("loaded", routeRegistry.reload()); + return out; + } + + /** 目标地址表全量 */ + @GetMapping("/targets") + public List targets() { + List all = targetRepo.findAll(); + all.sort((a, b) -> a.getTargetCode().compareTo(b.getTargetCode())); + return all; + } + + /** 行内编辑目标地址/启停;成功后热重载 target 快照(禁用/空白行按无行回落 yml 由 TargetRouter 承担) */ + @PutMapping("/targets/{id}") + public Map updateTarget(@PathVariable("id") Long id, + @RequestBody Map body) { + OpenApiTarget target = targetRepo.findById(id).orElse(null); + if (target == null) { + throw new IllegalArgumentException("目标不存在:" + id); + } + if (body.containsKey("baseUrl")) { + String v = text(body.get("baseUrl")); + if (v == null || v.trim().isEmpty()) { + throw new IllegalArgumentException("baseUrl 不可为空"); + } + target.setBaseUrl(v.trim()); + } + if (body.containsKey("enabled")) { + target.setEnabled(Boolean.TRUE.equals(body.get("enabled"))); + } + target.setGmtModified(LocalDateTime.now()); + targetRepo.save(target); + Map out = new LinkedHashMap<>(); + out.put("targets", routeRegistry.reloadTargets()); + return out; + } + + /** 台账倒序查询(limit 默认 50 上限 500;targetCode/callerAppKey 可空) */ + @GetMapping("/logs") + public Map logs(@RequestParam(value = "limit", required = false) Integer limit, + @RequestParam(value = "targetCode", required = false) String targetCode, + @RequestParam(value = "callerAppKey", required = false) String callerAppKey) { + int size = limit == null || limit < 1 ? 50 : Math.min(limit, 500); + Page page = logRepo.pageLatest(emptyToNull(targetCode), emptyToNull(callerAppKey), + PageRequest.of(0, size)); + Map out = new LinkedHashMap<>(); + out.put("total", page.getTotalElements()); + out.put("rows", new ArrayList<>(page.getContent())); + return out; + } + + /** + * 值域/参数错误 → 400 + JSON 错误体(页面 alert 展示)。本类抛出的 IllegalArgumentException + * 全是"调用方传值非法",缺省会被 Spring 映射成 500,误导排障方向——故在类内显式收敛。 + */ + @org.springframework.web.bind.annotation.ExceptionHandler(IllegalArgumentException.class) + @org.springframework.web.bind.annotation.ResponseStatus( + org.springframework.http.HttpStatus.BAD_REQUEST) + public Map badRequest(IllegalArgumentException e) { + Map out = new LinkedHashMap<>(); + out.put("code", 1); + out.put("msg", e.getMessage()); + return out; + } + + private static String text(Object v) { + return v == null ? null : String.valueOf(v).trim(); + } + + private static int intVal(Object v, String field) { + if (!(v instanceof Number)) { + throw new IllegalArgumentException(field + " 须为数字:" + v); + } + return ((Number) v).intValue(); + } + + private static String emptyToNull(String s) { + return s == null || s.trim().isEmpty() ? null : s.trim(); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java index bfde09e..737ec04 100644 --- a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java +++ b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java @@ -1,7 +1,22 @@ package com.yunhai.platform.gateway.repository; import com.yunhai.platform.gateway.entity.OpenApiLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface OpenApiLogRepository extends JpaRepository { + + /** + * 台账倒序分页(治理小页查询;targetCode/callerAppKey 可空=不过滤)。 + * 可空参数走 (:x is null or ...) 形态与既有分页查询一致。 + */ + @Query("select l from OpenApiLog l where (:targetCode is null or l.targetCode = :targetCode) " + + "and (:callerAppKey is null or l.callerAppKey = :callerAppKey) " + + "order by l.id desc") + Page pageLatest(@Param("targetCode") String targetCode, + @Param("callerAppKey") String callerAppKey, + Pageable pageable); } diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html new file mode 100644 index 0000000..b111954 --- /dev/null +++ b/src/main/resources/static/admin.html @@ -0,0 +1,5 @@ + + +网关治理 + + diff --git a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java new file mode 100644 index 0000000..1bf4bdd --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java @@ -0,0 +1,196 @@ +package com.yunhai.platform.gateway.admin; + +import com.yunhai.platform.gateway.entity.OpenApiLog; +import com.yunhai.platform.gateway.entity.OpenApiRoute; +import com.yunhai.platform.gateway.entity.OpenApiTarget; +import com.yunhai.platform.gateway.repository.OpenApiLogRepository; +import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import com.yunhai.platform.gateway.repository.OpenApiTargetRepository; +import com.yunhai.platform.gateway.route.RouteRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.ArrayList; +import java.util.Arrays; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +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.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 治理小页后端(N02 TDD):page/routes/targets/logs 查询形态 + 路由/目标行内编辑的 + * 值域闸(非法值 400 且不落库)+ 写后热重载调用。MockMvc standalone + mock 仓储/注册表。 + */ +class InternalAdminControllerTest { + + private OpenApiRouteRepository routeRepo; + private OpenApiTargetRepository targetRepo; + private OpenApiLogRepository logRepo; + private RouteRegistry registry; + private MockMvc mvc; + + @BeforeEach + void setUp() { + routeRepo = Mockito.mock(OpenApiRouteRepository.class); + targetRepo = Mockito.mock(OpenApiTargetRepository.class); + logRepo = Mockito.mock(OpenApiLogRepository.class); + registry = Mockito.mock(RouteRegistry.class); + + InternalAdminController controller = new InternalAdminController(); + controller.setRepos(routeRepo, targetRepo, logRepo, registry); + mvc = MockMvcBuilders.standaloneSetup(controller).build(); + + when(routeRepo.findAll()).thenReturn(new ArrayList<>(Arrays.asList(route(1L)))); + when(targetRepo.findAll()).thenReturn(new ArrayList<>(Arrays.asList(target(5L)))); + when(registry.reload()).thenReturn(22); + when(registry.reloadTargets()).thenReturn(13); + when(logRepo.pageLatest(any(), any(), any(Pageable.class))) + .thenReturn(new PageImpl<>(Arrays.asList(logRow()))); + } + + @Test + void page_servesHtml() throws Exception { + mvc.perform(get("/api/internal/admin/page")) + .andExpect(status().isOk()) + .andExpect(result -> { + String body = result.getResponse().getContentAsString(); + org.assertj.core.api.Assertions.assertThat(body).contains(" p.getPageSize() == 500)); + } + + private static OpenApiRoute route(Long id) { + OpenApiRoute r = new OpenApiRoute(); + r.setId(id); + r.setTargetCode("userbase"); + r.setPathPattern("/oapi/user/**"); + r.setHttpMethod("*"); + r.setVisibility("INTERNAL"); + r.setAuthMode("SIGNED"); + r.setCacheTtlSeconds(0); + r.setCacheScope("CALLER"); + r.setRateLimitQps(0); + r.setEnabled(true); + return r; + } + + private static OpenApiTarget target(Long id) { + OpenApiTarget t = new OpenApiTarget(); + t.setId(id); + t.setTargetCode("notice"); + t.setBaseUrl("http://notice-service:10002"); + t.setEnabled(true); + return t; + } + + private static OpenApiLog logRow() { + OpenApiLog l = new OpenApiLog(); + l.setId(100L); + l.setTargetCode("notice"); + l.setMethod("GET"); + l.setPath("/oapi/notice/getDict"); + l.setStatus(200); + l.setCallerAppKey("100001"); + l.setCostMs(5); + return l; + } +}