From c99fdfe567fc90e2a6ffb028f0de9c0b5ed025c1 Mon Sep 17 00:00:00 2001 From: chaoliu Date: Wed, 17 Jun 2026 18:53:19 +0800 Subject: [PATCH 1/2] feat(statistics): add operator data production stats --- ...perator-data-production-statistics.zh.html | 582 ++++++++++++++++++ .../handlers/data_production_statistics.go | 109 +++- .../data_production_statistics_test.go | 37 ++ internal/server/server.go | 2 + 4 files changed, 727 insertions(+), 3 deletions(-) create mode 100644 docs/designs/operator-data-production-statistics.zh.html diff --git a/docs/designs/operator-data-production-statistics.zh.html b/docs/designs/operator-data-production-statistics.zh.html new file mode 100644 index 0000000..497ce26 --- /dev/null +++ b/docs/designs/operator-data-production-statistics.zh.html @@ -0,0 +1,582 @@ + + + + + + + 数采员个人数据产量设计 + + + +
+
+
+

Synapse Operator Portal

+

数采员个人数据产量设计

+

+ 本文档固化“数采员登录 Synapse 后查看自己每天数据产量”的产品口径、页面范围、API 形态和实现边界。 + 目标是在不影响管理后台统计页的前提下,复用现有数据生产统计能力。 +

+ +
+ +
+ +
+

1. 已确认决策

+
+
+ 归属口径 +

与管理后台“数据统计”按数采员筛选时完全一致,使用现有统计 SQL 里的 collector_operator_id 口径。

+
+
+ 入口 +

新增数采员门户页面 /operator/production,侧边栏文案为“我的产量”。不替换现有生产仪表板。

+
+
+ 默认时间 +

默认展示“今天”。其他时间筛选交互尽量与管理后台统计页面一致。

+
+
+ 时区 +

“今天”按浏览器本地时区切日,趋势接口继续传 timezone_offset

+
+
+ 页面模块 +

保留摘要卡、趋势图、维度分布。不做明细表,不做 CSV 导出。

+
+
+ 云同步 +

个人页不展示云同步率,不展示云同步状态筛选,也不提供按云同步状态分布。

+
+
+ +
+

+ 注意:当前口径会沿用管理后台统计页的现有归属方式。它通过 episode/workstation/data_collector 关系得出数采员, + 不在本次需求中新增 episode 历史归属快照字段。 +

+
+
+ +
+

2. 页面设计

+ +

2.1 路由与文案

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
位置内容
路由/operator/production
侧边栏我的产量
页面标题我的数据产量
趋势区标题产量趋势
分布区标题维度分布
+ +

2.2 页面模块

+ + + + + + + + + + + + + + + + + + + + + + + + + +
模块内容默认行为
摘要卡数据条数、总时长、总大小、QA 通过率基于当前时间范围和筛选条件展示
产量趋势数量 / 时长 / 大小 三个视图默认展示数量
维度分布设备 ID、设备类型、场景、SOP、QA 状态默认按设备 ID
+ +

2.3 筛选项

+

时间筛选与管理后台统计页保持一致,默认值改为今天。高级筛选保留以下 5 项:

+

+ 场景 + SOP + 设备 ID + 设备类型 + QA 状态 +

+

不显示“数采员”筛选,因为后端永远强制当前登录数采员 scope。不显示“云同步状态”筛选。

+ +

2.4 空态与错误态

+
    +
  • 当前时间范围没有数据时,摘要卡显示 0 或空值格式化后的 0。
  • +
  • 趋势图展示空时间轴或 0 值,不弹错误提示。
  • +
  • 维度分布展示“暂无数据”。
  • +
  • 只有接口失败、登录过期或权限异常时显示错误 banner。
  • +
+
+ +
+

3. API 设计

+ +

3.1 新增路径

+

新增 operator 专用统计路径,复用管理后台数据生产统计 handler 的核心查询逻辑。

+
GET /api/v1/operator/statistics/data-production/summary
+GET /api/v1/operator/statistics/data-production/trend
+GET /api/v1/operator/statistics/data-production/breakdown
+ +

3.2 不提供的路径

+
    +
  • 不提供 details,因为个人页不展示明细表。
  • +
  • 不提供 export,因为个人页不做 CSV 导出。
  • +
+ +

3.3 认证与权限

+ + + + + + + + + + + + + + + + + + + + + + + + + +
规则说明
认证JWTAuth
角色RequireRole("data_collector")
本人 scope后端从 JWT claims 读取 OperatorID,强制覆盖查询中的 collector_operator_id
越权参数如果请求传入其他 collector_operator_id,不返回 403,直接忽略并仍然查询本人。
+ +

3.4 查询参数

+

保留管理后台统计接口已有参数中的子集:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
参数用途个人页状态
start_time, end_time统计时间范围,RFC3339保留
granularity趋势粒度:hour/day/week/month保留
timezone_offset趋势本地时间分桶trend 保留
scene_id, sop_id场景与 SOP 筛选保留
robot_device_id, robot_type_id设备筛选保留
qa_statusQA 状态筛选保留
collector_operator_id数采员筛选请求中忽略,由后端强制为本人
cloud_synced云同步状态筛选个人页不传、不展示
+
+ +
+

4. 实现建议

+ +

4.1 Keystone

+
    +
  1. DataProductionStatisticsHandler 中新增 operator 注册方法,例如 RegisterOperatorRoutes
  2. +
  3. 新增 scope helper:解析通用 query 后,将 CollectorOperatorIDs 覆盖为 []string{claims.OperatorID}
  4. +
  5. operator 的 summarytrendbreakdown 复用现有查询与 response 结构。
  6. +
  7. 不要把 admin 路由改成 data_collector 可访问,避免污染管理后台权限边界。
  8. +
  9. 新增测试覆盖:data_collector 传入别人 collector_operator_id 时仍只查询本人。
  10. +
+ +

4.2 Synapse

+
    +
  1. 新增 API wrapper,例如 operatorDataProductionStatistics.js,endpoint 指向 /operator/statistics/data-production
  2. +
  3. 新增页面 OperatorDataProduction.vue,新写个人页,不大拆管理后台统计页。
  4. +
  5. router/index.js 增加 /operator/production 路由。
  6. +
  7. OperatorSidebar.vue 增加“我的产量”入口。
  8. +
  9. 复用现有格式化函数、ECharts 初始化思路、RemoteSelect 组件和管理后台统计页的筛选交互。
  10. +
+ +
+

+ 前端页面应是管理后台统计页的个人精简版,而不是抽象出一个大而全的共享组件。 + 这样可以降低对现有 admin 页面的回归风险。 +

+
+
+ +
+

5. 验收清单

+
    +
  • 数采员登录后可从侧边栏进入“我的产量”。
  • +
  • 默认时间范围为今天,并按浏览器本地时区切日。
  • +
  • 页面展示 4 个摘要卡:数据条数、总时长、总大小、QA 通过率。
  • +
  • 趋势图支持数量、时长、大小切换,默认数量。
  • +
  • 维度分布支持设备 ID、设备类型、场景、SOP、QA 状态,默认设备 ID。
  • +
  • 高级筛选支持场景、SOP、设备 ID、设备类型、QA 状态。
  • +
  • 个人页不展示数采员筛选、云同步指标、云同步筛选、明细表、CSV 导出。
  • +
  • 请求中手动传入其他 collector_operator_id 时,后端仍返回当前登录数采员的数据。
  • +
  • 没有数据时页面正常显示 0 和“暂无数据”,不报错。
  • +
  • admin 数据统计页行为保持不变。
  • +
+
+
+ + diff --git a/internal/api/handlers/data_production_statistics.go b/internal/api/handlers/data_production_statistics.go index 68f53b0..d0d7e4e 100644 --- a/internal/api/handlers/data_production_statistics.go +++ b/internal/api/handlers/data_production_statistics.go @@ -18,6 +18,7 @@ import ( "github.com/jmoiron/sqlx" "archebase.com/keystone-edge/internal/logger" + "archebase.com/keystone-edge/internal/middleware" ) // DataProductionStatisticsHandler handles Synapse Admin data production statistics APIs. @@ -39,6 +40,13 @@ func (h *DataProductionStatisticsHandler) RegisterRoutes(apiV1 *gin.RouterGroup) apiV1.GET("/export", h.ExportCSV) } +// RegisterOperatorRoutes registers data collector self-service data production statistics routes. +func (h *DataProductionStatisticsHandler) RegisterOperatorRoutes(apiV1 *gin.RouterGroup) { + apiV1.GET("/summary", h.GetOperatorSummary) + apiV1.GET("/trend", h.GetOperatorTrend) + apiV1.GET("/breakdown", h.GetOperatorBreakdown) +} + type dataProductionStatsQuery struct { StartTime time.Time EndTime time.Time @@ -237,6 +245,14 @@ var validDataProductionQAStatuses = map[string]struct{}{ } func parseDataProductionStatsQuery(c *gin.Context, requireGranularity bool) (dataProductionStatsQuery, error) { + return parseDataProductionStatsQueryWithOptions(c, requireGranularity, false) +} + +func parseOperatorDataProductionStatsQuery(c *gin.Context, requireGranularity bool) (dataProductionStatsQuery, error) { + return parseDataProductionStatsQueryWithOptions(c, requireGranularity, true) +} + +func parseDataProductionStatsQueryWithOptions(c *gin.Context, requireGranularity bool, ignoreCollectorOperatorFilter bool) (dataProductionStatsQuery, error) { startTime, err := parseStatsTime(c.Query("start_time")) if err != nil { return dataProductionStatsQuery{}, fmt.Errorf("start_time is required and must be RFC3339") @@ -269,9 +285,12 @@ func parseDataProductionStatsQuery(c *gin.Context, requireGranularity bool) (dat if err != nil { return dataProductionStatsQuery{}, err } - collectorOperatorIDs, err := parseStatsStringListQuery(c, "collector_operator_id") - if err != nil { - return dataProductionStatsQuery{}, err + collectorOperatorIDs := []string{} + if !ignoreCollectorOperatorFilter { + collectorOperatorIDs, err = parseStatsStringListQuery(c, "collector_operator_id") + if err != nil { + return dataProductionStatsQuery{}, err + } } sopIDs, err := parseStatsStringListQuery(c, "sop_id") if err != nil { @@ -356,6 +375,24 @@ func (h *DataProductionStatisticsHandler) GetSummary(c *gin.Context) { return } + h.writeSummary(c, q) +} + +// GetOperatorSummary returns aggregate data production statistics for the current data collector. +func (h *DataProductionStatisticsHandler) GetOperatorSummary(c *gin.Context) { + q, err := parseOperatorDataProductionStatsQuery(c, false) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !scopeDataProductionStatsQueryToCurrentCollector(c, &q) { + return + } + + h.writeSummary(c, q) +} + +func (h *DataProductionStatisticsHandler) writeSummary(c *gin.Context, q dataProductionStatsQuery) { row, err := h.aggregateStats(q, "") if err != nil { logger.Printf("[DATA_STATS] summary query failed: %v", err) @@ -398,6 +435,29 @@ func (h *DataProductionStatisticsHandler) GetTrend(c *gin.Context) { return } + h.writeTrend(c, q, timezoneOffset) +} + +// GetOperatorTrend returns bucketed data production statistics for the current data collector. +func (h *DataProductionStatisticsHandler) GetOperatorTrend(c *gin.Context) { + q, err := parseOperatorDataProductionStatsQuery(c, true) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !scopeDataProductionStatsQueryToCurrentCollector(c, &q) { + return + } + timezoneOffset, err := parseStatsTimezoneOffset(c.Query("timezone_offset")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + h.writeTrend(c, q, timezoneOffset) +} + +func (h *DataProductionStatisticsHandler) writeTrend(c *gin.Context, q dataProductionStatsQuery, timezoneOffset string) { bucketExpr, bucketArgs := statsBucketExpression(q.Granularity, timezoneOffset) baseSQL, args := h.filteredProductionRecordsSQL(q) query := fmt.Sprintf(` @@ -449,6 +509,29 @@ func (h *DataProductionStatisticsHandler) GetBreakdown(c *gin.Context) { return } + h.writeBreakdown(c, q, pagination) +} + +// GetOperatorBreakdown returns paginated data production breakdown for the current data collector. +func (h *DataProductionStatisticsHandler) GetOperatorBreakdown(c *gin.Context) { + q, err := parseOperatorDataProductionStatsQuery(c, false) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !scopeDataProductionStatsQueryToCurrentCollector(c, &q) { + return + } + pagination, err := ParsePagination(c) + if err != nil { + PaginationErrorResponse(c, err) + return + } + + h.writeBreakdown(c, q, pagination) +} + +func (h *DataProductionStatisticsHandler) writeBreakdown(c *gin.Context, q dataProductionStatsQuery, pagination PaginationParams) { dimension := strings.TrimSpace(c.DefaultQuery("dimension", "source")) idExpr, nameExpr, err := statsBreakdownExpressions(dimension) if err != nil { @@ -491,6 +574,26 @@ func (h *DataProductionStatisticsHandler) GetBreakdown(c *gin.Context) { }) } +func scopeDataProductionStatsQueryToCurrentCollector(c *gin.Context, q *dataProductionStatsQuery) bool { + claims := middleware.GetClaims(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return false + } + if claims.Role != "data_collector" { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return false + } + + operatorID := strings.TrimSpace(claims.OperatorID) + if operatorID == "" { + c.JSON(http.StatusForbidden, gin.H{"error": "collector identity missing"}) + return false + } + q.CollectorOperatorIDs = []string{operatorID} + return true +} + func dataProductionBreakdownCountSQL(idExpr string, baseSQL string) string { return fmt.Sprintf(` SELECT COUNT(1) diff --git a/internal/api/handlers/data_production_statistics_test.go b/internal/api/handlers/data_production_statistics_test.go index f35409b..6a90f47 100644 --- a/internal/api/handlers/data_production_statistics_test.go +++ b/internal/api/handlers/data_production_statistics_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "archebase.com/keystone-edge/internal/auth" + "archebase.com/keystone-edge/internal/middleware" "github.com/gin-gonic/gin" ) @@ -243,6 +245,41 @@ func TestParseDataProductionStatsQueryRejectsOversizedListFilters(t *testing.T) } } +func TestParseOperatorDataProductionStatsQueryIgnoresCollectorFilter(t *testing.T) { + gin.SetMode(gin.TestMode) + + target := "/stats?start_time=2026-05-01T00:00:00Z&end_time=2026-05-02T00:00:00Z&collector_operator_id=" + + strings.Repeat("x", maxMultiValueFilterStringItemLength+1) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodGet, target, nil) + + got, err := parseOperatorDataProductionStatsQuery(c, true) + if err != nil { + t.Fatalf("parseOperatorDataProductionStatsQuery returned error: %v", err) + } + if len(got.CollectorOperatorIDs) != 0 { + t.Fatalf("collector filter should be ignored before auth scope, got %#v", got.CollectorOperatorIDs) + } +} + +func TestScopeDataProductionStatsQueryToCurrentCollectorOverridesCollectorFilter(t *testing.T) { + gin.SetMode(gin.TestMode) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Set(middleware.ClaimsKey, &auth.Claims{ + OperatorID: "dc-001", + Role: "data_collector", + }) + q := dataProductionStatsQuery{CollectorOperatorIDs: []string{"dc-999"}} + + if !scopeDataProductionStatsQueryToCurrentCollector(c, &q) { + t.Fatalf("expected scope application to succeed") + } + if strings.Join(q.CollectorOperatorIDs, ",") != "dc-001" { + t.Fatalf("collector filter = %#v, want dc-001", q.CollectorOperatorIDs) + } +} + func mustParseStatsTimeForTest(t *testing.T, raw string) time.Time { t.Helper() value, err := parseStatsTime(raw) diff --git a/internal/server/server.go b/internal/server/server.go index 5100689..a9522a9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -331,6 +331,8 @@ func (s *Server) buildRoutes() http.Handler { jwtMw := middleware.JWTAuth(&s.cfg.Auth) adminStats := v1Routes.Group("/admin/statistics/data-production", jwtMw, middleware.RequireRole("admin")) s.dataStats.RegisterRoutes(adminStats) + operatorStats := v1Routes.Group("/operator/statistics/data-production", jwtMw, middleware.RequireRole("data_collector")) + s.dataStats.RegisterOperatorRoutes(operatorStats) } if s.dataOps != nil { jwtMw := middleware.JWTAuth(&s.cfg.Auth) From c9755a9475aa8b9cad95f7c4ec14008935f65e2a Mon Sep 17 00:00:00 2001 From: chaoliu Date: Wed, 17 Jun 2026 19:07:30 +0800 Subject: [PATCH 2/2] docs(statistics): update operator production design --- docs/designs/operator-data-production-statistics.zh.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/designs/operator-data-production-statistics.zh.html b/docs/designs/operator-data-production-statistics.zh.html index 497ce26..af73ed4 100644 --- a/docs/designs/operator-data-production-statistics.zh.html +++ b/docs/designs/operator-data-production-statistics.zh.html @@ -324,7 +324,7 @@

1. 已确认决策

入口 -

新增数采员门户页面 /operator/production,侧边栏文案为“我的产量”。不替换现有生产仪表板。

+

新增数采员门户页面 /operator/production,侧边栏文案为“我的产量”。数采员门户不再保留原普通生产仪表板入口。

默认时间