feat(admin): 网关治理小页后端——路由/目标/台账查询与行内编辑

/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 绿
This commit is contained in:
chenxing
2026-09-23 00:12:17 +08:00
parent 50f9948b49
commit 06b0f4ed23
4 changed files with 442 additions and 0 deletions
@@ -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 链)。
* <p>
* <b>收网前提示</b>:本前缀当前宿主端口可达(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<String> VISIBILITY = new HashSet<>(Arrays.asList("INTERNAL", "EXTERNAL"));
private static final Set<String> AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANONYMOUS"));
private static final Set<String> 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<OpenApiRoute> routes() {
List<OpenApiRoute> 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<String, Object> updateRoute(@PathVariable("id") Long id,
@RequestBody Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("loaded", routeRegistry.reload());
return out;
}
/** 目标地址表全量 */
@GetMapping("/targets")
public List<OpenApiTarget> targets() {
List<OpenApiTarget> all = targetRepo.findAll();
all.sort((a, b) -> a.getTargetCode().compareTo(b.getTargetCode()));
return all;
}
/** 行内编辑目标地址/启停;成功后热重载 target 快照(禁用/空白行按无行回落 yml 由 TargetRouter 承担) */
@PutMapping("/targets/{id}")
public Map<String, Object> updateTarget(@PathVariable("id") Long id,
@RequestBody Map<String, Object> 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<String, Object> out = new LinkedHashMap<>();
out.put("targets", routeRegistry.reloadTargets());
return out;
}
/** 台账倒序查询(limit 默认 50 上限 500;targetCode/callerAppKey 可空) */
@GetMapping("/logs")
public Map<String, Object> 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<OpenApiLog> page = logRepo.pageLatest(emptyToNull(targetCode), emptyToNull(callerAppKey),
PageRequest.of(0, size));
Map<String, Object> 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<String, Object> badRequest(IllegalArgumentException e) {
Map<String, Object> 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();
}
}
@@ -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<OpenApiLog, Long> {
/**
* 台账倒序分页(治理小页查询;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<OpenApiLog> pageLatest(@Param("targetCode") String targetCode,
@Param("callerAppKey") String callerAppKey,
Pageable pageable);
}
+5
View File
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="zh">
<head><meta charset="UTF-8"><title>网关治理</title></head>
<body><!-- N03 将替换为完整治理页 --></body>
</html>
@@ -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("<html");
});
}
@Test
void routes_listSorted() throws Exception {
mvc.perform(get("/api/internal/admin/routes"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].targetCode").value("userbase"))
.andExpect(jsonPath("$[0].pathPattern").value("/oapi/user/**"));
}
@Test
void routeUpdate_legalValue_savesAndReloads() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"authMode\":\"ANONYMOUS\",\"cacheTtlSeconds\":30,\"enabled\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.loaded").value(22));
verify(routeRepo).save(any(OpenApiRoute.class));
verify(registry).reload();
}
@Test
void routeUpdate_illegalAuthMode_400AndNoSave() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"authMode\":\"OPEN\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.msg").value(org.hamcrest.Matchers.containsString("authMode")));
verify(routeRepo, never()).save(any());
verify(registry, never()).reload();
}
@Test
void routeUpdate_negativeTtl_400() throws Exception {
when(routeRepo.findById(1L)).thenReturn(java.util.Optional.of(route(1L)));
mvc.perform(put("/api/internal/admin/routes/1")
.contentType("application/json")
.content("{\"cacheTtlSeconds\":-1}"))
.andExpect(status().isBadRequest());
verify(routeRepo, never()).save(any());
}
@Test
void routeUpdate_missingRow_400() throws Exception {
when(routeRepo.findById(99L)).thenReturn(java.util.Optional.empty());
mvc.perform(put("/api/internal/admin/routes/99")
.contentType("application/json")
.content("{\"enabled\":false}"))
.andExpect(status().isBadRequest());
}
@Test
void targetUpdate_savesAndReloadsTargets() throws Exception {
when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L)));
mvc.perform(put("/api/internal/admin/targets/5")
.contentType("application/json")
.content("{\"baseUrl\":\"http://notice-service:10002\",\"enabled\":true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.targets").value(13));
verify(targetRepo).save(any(OpenApiTarget.class));
verify(registry).reloadTargets();
}
@Test
void targetUpdate_blankBaseUrl_400NoSave() throws Exception {
when(targetRepo.findById(5L)).thenReturn(java.util.Optional.of(target(5L)));
mvc.perform(put("/api/internal/admin/targets/5")
.contentType("application/json")
.content("{\"baseUrl\":\" \"}"))
.andExpect(status().isBadRequest());
verify(targetRepo, never()).save(any());
}
@Test
void logs_defaultLimitAndFilters() throws Exception {
mvc.perform(get("/api/internal/admin/logs?targetCode=notice&callerAppKey=100001&limit=10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(1))
.andExpect(jsonPath("$.rows[0].targetCode").value("notice"));
verify(logRepo).pageLatest(eq("notice"), eq("100001"), any(Pageable.class));
}
@Test
void logs_overLimitCappedTo500() throws Exception {
mvc.perform(get("/api/internal/admin/logs?limit=9999"))
.andExpect(status().isOk());
verify(logRepo).pageLatest(any(), any(), Mockito.argThat(p -> 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;
}
}