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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user