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