Compare commits
2
Commits
50f9948b49
...
ce4c3cc5a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4c3cc5a6 | ||
|
|
06b0f4ed23 |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>网关治理</title>
|
||||
<style>
|
||||
:root { --line:#e2e8f0; --txt:#1a202c; --sub:#718096; --ok:#276749; --err:#c53030; }
|
||||
* { box-sizing:border-box; }
|
||||
body { font:14px/1.6 -apple-system,"Segoe UI","Microsoft YaHei",sans-serif; color:var(--txt);
|
||||
margin:0; background:#f7fafc; }
|
||||
header { background:#fff; border-bottom:1px solid var(--line); padding:14px 24px;
|
||||
display:flex; align-items:center; gap:16px; position:sticky; top:0; z-index:5; }
|
||||
header h1 { font-size:17px; margin:0; }
|
||||
header .sp { flex:1; }
|
||||
button { padding:5px 14px; border:1px solid var(--line); background:#fff; border-radius:6px;
|
||||
cursor:pointer; font-size:13px; }
|
||||
button:hover { background:#edf2f7; }
|
||||
button.primary { background:#2b6cb0; color:#fff; border-color:#2b6cb0; }
|
||||
main { max-width:1280px; margin:0 auto; padding:20px 24px 60px; }
|
||||
section { background:#fff; border:1px solid var(--line); border-radius:8px; margin-bottom:22px;
|
||||
overflow:hidden; }
|
||||
section > h2 { font-size:15px; margin:0; padding:12px 18px; border-bottom:1px solid var(--line);
|
||||
background:#fbfcfd; display:flex; align-items:center; gap:10px; }
|
||||
section > h2 .sp { flex:1; }
|
||||
.bar { padding:10px 18px; display:flex; gap:10px; align-items:center; border-bottom:1px solid var(--line);
|
||||
flex-wrap:wrap; }
|
||||
.bar label { color:var(--sub); font-size:13px; }
|
||||
.bar input, .bar select { padding:4px 8px; border:1px solid var(--line); border-radius:5px; font-size:13px; }
|
||||
table { border-collapse:collapse; width:100%; font-size:13px; }
|
||||
th, td { padding:7px 10px; border-bottom:1px solid var(--line); text-align:left;
|
||||
white-space:nowrap; max-width:340px; overflow:hidden; text-overflow:ellipsis; }
|
||||
th { color:var(--sub); font-weight:600; background:#fbfcfd; }
|
||||
td input, td select { padding:3px 6px; border:1px solid var(--line); border-radius:4px;
|
||||
font-size:12.5px; width:100%; }
|
||||
td .num { width:72px; }
|
||||
.tag { display:inline-block; padding:1px 8px; border-radius:10px; font-size:12px; }
|
||||
.tag.on { background:#c6f6d5; color:var(--ok); }
|
||||
.tag.off { background:#fed7d7; color:var(--err); }
|
||||
.st2 { color:var(--ok); } .st4xx { color:#b7791f; } .st5xx { color:var(--err); }
|
||||
.idle { color:var(--sub); padding:26px; text-align:center; }
|
||||
#toast { position:fixed; left:50%; bottom:34px; transform:translateX(-50%); background:#2d3748;
|
||||
color:#fff; padding:9px 20px; border-radius:8px; font-size:13px; opacity:0;
|
||||
transition:opacity .25s; pointer-events:none; max-width:70vw; }
|
||||
#toast.show { opacity:.96; }
|
||||
#toast.err { background:var(--err); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>网关治理</h1>
|
||||
<span class="sp"></span>
|
||||
<span id="stat" style="color:var(--sub);font-size:12.5px"></span>
|
||||
<button class="primary" onclick="reloadRegistry()">重载注册表</button>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
<section>
|
||||
<h2>路由治理旋钮 <span class="sp"></span>
|
||||
<span style="color:var(--sub);font-weight:400;font-size:12px">值域 visibility=INTERNAL/EXTERNAL · authMode=SIGNED/ANONYMOUS · cacheScope=CALLER/GLOBAL</span></h2>
|
||||
<div class="tableWrap"><table id="routeT">
|
||||
<thead><tr>
|
||||
<th>id</th><th>target</th><th>path</th><th>method</th>
|
||||
<th style="width:110px">visibility</th><th style="width:120px">authMode</th>
|
||||
<th style="width:80px">cacheTtl</th><th style="width:100px">cacheScope</th>
|
||||
<th style="width:80px">qps</th><th>启用</th><th>备注</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>目标地址</h2>
|
||||
<div class="tableWrap"><table id="tgtT">
|
||||
<thead><tr><th>id</th><th>target</th><th>base_url</th><th>启用</th><th>更新时间</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>访问台账</h2>
|
||||
<div class="bar">
|
||||
<label>条数</label><input id="qLimit" type="number" value="50" min="1" max="500" style="width:76px">
|
||||
<label>target</label><input id="qTarget" placeholder="如 notice" style="width:110px">
|
||||
<label>caller</label><input id="qCaller" placeholder="如 100001" style="width:110px">
|
||||
<button onclick="loadLogs()">查询</button>
|
||||
<span id="logTotal" style="color:var(--sub);font-size:12px"></span>
|
||||
</div>
|
||||
<div class="tableWrap"><table id="logT">
|
||||
<thead><tr><th>id</th><th>时间</th><th>caller</th><th>target</th><th>method</th>
|
||||
<th>path</th><th>status</th><th>backend</th><th>cost(ms)</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
var $ = function(id){ return document.getElementById(id); };
|
||||
|
||||
function toast(msg, isErr) {
|
||||
var t = $('toast');
|
||||
t.textContent = msg;
|
||||
t.className = 'show' + (isErr ? ' err' : '');
|
||||
setTimeout(function(){ t.className = ''; }, isErr ? 4200 : 2200);
|
||||
}
|
||||
|
||||
function api(method, url, body) {
|
||||
var opt = { method: method, headers: {} };
|
||||
if (body !== undefined) {
|
||||
opt.headers['Content-Type'] = 'application/json';
|
||||
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;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s == null ? '' : String(s).replace(/[&<>"]/g, function(c) {
|
||||
return { '&':'&', '<':'<', '>':'>', '"':'"' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 路由表 ---------- */
|
||||
function loadRoutes() {
|
||||
return api('GET', 'routes').then(function(rows) {
|
||||
var tb = $('routeT').tBodies[0];
|
||||
tb.innerHTML = rows.map(function(r) { return routeRow(r); }).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function routeRow(r) {
|
||||
return '<tr data-id="' + r.id + '">'
|
||||
+ '<td>' + r.id + '</td><td><b>' + esc(r.targetCode) + '</b></td>'
|
||||
+ '<td title="' + esc(r.pathPattern) + '">' + esc(r.pathPattern) + '</td>'
|
||||
+ '<td>' + esc(r.httpMethod) + '</td>'
|
||||
+ '<td><select data-k="visibility">'
|
||||
+ opt('INTERNAL', r.visibility) + opt('EXTERNAL', r.visibility) + '</select></td>'
|
||||
+ '<td><select data-k="authMode">'
|
||||
+ opt('SIGNED', r.authMode) + opt('ANONYMOUS', r.authMode) + '</select></td>'
|
||||
+ '<td><input class="num" type="number" min="0" data-k="cacheTtlSeconds" value="' + r.cacheTtlSeconds + '"></td>'
|
||||
+ '<td><select data-k="cacheScope">'
|
||||
+ opt('CALLER', r.cacheScope) + opt('GLOBAL', r.cacheScope) + '</select></td>'
|
||||
+ '<td><input class="num" type="number" min="0" data-k="rateLimitQps" value="' + (r.rateLimitQps || 0) + '"></td>'
|
||||
+ '<td><input type="checkbox" data-k="enabled"' + (r.enabled ? ' checked' : '') + '></td>'
|
||||
+ '<td><input data-k="remark" value="' + esc(r.remark || '') + '" placeholder="-"></td>'
|
||||
+ '<td><button onclick="saveRoute(this)">保存</button></td>'
|
||||
+ '</tr>';
|
||||
}
|
||||
|
||||
function opt(v, cur) {
|
||||
return '<option value="' + v + '"' + (v === cur ? ' selected' : '') + '>' + v + '</option>';
|
||||
}
|
||||
|
||||
function saveRoute(btn) {
|
||||
var tr = btn.closest('tr');
|
||||
var body = {};
|
||||
tr.querySelectorAll('[data-k]').forEach(function(el) {
|
||||
var k = el.getAttribute('data-k');
|
||||
if (el.type === 'checkbox') { body[k] = el.checked; }
|
||||
else if (el.type === 'number') { body[k] = parseInt(el.value || '0', 10); }
|
||||
else { body[k] = el.value; }
|
||||
});
|
||||
api('PUT', 'routes/' + tr.dataset.id, body)
|
||||
.then(function(j) { toast('已保存,注册表加载 ' + j.loaded + ' 条路由'); })
|
||||
.catch(function(e) { toast('保存失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
/* ---------- 目标表 ---------- */
|
||||
function loadTargets() {
|
||||
return api('GET', 'targets').then(function(rows) {
|
||||
var tb = $('tgtT').tBodies[0];
|
||||
tb.innerHTML = rows.map(function(t) {
|
||||
return '<tr data-id="' + t.id + '">'
|
||||
+ '<td>' + t.id + '</td><td><b>' + esc(t.targetCode) + '</b></td>'
|
||||
+ '<td><input data-k="baseUrl" value="' + esc(t.baseUrl) + '" style="width:300px"></td>'
|
||||
+ '<td><input type="checkbox" data-k="enabled"' + (t.enabled ? ' checked' : '') + '></td>'
|
||||
+ '<td>' + esc((t.gmtModified || '').replace('T', ' ').slice(0, 19)) + '</td>'
|
||||
+ '<td><button onclick="saveTarget(this)">保存</button></td>'
|
||||
+ '</tr>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function saveTarget(btn) {
|
||||
var tr = btn.closest('tr');
|
||||
var body = {};
|
||||
tr.querySelectorAll('[data-k]').forEach(function(el) {
|
||||
var k = el.getAttribute('data-k');
|
||||
body[k] = el.type === 'checkbox' ? el.checked : el.value;
|
||||
});
|
||||
api('PUT', 'targets/' + tr.dataset.id, body)
|
||||
.then(function(j) { toast('已保存,目标表加载 ' + j.targets + ' 条'); })
|
||||
.catch(function(e) { toast('保存失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
/* ---------- 台账 ---------- */
|
||||
function loadLogs() {
|
||||
var q = 'logs?limit=' + (parseInt($('qLimit').value || '50', 10) || 50);
|
||||
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 + ' 条';
|
||||
var tb = $('logT').tBodies[0];
|
||||
if (!j.rows || !j.rows.length) {
|
||||
tb.innerHTML = '<tr><td colspan="9" class="idle">无匹配记录</td></tr>';
|
||||
return;
|
||||
}
|
||||
tb.innerHTML = j.rows.map(function(l) {
|
||||
var st = l.status;
|
||||
var cls = st >= 500 ? 'st5xx' : (st >= 400 ? 'st4xx' : 'st2');
|
||||
return '<tr>'
|
||||
+ '<td>' + l.id + '</td>'
|
||||
+ '<td>' + esc((l.ts || '').replace('T', ' ').slice(0, 23)) + '</td>'
|
||||
+ '<td>' + esc(l.callerAppKey == null ? '-' : l.callerAppKey) + '</td>'
|
||||
+ '<td>' + esc(l.targetCode) + '</td><td>' + esc(l.method) + '</td>'
|
||||
+ '<td title="' + esc(l.path) + '">' + esc(l.path) + '</td>'
|
||||
+ '<td class="' + cls + '">' + st + '</td>'
|
||||
+ '<td>' + (l.backendCode == null ? '-' : l.backendCode) + '</td>'
|
||||
+ '<td>' + l.costMs + '</td></tr>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- 顶部 ---------- */
|
||||
function reloadRegistry() {
|
||||
api('POST', '../routes/refresh')
|
||||
.then(function(j) { toast('注册表已重载:路由 ' + j.loaded + ' 条 / 目标 ' + j.targets + ' 条'); })
|
||||
.catch(function(e) { toast('重载失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
Promise.all([loadRoutes(), loadTargets(), loadLogs()])
|
||||
.then(function() { $('stat').textContent = '更新于 ' + new Date().toLocaleTimeString(); })
|
||||
.catch(function(e) { toast('加载失败:' + e.message, true); });
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
</script>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user