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,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() } ?: "-"
|
||||
Reference in new issue
Block a user