diff --git a/docs/architecture/storage-minimal-contract.md b/docs/architecture/storage-minimal-contract.md index e221db67..0f27d65e 100644 --- a/docs/architecture/storage-minimal-contract.md +++ b/docs/architecture/storage-minimal-contract.md @@ -44,6 +44,7 @@ - 原因:日统计是正式业务指标,应只统计已经定位到 VIN 的车辆。 - 无 VIN 的 JT808 等数据保留在 raw/Redis/session 中用于排查和待绑定,不进入正式车辆指标。 - 状态:schema 使用 `PRIMARY KEY (vin, stat_date, protocol)`,不保留自增 `id` 和 `created_at`;首末总里程和样本数保留为日里程计算状态。 + - 查询:`/api/stats/daily-metrics` 默认不执行 `COUNT(*)`,`total` 表示本页返回条数;只有报表总页数等场景传 `includeTotal=true`。 - 生产:RDS 已在上线前删除泛化 `vehicle_daily_metric`,改为专用 `vehicle_daily_mileage`。 4. `vehicle_locations` 不再接收临时身份兜底字段。 diff --git a/go/vehicle-gateway/internal/stats/query.go b/go/vehicle-gateway/internal/stats/query.go index fcbaad71..f6e04aa7 100644 --- a/go/vehicle-gateway/internal/stats/query.go +++ b/go/vehicle-gateway/internal/stats/query.go @@ -17,12 +17,13 @@ type Queryer interface { } type MetricQuery struct { - VIN string - Protocol string - DateFrom string - DateTo string - Limit int - Offset int + VIN string + Protocol string + IncludeTotal bool + DateFrom string + DateTo string + Limit int + Offset int } type MetricRow struct { @@ -184,16 +185,22 @@ func (h *MetricHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeMetricError(w, http.StatusBadRequest, err.Error()) return } - total, err := h.repository.Count(r.Context(), query) - if err != nil { - writeMetricError(w, http.StatusInternalServerError, err.Error()) - return + var total int64 + if query.IncludeTotal { + total, err = h.repository.Count(r.Context(), query) + if err != nil { + writeMetricError(w, http.StatusInternalServerError, err.Error()) + return + } } rows, err := h.repository.Query(r.Context(), query) if err != nil { writeMetricError(w, http.StatusInternalServerError, err.Error()) return } + if !query.IncludeTotal { + total = int64(len(rows)) + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "items": rows, @@ -214,12 +221,13 @@ func parseMetricQuery(r *http.Request) (MetricQuery, error) { return MetricQuery{}, err } query := MetricQuery{ - VIN: values.Get("vin"), - Protocol: values.Get("protocol"), - DateFrom: values.Get("dateFrom"), - DateTo: values.Get("dateTo"), - Limit: limit, - Offset: offset, + VIN: values.Get("vin"), + Protocol: values.Get("protocol"), + IncludeTotal: strings.EqualFold(strings.TrimSpace(values.Get("includeTotal")), "true"), + DateFrom: values.Get("dateFrom"), + DateTo: values.Get("dateTo"), + Limit: limit, + Offset: offset, } if !validDate(query.DateFrom) || !validDate(query.DateTo) { return MetricQuery{}, errors.New("dateFrom/dateTo must use YYYY-MM-DD") diff --git a/go/vehicle-gateway/internal/stats/query_test.go b/go/vehicle-gateway/internal/stats/query_test.go index ae1ce3a4..923b10c5 100644 --- a/go/vehicle-gateway/internal/stats/query_test.go +++ b/go/vehicle-gateway/internal/stats/query_test.go @@ -75,7 +75,7 @@ func TestMetricHandlerReturnsDailyMetrics(t *testing.T) { )) handler := NewMetricHandler(NewMetricRepository(db)) - request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2020-07-01", nil) + request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?vin=LB9A32A21R0LS1707&protocol=GB32960&dateFrom=2020-07-01&dateTo=2020-07-01&includeTotal=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -103,9 +103,6 @@ func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() - mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM vehicle_daily_mileage"). - WithArgs("YUTONG_MQTT"). - WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(0)) mock.ExpectQuery("SELECT vin, stat_date, protocol, daily_mileage_km, first_total_mileage_km, latest_total_mileage_km, sample_count, updated_at FROM vehicle_daily_mileage"). WithArgs("YUTONG_MQTT", 50, 0). WillReturnRows(sqlmock.NewRows([]string{ @@ -132,6 +129,41 @@ func TestMetricHandlerReturnsEmptyItemsArrayWhenNoRows(t *testing.T) { } } +func TestMetricHandlerSkipsTotalCountByDefault(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock.New() error = %v", err) + } + defer db.Close() + mock.ExpectQuery("SELECT vin, stat_date, protocol, daily_mileage_km, first_total_mileage_km, latest_total_mileage_km, sample_count, updated_at FROM vehicle_daily_mileage"). + WithArgs("JT808", 1, 0). + WillReturnRows(sqlmock.NewRows([]string{ + "vin", "stat_date", "protocol", "daily_mileage_km", + "first_total_mileage_km", "latest_total_mileage_km", "sample_count", + "updated_at", + }).AddRow( + "LKLG7C4E3NA774736", "2026-07-02", "JT808", 12.3, + 8792.8, 8805.1, 30, "2026-07-02 23:59:59", + )) + + handler := NewMetricHandler(NewMetricRepository(db)) + request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?protocol=JT808&limit=1", nil) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } + body := response.Body.String() + if !strings.Contains(body, `"total":1`) { + t.Fatalf("response should use page size as total when total count is not requested: %s", body) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } +} + func TestMetricHandlerRejectsInvalidPagination(t *testing.T) { handler := NewMetricHandler(NewMetricRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodGet, "/api/stats/daily-metrics?limit=2001", nil)