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;
+ }
+}