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,60 @@
package top.yeij.cyrene.domain.admin
/**
* 管理侧权限矩阵(api-client §2.0)的**客户端镜像**。
*
* 后端契约明确要求客户端自行降级(`Docs/api-client.md`:「客户端需按本矩阵自行降级」):
* 不该读的页**不发请求**,而不是发出去等 403。这里的判断是纯函数,便于单测。
*/
/**
* 管理页可见性分档,按权限递增。
*
* - [Monitor]:api-client §2.0 的①档,`owner`/`admin`/`viewer` 均可读(只读非敏感投影)。
* - [OwnerAdmin]:②档,仅 `owner`/`admin`(含他人数据 / 隐私正文),`viewer` 一律 403。
*/
enum class AdminTier(val rank: Int) {
Monitor(0),
OwnerAdmin(1),
}
/** 已接线角色,`rank` 与后端 `owner>admin>viewer` 递增一致。 */
enum class AdminRole(val rank: Int) {
Viewer(0),
Admin(1),
Owner(2),
;
companion object {
/**
* 角色名解析。大小写 / 首尾空白不敏感;**未知或空值按 [Viewer] 处理**——
* 与服务端一样 fail-closed:宁可少给可见面,也不去撞预期中的 403。
*/
fun of(raw: String?): AdminRole = when (raw?.trim()?.lowercase()) {
"owner" -> Owner
"admin" -> Admin
else -> Viewer
}
}
}
/**
* 管理页清单:只声明「有哪些页 + 需要哪一档权限」。
* 入口页、ViewModel 与各页标题都按本清单驱动,新增管理模块只需在此追加一项。
*/
enum class AdminPage(
val title: String,
val summary: String,
val required: AdminTier,
) {
Overview("概览", "请求指标与最近 trace", AdminTier.Monitor),
Personas("人格", "角色目录与思考旋钮", AdminTier.Monitor),
Memories("记忆", "记忆正文(scope / 内容)", AdminTier.OwnerAdmin),
Backthink("后台思考", "后台思考与主动推送记录", AdminTier.OwnerAdmin),
}
/** 当前角色能否使用该页;不能则 UI 禁用入口、ViewModel 直接给「无权限」。 */
fun canAccess(role: AdminRole, page: AdminPage): Boolean = role.rank >= page.required.rank
/** 当前角色可用的页,顺序与 [AdminPage] 声明一致。 */
fun availablePages(role: AdminRole): List<AdminPage> = AdminPage.entries.filter { canAccess(role, it) }
@@ -0,0 +1,26 @@
package top.yeij.cyrene.domain.admin
import top.yeij.cyrene.core.error.CyreneError
import top.yeij.cyrene.data.api.CyreneException
/**
* 管理页取数失败。**「无权限」必须与其它失败分开**:UI 要能给出「无权限」状态,
* 而不是把 403 当成空列表渲染成「暂无数据」。
*/
sealed interface AdminFailure {
/** HTTP 403(角色不够)或 401(会话失效)。 */
data object Forbidden : AdminFailure
data class Other(val message: String) : AdminFailure
}
/** 把仓库层的 `Result` 失败收敛成 [AdminFailure];成功时返回 null。 */
fun Throwable.toAdminFailure(): AdminFailure {
val error = (this as? CyreneException)?.error
return when {
error is CyreneError.Unauthorized -> AdminFailure.Forbidden
error is CyreneError.Server && error.httpStatus == 403 -> AdminFailure.Forbidden
error != null -> AdminFailure.Other(error.userMessage)
else -> AdminFailure.Other("操作失败,请稍后重试")
}
}
@@ -0,0 +1,121 @@
package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
private val LocalTime = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
/**
* RFC3339(trace 的 `startedAt` / `endedAt`、trace event 的 `time`)→ 本地时间文本。
* 秒精度串与纳秒精度串都要吃得下;解析不了就**原样返回**,绝不静默清空。
*/
fun formatRfc3339(raw: String?): String? {
val text = raw?.takeIf { it.isNotBlank() } ?: return null
return runCatching {
OffsetDateTime.parse(text).atZoneSameInstant(ZoneId.systemDefault()).format(LocalTime)
}.getOrDefault(text)
}
/**
* Unix 毫秒(backthink 的 `at`)→ 本地时间文本。
* 与 [formatRfc3339] 是**两套输入**,刻意不共用解析路径(api-client §1 的两种时间形状)。
*/
fun formatEpochMillis(millis: Long): String =
Instant.ofEpochMilli(millis).atZone(ZoneId.systemDefault()).format(LocalTime)
/** 纳秒时长 → 按量级挑单位。 */
fun formatNanos(nanos: Long): String = when {
nanos < 1_000 -> "$nanos ns"
nanos < 1_000_000 -> String.format(Locale.ROOT, "%.1f µs", nanos / 1_000.0)
nanos < 1_000_000_000 -> String.format(Locale.ROOT, "%.1f ms", nanos / 1_000_000.0)
else -> String.format(Locale.ROOT, "%.2f s", nanos / 1_000_000_000.0)
}
/**
* 计数表(`perMethod` / `perPath` / `statusCounts`)→ 一行文本,按次数倒序。
* 键名一律原样保留(开放集合,不做白名单过滤,也不截断条目)。
*/
fun formatCounts(counts: Map<String, Long>): String =
counts.entries
.sortedWith(compareByDescending<Map.Entry<String, Long>> { it.value }.thenBy { it.key })
.joinToString(" · ") { (key, value) -> "$key $value" }
/** 后端用 float64 表达计数 / 毫秒,整数时抹掉无意义的小数位。 */
fun formatDouble(value: Double, decimals: Int = 2): String =
BigDecimal(value).setScale(decimals, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString()
/** 任意 `props` 对象 → 紧凑 JSON 文本,原样展示(不裁剪、不丢键)。 */
fun formatProps(props: Map<String, JsonElement>?): String? =
props?.takeIf { it.isNotEmpty() }?.let { JsonObject(it).toString() }
private val traceKindLabels = mapOf(
"llm_call" to "LLM 调用",
"tool_call" to "工具调用",
"orchestration" to "编排",
"state_transition" to "状态迁移",
"subagent" to "子代理",
"memory" to "记忆",
"guardrail" to "护栏",
)
private val traceStatusLabels = mapOf(
"ok" to "成功",
"error" to "失败",
)
private val personaModeLabels = mapOf(
"chat" to "对话",
"code" to "代码",
"home" to "家居",
"work" to "工作",
"docs" to "文档",
"voice" to "语音",
"vision" to "视觉",
"agents" to "智能体",
"settings" to "设置",
)
private val memoryScopeLabels = mapOf(
"user" to "用户",
"persona" to "人格",
"project" to "项目",
)
private val moodLabels = mapOf(
"calm" to "平静",
"happy" to "开心",
"excited" to "兴奋",
"sad" to "低落",
"angry" to "生气",
)
/**
* 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**——
* 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。
*/
fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw
fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw
fun personaModeLabel(raw: String): String = personaModeLabels[raw] ?: raw
fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw
fun moodLabel(raw: String): String = moodLabels[raw] ?: raw
/** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */
fun triStateLabel(value: Boolean?): String = when (value) {
null -> "未配置"
true -> "显示"
false -> "不显示"
}
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"