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)
}
}