feat(admin): 新增 C-2 管理员只读监控(概览/人格/记忆/后台思考)
侧边栏「管理员监控」进入的独立管理区 + 独立 Scaffold,四页全部只读, 不调用任何 /admin/* 写端点。 - data:AdminApi(5 条只读 GET)+ AdminDto + AdminRepository, 经 ApiClientFactory 创建以复用既有 token / 401 处理 - domain:AdminAccess.kt 为权限真源(纯函数,便于单测); AdminFormat.kt 把 RFC3339 与 Unix 毫秒两种时间形状分开解析 - ui:AdminHomeScreen 做成可加页的壳(加页 = AdminPage 加一条枚举), 四个子页 + 统一失败态;列表项一律 PreferenceCard + BasicComponent - RBAC:后端契约要求客户端自行降级(api-client §2.0),按矩阵分档—— viewer 只能进概览/人格;入口禁用 + ViewModel 短路(不发请求)+ 服务端 403 也渲染「无权限」态,三层 fail-closed; 任何失败都不渲染成「暂无数据」 为什么:M0–M5 + 设置页 C-1 已完成,C-2 是路线上的下一项;后端 5 个读端点 均已实现(非 501),且手机端后续要走「全功能管理」(后端工作小结 主线②, 凭据前置已完成),故管理区做成可扩展的壳而非只读死胡同。 测试:74 个单测全绿,其中新增 29 个 (AdminAccessTest 9 / AdminFormatTest 13 / AdminDtoDecodingTest 7)
This commit is contained in:
1 parent
9bc45dc315
commit
a7b3452e6d
19 files changed
+1672
-6
No files matched your search
@@ -0,0 +1,155 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.int
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
|
||||
/**
|
||||
* 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2):
|
||||
* `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。
|
||||
*/
|
||||
class AdminDtoDecodingTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val traceSpans = ListSerializer(AdminTraceSpanDto.serializer())
|
||||
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
|
||||
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
|
||||
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
|
||||
|
||||
@Test
|
||||
fun `metrics envelope decodes integer-shaped doubles and count maps`() {
|
||||
val body = """
|
||||
{"data":{"totalRequests":12,"qps":3,"avgLatencyMs":10,"maxLatencyMs":40,
|
||||
"perMethod":{"GET":12},"perPath":{"/api/v1/x":12},"statusCounts":{"200":11,"500":1},
|
||||
"llmCalls":2,"subagentEvents":0}}
|
||||
""".trimIndent()
|
||||
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), body)
|
||||
.data!!
|
||||
|
||||
assertEquals(12L, data.totalRequests)
|
||||
assertEquals(3.0, data.qps, 0.0)
|
||||
assertEquals(10.0, data.avgLatencyMs, 0.0)
|
||||
assertEquals(mapOf("GET" to 12L), data.perMethod)
|
||||
assertEquals(mapOf("200" to 11L, "500" to 1L), data.statusCounts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metrics tolerates missing optional fields`() {
|
||||
val data = json
|
||||
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), """{"data":{}}""")
|
||||
.data!!
|
||||
|
||||
assertEquals(0L, data.totalRequests)
|
||||
assertTrue(data.perMethod.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trace span keeps unknown kind and arbitrary props`() {
|
||||
val body = """
|
||||
{"data":[{"id":"s1","parentId":"s0","name":"loop","kind":"quantum_hop","status":"ok",
|
||||
"startedAt":"2026-03-04T04:34:56Z","endedAt":"2026-03-04T04:34:57Z","durationNs":1000000000,
|
||||
"props":{"model":"gpt","n":3,"flag":true},
|
||||
"events":[{"time":"2026-03-04T04:34:56.123456789Z","type":"retry","props":{"attempt":2}}]}]}
|
||||
""".trimIndent()
|
||||
|
||||
val span = json
|
||||
.decodeFromString(ApiEnvelope.serializer(traceSpans), body)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertEquals("quantum_hop", span.kind)
|
||||
assertEquals("s0", span.parentId)
|
||||
assertEquals(1_000_000_000L, span.durationNs)
|
||||
val props = span.props!!
|
||||
assertTrue(props.isNotEmpty())
|
||||
assertEquals("gpt", (props["model"] as JsonPrimitive).content)
|
||||
assertEquals(3, (props["n"] as JsonPrimitive).int)
|
||||
assertTrue((props["flag"] as JsonPrimitive).boolean)
|
||||
val events = span.events!!
|
||||
assertEquals(1, events.size)
|
||||
assertEquals("retry", events.single().type)
|
||||
assertEquals(2, (events.single().props!!["attempt"] as JsonPrimitive).int)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `trace span tolerates missing parent and events`() {
|
||||
val span = json
|
||||
.decodeFromString(
|
||||
ApiEnvelope.serializer(traceSpans),
|
||||
"""{"data":[{"id":"s1","name":"loop","kind":"llm_call","status":"error","startedAt":"2026-03-04T04:34:56Z","durationNs":1}]}""",
|
||||
)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertNull(span.parentId)
|
||||
assertNull(span.endedAt)
|
||||
assertNull(span.props)
|
||||
assertNull(span.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persona thinking is absent when unconfigured and keeps tri-state when present`() {
|
||||
val body = """
|
||||
{"data":[
|
||||
{"id":"cyrene","name":"昔涟","character":"memokeeper","isDefault":true,"mode":"chat",
|
||||
"memoryNamespace":"cyrene","mood":"",
|
||||
"thinking":{"budgetTokens":0,"effort":"","showToUser":null,"mode":""}},
|
||||
{"id":"other","name":"?","character":"","isDefault":false,"mode":"telepathy",
|
||||
"memoryNamespace":"","mood":"euphoric"}]}
|
||||
""".trimIndent()
|
||||
|
||||
val personas = json
|
||||
.decodeFromString(ApiEnvelope.serializer(personaList), body)
|
||||
.data!!
|
||||
|
||||
assertEquals(2, personas.size)
|
||||
val configured = personas[0].thinking!!
|
||||
assertNull("null 不能拍平成 false", configured.showToUser)
|
||||
assertEquals(0, configured.budgetTokens)
|
||||
|
||||
assertNull("未配置的人格 thinking 键完全不出现", personas[1].thinking)
|
||||
assertEquals("euphoric", personas[1].mood)
|
||||
// 未知 mode 原样保留,不丢弃、不替换占位符。
|
||||
assertEquals("telepathy", personas[1].mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `memory scope and backthink millis decode as declared`() {
|
||||
val memories = json
|
||||
.decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"shared","text":"hi"}]}""")
|
||||
.data!!
|
||||
assertEquals("shared", memories.single().scope)
|
||||
|
||||
val backthink = json
|
||||
.decodeFromString(
|
||||
ApiEnvelope.serializer(backthinkList),
|
||||
"""{"data":[{"id":"7","trigger":"cron","shouldNotify":true,"pushed":false,"at":1772598896000}]}""",
|
||||
)
|
||||
.data!!
|
||||
.single()
|
||||
|
||||
assertNull(backthink.conclusion)
|
||||
assertTrue(backthink.shouldNotify)
|
||||
assertFalse(backthink.pushed)
|
||||
assertEquals(1_772_598_896_000L, backthink.at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown scope is preserved raw instead of being dropped`() {
|
||||
val memories = json
|
||||
.decodeFromString(ApiEnvelope.serializer(memoryList), """{"data":[{"id":"m1","scope":"galaxy","text":"hi"}]}""")
|
||||
.data!!
|
||||
assertEquals("galaxy", memories.single().scope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** 管理页权限矩阵(api-client §2.0)的客户端降级判定。 */
|
||||
class AdminAccessTest {
|
||||
|
||||
@Test
|
||||
fun `owner can use every page`() {
|
||||
AdminPage.entries.forEach { page ->
|
||||
assertTrue(page.name, canAccess(AdminRole.Owner, page))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin can use every page`() {
|
||||
AdminPage.entries.forEach { page ->
|
||||
assertTrue(page.name, canAccess(AdminRole.Admin, page))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewer can read monitor pages only`() {
|
||||
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Overview))
|
||||
assertTrue(canAccess(AdminRole.Viewer, AdminPage.Personas))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `viewer cannot read memories or backthink`() {
|
||||
assertFalse(canAccess(AdminRole.Viewer, AdminPage.Memories))
|
||||
assertFalse(canAccess(AdminRole.Viewer, AdminPage.Backthink))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown role degrades to viewer`() {
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of("superowner"))
|
||||
assertFalse(canAccess(AdminRole.of("superowner"), AdminPage.Memories))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null and blank role degrade to viewer`() {
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of(null))
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `role parsing tolerates case and surrounding blanks`() {
|
||||
assertEquals(AdminRole.Owner, AdminRole.of(" OWNER "))
|
||||
assertEquals(AdminRole.Admin, AdminRole.of("Admin"))
|
||||
assertEquals(AdminRole.Viewer, AdminRole.of("viewer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `available pages follow the declared order`() {
|
||||
assertEquals(
|
||||
listOf(AdminPage.Overview, AdminPage.Personas),
|
||||
availablePages(AdminRole.Viewer),
|
||||
)
|
||||
assertEquals(AdminPage.entries.toList(), availablePages(AdminRole.Owner))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `monitor tier is weaker than owner admin tier`() {
|
||||
assertTrue(AdminTier.Monitor.rank < AdminTier.OwnerAdmin.rank)
|
||||
assertTrue(AdminRole.Viewer.rank < AdminRole.Admin.rank)
|
||||
assertTrue(AdminRole.Admin.rank < AdminRole.Owner.rank)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package top.yeij.cyrene.domain.admin
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 两种时间形状 + 未知枚举回落 + 三态展示。
|
||||
*
|
||||
* 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成
|
||||
* `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。
|
||||
*/
|
||||
class AdminFormatTest {
|
||||
|
||||
private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""")
|
||||
|
||||
@Test
|
||||
fun `offset and zulu spellings of the same instant agree`() {
|
||||
assertEquals(
|
||||
formatRfc3339("2026-03-04T04:34:56Z"),
|
||||
formatRfc3339("2026-03-04T12:34:56+08:00"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rfc3339 with nanosecond precision parses`() {
|
||||
assertEquals(
|
||||
formatRfc3339("2026-03-04T04:34:56Z"),
|
||||
formatRfc3339("2026-03-04T04:34:56.123456789Z"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rfc3339 renders as a local timestamp`() {
|
||||
val formatted = formatRfc3339("2026-03-04T04:34:56Z")!!
|
||||
assertTrue(formatted, timestamp.matches(formatted))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unparsable rfc3339 is returned raw instead of dropped`() {
|
||||
assertEquals("not-a-time", formatRfc3339("not-a-time"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank rfc3339 becomes null`() {
|
||||
assertNull(formatRfc3339(null))
|
||||
assertNull(formatRfc3339(" "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `backthink millis use the epoch-millis parser, not the rfc3339 one`() {
|
||||
// 同一时刻的两种形状:Unix 毫秒 1772598896000 = 2026-03-04T04:34:56Z
|
||||
assertEquals(
|
||||
formatRfc3339("2026-03-04T04:34:56Z"),
|
||||
formatEpochMillis(1_772_598_896_000L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nanos pick a unit by magnitude`() {
|
||||
assertEquals("512 ns", formatNanos(512))
|
||||
assertEquals("1.5 µs", formatNanos(1_500))
|
||||
assertEquals("2.5 ms", formatNanos(2_500_000))
|
||||
assertEquals("1.25 s", formatNanos(1_250_000_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counts are sorted by hits descending`() {
|
||||
assertEquals("GET 5 · POST 2", formatCounts(mapOf("POST" to 2L, "GET" to 5L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `counts keep unknown keys verbatim`() {
|
||||
assertEquals("999 1", formatCounts(mapOf("999" to 1L)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `whole doubles lose their decimals`() {
|
||||
assertEquals("12", formatDouble(12.0))
|
||||
assertEquals("0", formatDouble(0.0))
|
||||
assertEquals("1.5", formatDouble(1.5))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown enum values render raw`() {
|
||||
assertEquals("LLM 调用", traceKindLabel("llm_call"))
|
||||
assertEquals("quantum_hop", traceKindLabel("quantum_hop"))
|
||||
assertEquals("成功", traceStatusLabel("ok"))
|
||||
assertEquals("degraded", traceStatusLabel("degraded"))
|
||||
assertEquals("对话", personaModeLabel("chat"))
|
||||
assertEquals("telepathy", personaModeLabel("telepathy"))
|
||||
assertEquals("人格", memoryScopeLabel("persona"))
|
||||
assertEquals("shared", memoryScopeLabel("shared"))
|
||||
assertEquals("平静", moodLabel("calm"))
|
||||
assertEquals("euphoric", moodLabel("euphoric"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showToUser keeps three distinct states`() {
|
||||
assertEquals("未配置", triStateLabel(null))
|
||||
assertEquals("显示", triStateLabel(true))
|
||||
assertEquals("不显示", triStateLabel(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `props are rendered as compact json without losing keys`() {
|
||||
assertEquals(
|
||||
"""{"model":"gpt"}""",
|
||||
formatProps(mapOf<String, JsonElement>("model" to JsonPrimitive("gpt"))),
|
||||
)
|
||||
val two = formatProps(
|
||||
mapOf<String, JsonElement>(
|
||||
"a" to JsonPrimitive(1),
|
||||
"b" to JsonPrimitive(2),
|
||||
),
|
||||
)!!
|
||||
assertTrue(two, two.contains("\"a\":1") && two.contains("\"b\":2"))
|
||||
assertNull(formatProps(emptyMap()))
|
||||
assertNull(formatProps(null))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user