From 2389d2fa9a8a0496d8375a478ea300de179fc0e5 Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 23 Sep 2026 12:17:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86=E9=9D=A2?= =?UTF-8?q?=E4=BF=A1=E5=B0=81=E5=8C=96+=E5=88=86=E9=A1=B5=E5=8C=96?= =?UTF-8?q?=EF=BC=8COpenAPI=20=E6=96=87=E6=A1=A3=E7=AB=AF=E7=82=B9?= =?UTF-8?q?=E6=97=81=E8=B7=AF=EF=BC=88B1-B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pom: 引 yh-platform-dependencies(import) / yh-api-std / springdoc-openapi-ui 1.7.0 + nexus-public 仓库 - RoutingFilter.isGatewayInternal: /v3/api-docs、/swagger-ui/**、/swagger-ui.html 纳入自身路径旁路,避免被 TargetRouter 当 target 裸 404 - InternalAdminController: page/routes/targets/logs 三接口分页化返回 BasePageResult;补 @Operation(operationId=GatewayAdmin.*);错误契约去 @ResponseStatus(400) 改 200+code=2;logs 兼容旧 limit 入参 - RouteRefreshController: 补 @Tag/@Operation(operationId=GatewayAdmin.refresh) - static/admin.html: 适配 {code,msg,data} 信封与 content/total 分页 - InternalAdminControllerTest: 重写 12 用例,77 项测试全绿 --- pom.xml | 36 ++++++ .../admin/InternalAdminController.java | 118 ++++++++++++------ .../gateway/filter/RoutingFilter.java | 16 ++- .../gateway/route/RouteRefreshController.java | 4 + src/main/resources/static/admin.html | 32 +++-- .../admin/InternalAdminControllerTest.java | 59 ++++++--- 6 files changed, 198 insertions(+), 67 deletions(-) diff --git a/pom.xml b/pom.xml index 4c11879..f928a8c 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,19 @@ UTF-8 + + + + + com.yunhai.platform + yh-platform-dependencies + 1.0.0-SNAPSHOT + pom + import + + + + @@ -77,6 +90,19 @@ lombok true + + + com.yunhai.platform + yh-api-std + + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + @@ -95,4 +121,14 @@ + + + + nexus-public + bsch/nexus public + http://nexus.yunhaixingtong.com/repository/maven-public/ + true + true + + diff --git a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java index 68a4782..fa1e6ee 100644 --- a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java +++ b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java @@ -1,5 +1,6 @@ package com.yunhai.platform.gateway.admin; +import com.yunhai.platform.apistd.ApiResponse; import com.yunhai.platform.gateway.entity.OpenApiLog; import com.yunhai.platform.gateway.entity.OpenApiRoute; import com.yunhai.platform.gateway.entity.OpenApiTarget; @@ -7,12 +8,18 @@ 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 com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BasePageResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; 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.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.util.StreamUtils; +import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -24,24 +31,27 @@ 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), + * 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets), * 无须重启网关。与 {@code RouteRefreshController} 同前缀,被 RoutingFilter 旁路(不进 oapi 链)。 *

+ * 响应契约:成功响应由 {@code yh-api-std} 的 {@code ApiResponseAdvice} 统一包成 + * {@code {code:0, msg:"", data:...}}(与 notice/userbase 一致);非法入参走 {@link #badRequest} + * 返回 HTTP 200 + {@code {code:2, msg:...}}(平台规范:业务错误不改 HTTP 状态码)。 + *

* 收网前提示:本前缀当前宿主端口可达(10013 未绑 127.0.0.1),页面用于 observe 期 * 治理与排障;T0.2 端口收网随下次重新部署自然收紧(届时仅容器网内可达)。 * 值域白名单与 {@code docs/sql/01_schema.sql} 注释对齐:visibility∈{INTERNAL,EXTERNAL}、 - * authMode∈{SIGNED,ANONYMOUS}、cacheScope∈{CALLER,GLOBAL};非法值 400 且不落库。 + * authMode∈{SIGNED,ANONYMOUS}、cacheScope∈{CALLER,GLOBAL};非法值返回 code=2 且不落库。 */ +@Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账") @RestController @RequestMapping("/api/internal/admin") public class InternalAdminController { @@ -71,28 +81,29 @@ public class InternalAdminController { this.routeRegistry = routeRegistry; } - /** 治理小页本体(classpath:/static/admin.html,N03 产出) */ + /** 治理小页本体(classpath:/static/admin.html,N03 产出);返回 String 故不被信封包装 */ + @Operation(operationId = "GatewayAdmin.page", summary = "治理小页(内置 HTML)") @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); } - /** 路由表(治理对象全量,含禁用行;排序稳定便于表格展示) */ + /** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */ + @Operation(operationId = "GatewayAdmin.routes", summary = "路由注册表分页") @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; + public BasePageResult routes( + @RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex, + @RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) { + Sort sort = Sort.by("targetCode").and(Sort.by("pathPattern")); + return toResult(routeRepo.findAll(pageRequest(pageIndex, pageSize, sort))); } /** * 行内编辑路由治理旋钮。只接受治理字段(不可改 target/pattern/method——那是路由身份, * 改动应走 SQL 迁移评审);enabled 可整体停用一行。成功后热重载并返回加载数。 */ + @Operation(operationId = "GatewayAdmin.updateRoute", summary = "编辑路由治理旋钮") @PutMapping("/routes/{id}") public Map updateRoute(@PathVariable("id") Long id, @RequestBody Map body) { @@ -148,15 +159,17 @@ public class InternalAdminController { return out; } - /** 目标地址表全量 */ + /** 目标地址表分页 */ + @Operation(operationId = "GatewayAdmin.targets", summary = "目标地址分页") @GetMapping("/targets") - public List targets() { - List all = targetRepo.findAll(); - all.sort((a, b) -> a.getTargetCode().compareTo(b.getTargetCode())); - return all; + public BasePageResult targets( + @RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex, + @RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) { + return toResult(targetRepo.findAll(pageRequest(pageIndex, pageSize, Sort.by("targetCode")))); } /** 行内编辑目标地址/启停;成功后热重载 target 快照(禁用/空白行按无行回落 yml 由 TargetRouter 承担) */ + @Operation(operationId = "GatewayAdmin.updateTarget", summary = "编辑目标地址/启停") @PutMapping("/targets/{id}") public Map updateTarget(@PathVariable("id") Long id, @RequestBody Map body) { @@ -181,32 +194,61 @@ public class InternalAdminController { return out; } - /** 台账倒序查询(limit 默认 50 上限 500;targetCode/callerAppKey 可空) */ + /** + * 台账倒序分页查询。{@code pageSize} 优先、上限 500;兼容旧 {@code limit} 参数 + * (未传 pageSize 时以 limit 作 pageSize)。targetCode/callerAppKey 可空=不过滤。 + */ + @Operation(operationId = "GatewayAdmin.logs", summary = "访问台账分页") @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; + public BasePageResult logs( + @RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + @RequestParam(value = "limit", required = false) Integer limit, + @RequestParam(value = "targetCode", required = false) String targetCode, + @RequestParam(value = "callerAppKey", required = false) String callerAppKey) { + int size = resolvePageSize(pageSize, limit); + int page = normalizePageIndex(pageIndex); + Page result = logRepo.pageLatest(emptyToNull(targetCode), emptyToNull(callerAppKey), + PageRequest.of(page - 1, size)); + return toResult(result); } /** - * 值域/参数错误 → 400 + JSON 错误体(页面 alert 展示)。本类抛出的 IllegalArgumentException + * 值域/参数错误 → HTTP 200 + {@code {code:2, msg}}(平台规范:业务错误不改 HTTP 状态码, + * 与 notice/userbase 的 GlobalExceptionHandler 行为一致)。本类抛出的 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; + @ExceptionHandler(IllegalArgumentException.class) + public ApiResponse badRequest(IllegalArgumentException e) { + return ApiResponse.fail(ErrorCode.PARAM_ERROR, e.getMessage()); + } + + private static BasePageResult toResult(Page page) { + return new BasePageResult( + page.getContent(), + (int) page.getTotalElements(), + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.hasNext()); + } + + private static PageRequest pageRequest(Integer pageIndex, Integer pageSize, Sort sort) { + return PageRequest.of(normalizePageIndex(pageIndex) - 1, normalizePageSize(pageSize), sort); + } + + private static int normalizePageIndex(Integer pageIndex) { + return pageIndex == null || pageIndex < 1 ? 1 : pageIndex; + } + + private static int normalizePageSize(Integer pageSize) { + return pageSize == null || pageSize < 1 ? 10 : pageSize; + } + + /** pageSize 优先;兼容旧 limit 参数,上限 500 */ + private static int resolvePageSize(Integer pageSize, Integer limit) { + Integer v = pageSize != null ? pageSize : limit; + return v == null || v < 1 ? 50 : Math.min(v, 500); } private static String text(Object v) { diff --git a/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java index d088400..b53b041 100644 --- a/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java +++ b/src/main/java/com/yunhai/platform/gateway/filter/RoutingFilter.java @@ -37,6 +37,16 @@ public class RoutingFilter implements Filter { */ public static final String ACTUATOR_PREFIX = "/actuator"; + /** + * OpenAPI 文档端点(B2):springdoc 的 {@code /v3/api-docs}、{@code /swagger-ui/**} 属网关自身路径, + * 须与 {@link #INTERNAL_PREFIX} 同级旁路——否则首段({@code v3} / {@code swagger-ui.html})会被 + * {@link TargetRouter} 当作 target 解析 → 未知 target 裸 404,文档端点不可达。 + */ + public static final String API_DOCS_PREFIX = "/v3/api-docs"; + public static final String SWAGGER_UI_PREFIX = "/swagger-ui"; + /** springdoc UI 传统入口;{@link #isSelfPath} 的 equals+前缀双条件均不覆盖此路径,须单独 equals 判定 */ + public static final String SWAGGER_UI_HTML = "/swagger-ui.html"; + /** 路由产物 attribute({@link TargetRouter.Routing}) */ public static final String ATTR_ROUTING = "yh.gateway.routing"; /** 注册表策略 attribute({@link RouteSpec}) */ @@ -89,7 +99,11 @@ public class RoutingFilter implements Filter { } static boolean isGatewayInternal(String uri) { - return isSelfPath(uri, INTERNAL_PREFIX) || isSelfPath(uri, ACTUATOR_PREFIX); + return isSelfPath(uri, INTERNAL_PREFIX) + || isSelfPath(uri, ACTUATOR_PREFIX) + || isSelfPath(uri, API_DOCS_PREFIX) + || isSelfPath(uri, SWAGGER_UI_PREFIX) + || (uri != null && uri.equals(SWAGGER_UI_HTML)); } private static boolean isSelfPath(String uri, String prefix) { diff --git a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java index a803ad6..262dc5f 100644 --- a/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java +++ b/src/main/java/com/yunhai/platform/gateway/route/RouteRefreshController.java @@ -1,5 +1,7 @@ package com.yunhai.platform.gateway.route; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -12,6 +14,7 @@ import java.util.Map; * 网关内部管理端点(T0.10):/api/internal/** 属网关自身路径,收网后仅容器网内可达, * 不注册进 open_api_route(注册表只治理 /{target}/oapi/**)。 */ +@Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账") @RestController @RequestMapping("/api/internal") public class RouteRefreshController { @@ -20,6 +23,7 @@ public class RouteRefreshController { private RouteRegistry routeRegistry; /** 清缓存重 load,返回加载数(routes = 路由条目,targets = DB 地址行,E11 同端点重载) */ + @Operation(operationId = "GatewayAdmin.refresh", summary = "热重载路由注册表与目标地址") @PostMapping("/routes/refresh") public Map refresh() { Map body = new LinkedHashMap<>(); diff --git a/src/main/resources/static/admin.html b/src/main/resources/static/admin.html index 26b72e7..1f3d09c 100644 --- a/src/main/resources/static/admin.html +++ b/src/main/resources/static/admin.html @@ -106,6 +106,7 @@ function toast(msg, isErr) { setTimeout(function(){ t.className = ''; }, isErr ? 4200 : 2200); } +/* 管理面响应 = 平台信封 {code,msg,data}(yh-api-std 统一包装);成功时把 data 交给调用方 */ function api(method, url, body) { var opt = { method: method, headers: {} }; if (body !== undefined) { @@ -113,9 +114,10 @@ function api(method, url, body) { opt.body = JSON.stringify(body); } return fetch(url, opt).then(function(r) { - return r.json().catch(function(){ return {}; }).then(function(j) { - if (!r.ok) { throw new Error(j.msg || ('HTTP ' + r.status)); } - return j; + if (!r.ok) { throw new Error('HTTP ' + r.status); } + return r.json().then(function(j) { + if (j.code !== 0) { throw new Error(j.msg || ('业务错误 code=' + j.code)); } + return j.data; }); }); } @@ -128,7 +130,8 @@ function esc(s) { /* ---------- 路由表 ---------- */ function loadRoutes() { - return api('GET', 'routes').then(function(rows) { + return api('GET', 'routes?pageIndex=1&pageSize=200').then(function(pg) { + var rows = (pg && pg.content) || []; var tb = $('routeT').tBodies[0]; tb.innerHTML = rows.map(function(r) { return routeRow(r); }).join(''); }); @@ -167,13 +170,14 @@ function saveRoute(btn) { else { body[k] = el.value; } }); api('PUT', 'routes/' + tr.dataset.id, body) - .then(function(j) { toast('已保存,注册表加载 ' + j.loaded + ' 条路由'); }) + .then(function(d) { toast('已保存,注册表加载 ' + d.loaded + ' 条路由'); }) .catch(function(e) { toast('保存失败:' + e.message, true); }); } /* ---------- 目标表 ---------- */ function loadTargets() { - return api('GET', 'targets').then(function(rows) { + return api('GET', 'targets?pageIndex=1&pageSize=200').then(function(pg) { + var rows = (pg && pg.content) || []; var tb = $('tgtT').tBodies[0]; tb.innerHTML = rows.map(function(t) { return '' @@ -195,23 +199,25 @@ function saveTarget(btn) { body[k] = el.type === 'checkbox' ? el.checked : el.value; }); api('PUT', 'targets/' + tr.dataset.id, body) - .then(function(j) { toast('已保存,目标表加载 ' + j.targets + ' 条'); }) + .then(function(d) { toast('已保存,目标表加载 ' + d.targets + ' 条'); }) .catch(function(e) { toast('保存失败:' + e.message, true); }); } /* ---------- 台账 ---------- */ function loadLogs() { - var q = 'logs?limit=' + (parseInt($('qLimit').value || '50', 10) || 50); + var size = parseInt($('qLimit').value || '50', 10) || 50; + var q = 'logs?pageIndex=1&pageSize=' + size; if ($('qTarget').value.trim()) q += '&targetCode=' + encodeURIComponent($('qTarget').value.trim()); if ($('qCaller').value.trim()) q += '&callerAppKey=' + encodeURIComponent($('qCaller').value.trim()); - return api('GET', q).then(function(j) { - $('logTotal').textContent = '共 ' + j.total + ' 条'; + return api('GET', q).then(function(pg) { + $('logTotal').textContent = '共 ' + pg.total + ' 条'; + var rows = (pg && pg.content) || []; var tb = $('logT').tBodies[0]; - if (!j.rows || !j.rows.length) { + if (!rows.length) { tb.innerHTML = '无匹配记录'; return; } - tb.innerHTML = j.rows.map(function(l) { + tb.innerHTML = rows.map(function(l) { var st = l.status; var cls = st >= 500 ? 'st5xx' : (st >= 400 ? 'st4xx' : 'st2'); return '' @@ -230,7 +236,7 @@ function loadLogs() { /* ---------- 顶部 ---------- */ function reloadRegistry() { api('POST', '../routes/refresh') - .then(function(j) { toast('注册表已重载:路由 ' + j.loaded + ' 条 / 目标 ' + j.targets + ' 条'); }) + .then(function(d) { toast('注册表已重载:路由 ' + d.loaded + ' 条 / 目标 ' + d.targets + ' 条'); }) .catch(function(e) { toast('重载失败:' + e.message, true); }); } diff --git a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java index 1bf4bdd..99749f8 100644 --- a/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java +++ b/src/test/java/com/yunhai/platform/gateway/admin/InternalAdminControllerTest.java @@ -15,7 +15,6 @@ 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; @@ -30,7 +29,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. /** * 治理小页后端(N02 TDD):page/routes/targets/logs 查询形态 + 路由/目标行内编辑的 - * 值域闸(非法值 400 且不落库)+ 写后热重载调用。MockMvc standalone + mock 仓储/注册表。 + * 值域闸(非法值不落库)+ 写后热重载调用。MockMvc standalone + mock 仓储/注册表。 + *

+ * 契约说明(B3/B4 后):查询接口返回 {@code BasePageResult}({@code {total, content, ...}}), + * 非法入参返回 HTTP 200 + {@code {code:2, msg}}(平台规范)。standalone 模式不装配 + * {@code yh-api-std} 的 {@code ApiResponseAdvice},故成功响应在本测试中保持裸结构; + * 线上由 advice 统一再包一层 {@code {code,msg,data}}(分页体落在 {@code data})。 */ class InternalAdminControllerTest { @@ -51,8 +55,10 @@ class InternalAdminControllerTest { 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(routeRepo.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(Arrays.asList(route(1L)))); + when(targetRepo.findAll(any(Pageable.class))) + .thenReturn(new PageImpl<>(Arrays.asList(target(5L)))); when(registry.reload()).thenReturn(22); when(registry.reloadTargets()).thenReturn(13); when(logRepo.pageLatest(any(), any(), any(Pageable.class))) @@ -73,8 +79,19 @@ class InternalAdminControllerTest { 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/**")); + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.pageIndex").value(1)) + .andExpect(jsonPath("$.content[0].targetCode").value("userbase")) + .andExpect(jsonPath("$.content[0].pathPattern").value("/oapi/user/**")); + } + + @Test + void targets_paged() throws Exception { + mvc.perform(get("/api/internal/admin/targets?pageIndex=1&pageSize=20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)) + .andExpect(jsonPath("$.content[0].targetCode").value("notice")); + verify(targetRepo).findAll(Mockito.argThat(p -> p.getPageSize() == 20 && p.getPageNumber() == 0)); } @Test @@ -90,34 +107,37 @@ class InternalAdminControllerTest { } @Test - void routeUpdate_illegalAuthMode_400AndNoSave() throws Exception { + void routeUpdate_illegalAuthMode_bizErrorAndNoSave() 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(status().isOk()) + .andExpect(jsonPath("$.code").value(2)) .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 { + void routeUpdate_negativeTtl_bizError() 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()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); verify(routeRepo, never()).save(any()); } @Test - void routeUpdate_missingRow_400() throws Exception { + void routeUpdate_missingRow_bizError() 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()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); } @Test @@ -133,12 +153,13 @@ class InternalAdminControllerTest { } @Test - void targetUpdate_blankBaseUrl_400NoSave() throws Exception { + void targetUpdate_blankBaseUrl_bizErrorNoSave() 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()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); verify(targetRepo, never()).save(any()); } @@ -147,10 +168,18 @@ class InternalAdminControllerTest { 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")); + .andExpect(jsonPath("$.content[0].targetCode").value("notice")); verify(logRepo).pageLatest(eq("notice"), eq("100001"), any(Pageable.class)); } + @Test + void logs_pageIndexAndSize() throws Exception { + mvc.perform(get("/api/internal/admin/logs?pageIndex=2&pageSize=20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.total").value(1)); + verify(logRepo).pageLatest(any(), any(), Mockito.argThat(p -> p.getPageSize() == 20 && p.getPageNumber() == 1)); + } + @Test void logs_overLimitCappedTo500() throws Exception { mvc.perform(get("/api/internal/admin/logs?limit=9999"))