客户端错误遥测(#24)此前只有 ingest + 留存清理,无后台查看。新增管理员只读视图 (列表 + 聚合两者),admin-gated —— device_id 可关联账号,仅管理员可读。 后端: - model.ListTelemetryEvents(filter, startIdx, pageSize):分页列表(received_at desc), 按 user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。 - model.AggregateTelemetryEvents(filter, dimension, limit):按白名单维度 group by 计数 + COUNT(DISTINCT user_id),busiest first。维度白名单是 SQL 注入防线(列名只能来自 map)。 跨库安全(仅 COUNT/COUNT(DISTINCT)/GROUP BY 固定列,无 DB 特有日期函数)。 - 查询走 LOG_DB(与 ingest 同库);遥测仍隔离于计费、载荷无用户内容。 - controller AdminListTelemetryEvents / AdminAggregateTelemetryEvents;路由 GET /api/telemetry/{events,aggregate},middleware.AdminAuth() 守门。 前端(web/default): - features/telemetry:api + types + 页面(过滤条 + 聚合面板含维度切换/条形/去重用户数 + 分页列表表)。 - 路由 /_authenticated/telemetry(ROLE.ADMIN 守门),系统设置工作区侧栏加「Client Telemetry」入口。 - routeTree.gen.ts 由 router-generator 重新生成(仅 +22 行 telemetry 路由,无重排)。 测试:model/telemetry_query_test.go 覆盖列表过滤/分页/最新在前、聚合计数 + 去重用户、 维度白名单拒绝(注入防线)。go build ./... + model/controller/router 全绿;前端 tsc 仅 resources-page 一处既有报错(与本改动无关)。 影响面:Manager ✅(新增 admin 只读端点 + 前端页面)/ 计费 ❌(遥测隔离)/ 密钥 ❌ / 审计 ❌ / Client ❌ / agent_swarm ❌ / 发布链路 ❌。隐私:沿用 #24 口径,载荷无用户内容, device_id 账号可关联故管理员才可读。 Refs #24。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
65 lines
2.4 KiB
Go
65 lines
2.4 KiB
Go
package controller
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
)
|
|
|
|
// 客户端遥测(#24)管理员后台只读视图:列表 + 聚合。遥测仅诊断、隔离于计费,载荷无用户内容
|
|
// (仅枚举/哈希/计数/脱敏栈帧);device_id(client_id)经 user_id 可关联账号,故仅管理员可读。
|
|
|
|
func telemetryQueryFilterFromContext(c *gin.Context) model.TelemetryQueryFilter {
|
|
userId, _ := strconv.Atoi(strings.TrimSpace(c.Query("user_id")))
|
|
start, _ := strconv.ParseInt(strings.TrimSpace(c.Query("start_timestamp")), 10, 64)
|
|
end, _ := strconv.ParseInt(strings.TrimSpace(c.Query("end_timestamp")), 10, 64)
|
|
return model.TelemetryQueryFilter{
|
|
UserId: userId,
|
|
ClientId: strings.TrimSpace(c.Query("client_id")),
|
|
Platform: strings.TrimSpace(c.Query("platform")),
|
|
AppVersion: strings.TrimSpace(c.Query("app_version")),
|
|
ErrorCategory: strings.TrimSpace(c.Query("error_category")),
|
|
ErrorCode: strings.TrimSpace(c.Query("error_code")),
|
|
StartReceivedAt: start,
|
|
EndReceivedAt: end,
|
|
}
|
|
}
|
|
|
|
// AdminListTelemetryEvents: GET /api/telemetry/events — 分页列表(最新在前),支持
|
|
// user_id/client_id/platform/app_version/error_category/error_code + 时间范围过滤。
|
|
func AdminListTelemetryEvents(c *gin.Context) {
|
|
pageInfo := common.GetPageQuery(c)
|
|
events, total, err := model.ListTelemetryEvents(telemetryQueryFilterFromContext(c), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
pageInfo.SetTotal(int(total))
|
|
pageInfo.SetItems(events)
|
|
common.ApiSuccess(c, pageInfo)
|
|
}
|
|
|
|
// AdminAggregateTelemetryEvents: GET /api/telemetry/aggregate — 按维度聚合计数。
|
|
// dimension 默认 error_category(白名单:error_category/error_code/platform/app_version/
|
|
// os_version/arch/stack_hash);返回各桶 {key,count,users},busiest first。过滤同列表。
|
|
func AdminAggregateTelemetryEvents(c *gin.Context) {
|
|
dimension := strings.TrimSpace(c.Query("dimension"))
|
|
if dimension == "" {
|
|
dimension = "error_category"
|
|
}
|
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
|
buckets, err := model.AggregateTelemetryEvents(telemetryQueryFilterFromContext(c), dimension, limit)
|
|
if err != nil {
|
|
common.ApiError(c, err)
|
|
return
|
|
}
|
|
common.ApiSuccess(c, gin.H{
|
|
"dimension": dimension,
|
|
"buckets": buckets,
|
|
})
|
|
}
|