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:
AskaEth committed 2026-10-03 21:31:09 +08:00
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))
}
}