feat(admin): 「最近日志」接上 logs REST 引导读取 —— 补齐 W10 只显示进页面之后那段日志的缺口

W10 接上的 `observability_log` 帧以「订阅那一刻的最新条目」为锚、不回灌历史,而客户端没有
`GET /api/v1/admin/observability/logs` 的接入 ⇒ 概览页的「最近日志」只能显示进页面**之后**产生的
条目。本次把 REST 引导读取补上,并把 W10 那条取舍(「一帧都没到过 → 整段不渲染」,为的是不把
「没有推送」说成「没有数据」)改成契约要求的「**REST 成功即渲染、帧是增量**」。

- **取数**:`AdminApi` + `AdminRepository` 接上该端点,显式发 `limit=200`(与客户端保留窗口、
  帧的单帧上限同源);**不暴露** `?level=`——它是服务端「最低等级」过滤,只在服务端裁剪一次,
  会饿死界面上那排要来回切换的等级档,所以一律取全量、由客户端本地过滤(契约 §3.12 亦然)。
- **DTO 统一**:REST 与帧同形(后端同一个 `gateway.AdminLogEntry`),因此把
  `data/ws/ObservabilityLogEntry.kt` 挪进 `data/api/dto/AdminDto.kt` 并改名 `AdminLogEntryDto`
  (顺带与 `AdminMetricsDto` / `AdminTraceSpanDto` 对齐),删掉旧文件——不留下两份等价形状。
- **合并纯函数**:日志改成「去重 → 按 `time` 倒序 → 截窗口」。三步顺序不能换:窗口是**位置**
  截断,先截再排会让一条更旧的条目挤掉更新的那条;排序用解析出的 epoch 秒 + **稳定排序**保住
  同秒条目的到达顺序(服务端 `time` 只有秒精度);时间判不出来的条目落到尾部但**不丢**。
  trace 那条路径不动(span 有服务端写入序号,重排反而把顺序排乱)。
- **界面**:`logs` 改为可空——null = 还没有基线(不渲染);非 null(含空列表)= REST 读过且成功
  ⇒ 渲染,于是「加载中 / 不可用 / 暂无日志 / 列表」四种表现分明,未知等级条目照旧一律保留。
  三路的失败原因也分开记了:日志挂了不再让 trace 那块显示「不可用」(那是把一次成功说成失败)。
- **角色分档**:新增纯函数 `canReadObservabilityLogHistory`(日志历史与概览页同档,
  v0.40 的 viewerAuth 只读监控档 ⇒ **viewer 也读得到**),与只发 owner/admin 的帧门刻意不对称。
  ⇒ viewer 看得见历史、收不到增量,界面不得因此报错或报空(区块是否渲染只看历史这条门)。

测试 567 → 591(DTO 边界、合并去重/排序/窗口、角色分档、仓库线级请求与 501/403/坏载荷)。
`assembleDebug` 与 `testDebugUnitTest` 均通过(591 测试 / 0 失败 / 0 错误)。未做真机与真后端验收。
This commit is contained in:
AskaEth committed 2026-10-04 19:18:42 +08:00
1 parent e59ef08cf9
commit 642bdb13f0
15 files changed
+681 -119

No files matched your search

@@ -4,22 +4,25 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.AdminLogEntryDto
import top.yeij.cyrene.data.api.dto.AdminTraceSpanDto
import top.yeij.cyrene.data.ws.ObservabilityLogEntryDto
import top.yeij.cyrene.data.repository.AdminRepository
/**
* 可观测推送帧的合并与本地过滤(契约 v0.55 §3.12)的纯函数。
* 可观测面合并与本地过滤的纯函数(契约 §2「可观测性契约」+ §3.12)。
*
* 断言的是**三条不会写进类型里的语义**:新帧在前(列表两端本来就最新在前,不排序)、
* 按业务键去重(帧与 REST 竞态时不会出现两条同样的数据)、窗口截断(不许无界增长);
* 以及日志的等级过滤保留「判不出等级」的条目。
* 断言的是**四条不会写进类型里的语义**:
* 1. 合并的两端形态不同——REST 基线是**全量**、帧是**增量**,空增量不改任何东西;
* 2. 日志列表始终满足「**无重复 + 时间倒序 + 至多窗口条**」这个不变式(trace 不排序,见文件内说明);
* 3. 窗口是**位置**截断,所以必须排在排序**之后**,否则一条更旧的条目会挤掉更新的那条;
* 4. 日志的等级过滤保留「判不出等级」的条目。
*/
class AdminOverviewMergeTest {
private fun span(id: String) = AdminTraceSpanDto(id = id, name = "span-$id", startedAt = "2026-10-04T12:00:00Z")
private fun entry(msg: String, level: String = "info") =
ObservabilityLogEntryDto(time = "2026-10-04T12:00:00Z", level = level, source = "gateway", msg = msg)
private fun entry(msg: String, level: String = "info", time: String = "2026-10-04T12:00:00Z") =
AdminLogEntryDto(time = time, level = level, source = "gateway", msg = msg)
@Test
fun `an empty delta leaves the snapshot untouched`() {
@@ -57,13 +60,107 @@ class AdminOverviewMergeTest {
@Test
fun `log entries are deduplicated by their whole business key`() {
// 服务端两端都是**最新在前**,合并保留各自的相对顺序(不排序):帧里新到的在前,
// 已经在列表里的那条不会被重复追加。
val merged = mergeLogEntries(listOf(entry("启动")), listOf(entry("慢请求"), entry("启动")))
assertEquals(listOf("慢请求", "启动"), merged.map { it.msg })
}
@Test
fun `a rest baseline and the frame delta merge into one time ordered list`() {
// REST 基线(最新在前)+ 之后的帧增量:去重、按 time 倒序,同一条不出现两次。
val baseline = listOf(
entry("b3", time = "2026-10-04T12:00:03Z"),
entry("b2", time = "2026-10-04T12:00:02Z"),
entry("b1", time = "2026-10-04T12:00:01Z"),
)
val delta = listOf(
entry("f5", time = "2026-10-04T12:00:05Z"),
// 订阅握手与引导读取之间可能重叠:同一条既在快照里、又被帧带了一次。
entry("b3", time = "2026-10-04T12:00:03Z"),
)
val merged = mergeLogEntries(baseline, delta)
assertEquals(listOf("f5", "b3", "b2", "b1"), merged.map { it.msg })
}
@Test
fun `a stale entry arriving with a frame does not jump to the top`() {
// 拼接顺序不等于时间顺序:帧里若夹带一条**更旧**的条目(服务端锚点用的是纳秒内部时间、
// 下发的 time 只有秒精度,拼接口上就有这种可能),它必须落到该落的位置。
val merged = mergeLogEntries(
listOf(entry("new", time = "2026-10-04T12:00:09Z")),
listOf(entry("old", time = "2026-10-04T12:00:01Z")),
)
assertEquals(listOf("new", "old"), merged.map { it.msg })
}
@Test
fun `the window keeps the newest by time, not the ones that arrived last`() {
// 窗口是**位置**截断 ⇒ 必须先排序再截断。否则这条更旧的 "old" 会占掉一格、把 b2 挤掉。
val merged = mergeLogEntries(
existing = listOf(entry("b1", time = "2026-10-04T12:00:05Z"), entry("b2", time = "2026-10-04T12:00:04Z")),
incoming = listOf(entry("old", time = "2026-10-04T12:00:03Z")),
window = 2,
)
assertEquals(listOf("b1", "b2"), merged.map { it.msg })
}
@Test
fun `entries sharing a second keep their arrival order`() {
// time 只有秒精度,一秒内多条是常态:稳定排序必须保住到达顺序(新到的在前),
// 否则同一秒里的时间线会被排乱。
val merged = mergeLogEntries(
existing = listOf(entry("先", time = "2026-10-04T12:00:07Z")),
incoming = listOf(entry("后", time = "2026-10-04T12:00:07Z")),
)
assertEquals(listOf("后", "先"), merged.map { it.msg })
}
@Test
fun `an unparsable time sinks to the tail but is never dropped`() {
val entries = listOf(
entry("没有时间", time = ""),
entry("有时间", time = "2026-10-04T12:00:01Z"),
entry("时间坏了", time = "not-a-time"),
)
val normalized = normalizeLogEntries(entries)
assertEquals(listOf("有时间", "没有时间", "时间坏了"), normalized.map { it.msg })
}
@Test
fun `a unix millisecond timestamp is not mistaken for an rfc3339 one`() {
// 两种时间形状刻意不共用解析器:1730721600000 是**毫秒**,按 RFC3339 解析必须失败
//(否则会被当成「有时间的条目」排到前面,把真实顺序排乱)。
val normalized = normalizeLogEntries(
listOf(
entry("毫秒形状", time = "1730721600000"),
entry("真时间", time = "2026-10-04T12:00:01Z"),
),
)
assertEquals(listOf("真时间", "毫秒形状"), normalized.map { it.msg })
}
@Test
fun `normalizing a rest snapshot is idempotent`() {
// 服务端本来就给「最新在前」,归一化不该改变它;再归一化一次也必须一样(不变式自洽)。
val snapshot = listOf(
entry("a", time = "2026-10-04T12:00:03Z"),
entry("b", time = "2026-10-04T12:00:02Z"),
)
val once = normalizeLogEntries(snapshot)
assertEquals(snapshot, once)
assertEquals(once, normalizeLogEntries(once))
}
@Test
fun `log entries differing only in level are distinct`() {
val merged = mergeLogEntries(
@@ -131,5 +228,8 @@ class AdminOverviewMergeTest {
assertEquals(100, TRACE_WINDOW)
assertEquals(200, LOG_WINDOW)
assertTrue(TRACE_WINDOW > 0)
// 「向服务端要多少条」与「留多少条」必须同源:不一致就是悄悄丢数据。
assertEquals(AdminRepository.DEFAULT_LOG_LIMIT, LOG_WINDOW)
assertEquals(AdminRepository.DEFAULT_TRACE_LIMIT, TRACE_WINDOW)
}
}