feat: 推广 V3.5 实时氢耗并完善导出

This commit is contained in:
lingniu
2026-09-04 15:42:02 +08:00
parent a26179c8fe
commit e402eb5e60
53 changed files with 2855 additions and 320 deletions
@@ -26,6 +26,7 @@ func main() {
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
vin := flag.String("vin", "", "optional 17-character VIN for a scoped rebuild")
brand := flag.String("brand", "", "optional exact vehicle brand for an atomic scoped rebuild")
vinWorkers := flag.Int("vin-workers", envInt("OPEN_STAT_VIN_WORKERS", 4), "parallel per-VIN queries for an all-vehicle day")
allDates := flag.Bool("all-dates", false, "rebuild every available completed event date; optionally scoped by -vin")
dryRun := flag.Bool("dry-run", false, "calculate and print results without changing MySQL")
@@ -62,9 +63,16 @@ func main() {
log.Fatal("-vin-workers must be between 1 and 16")
}
normalizedVIN := strings.ToUpper(strings.TrimSpace(*vin))
normalizedBrand := strings.TrimSpace(*brand)
if normalizedVIN != "" && normalizedBrand != "" {
log.Fatal("-vin and -brand cannot be combined")
}
if *seedStream && normalizedVIN != "" {
log.Fatal("-seed-stream-state requires an all-vehicle rebuild")
}
if *seedStream && normalizedBrand != "" {
log.Fatal("-seed-stream-state cannot be combined with -brand")
}
if *seedStream && *dryRun {
log.Fatal("-seed-stream-state cannot be combined with -dry-run")
}
@@ -84,6 +92,29 @@ func main() {
log.Fatalf("no active hydrogen tank capacity for VIN %s", normalizedVIN)
}
}
var brandVINs []string
if normalizedBrand != "" {
brandVINs, err = openplatform.LoadHydrogenBrandVINs(ctx, mysqlDB, normalizedBrand)
if err != nil {
log.Fatalf("load VINs for brand %q: %v", normalizedBrand, err)
}
if len(brandVINs) == 0 {
log.Fatalf("no vehicles found for brand %q", normalizedBrand)
}
brandSet := make(map[string]struct{}, len(brandVINs))
for _, brandVIN := range brandVINs {
brandSet[brandVIN] = struct{}{}
}
for capacityVIN := range capacities {
if _, ok := brandSet[capacityVIN]; !ok {
delete(capacities, capacityVIN)
}
}
if len(capacities) == 0 {
log.Fatalf("no active hydrogen tank capacities found for brand %q", normalizedBrand)
}
fmt.Printf("brand=%s scoped_vins=%d capacity_vins=%d\n", normalizedBrand, len(brandVINs), len(capacities))
}
startDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -(*lookback - 1))
if *allDates {
var first, last time.Time
@@ -112,6 +143,8 @@ func main() {
scope := "all-vehicles"
if normalizedVIN != "" {
scope = normalizedVIN
} else if normalizedBrand != "" {
scope = "brand:" + normalizedBrand
}
fmt.Printf("scope=%s date_from=%s date_to=%s mode=all-dates\n", scope, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
}
@@ -141,6 +174,8 @@ func main() {
if !*dryRun {
if *seedStream {
err = openplatform.ReplaceHydrogenDailyStatsAndSeedStream(ctx, mysqlDB, date, stats)
} else if normalizedBrand != "" {
err = openplatform.ReplaceHydrogenDailyStatsForVINs(ctx, mysqlDB, date, brandVINs, stats)
} else if normalizedVIN == "" {
err = openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, date, stats)
} else {
@@ -150,6 +150,10 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
return
}
}
if strings.HasPrefix(r.URL.Path, "/api/v2/admin/users") && !principal.CanMenu("users") {
httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问账号管理", "users", requestTraceID(r))
return
}
required := requiredRole(r)
if roleRank(principal.Role) < roleRank(required) {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
@@ -243,7 +247,7 @@ func requiredMenu(r *http.Request) string {
switch {
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
return "monitor"
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", strings.HasSuffix(path, "/telemetry/latest"):
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
return "shared"
case path == "/api/v2/tracks":
return "tracks"
@@ -40,6 +40,11 @@ var customerMenuSet = map[string]bool{
var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"}
var adminMenuSet = map[string]bool{
"monitor": true, "vehicles": true, "tracks": true, "history": true, "statistics": true,
"alerts": true, "access": true, "operations": true, "users": true,
}
type authUser struct {
ID uint64 `json:"id"`
Username string `json:"username"`
@@ -551,14 +556,13 @@ func (s *authStore) localCredential(ctx context.Context, username string) (local
}
func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) {
menus := append([]string(nil), adminMenus...)
menus := []string{}
vehicles := []string{}
vehicleGrants := []platform.VehicleGrant{}
businessScopeLevel := ""
departmentIDs := []string{}
responsibleUserID := ""
if user.UserType == "customer" {
menus = []string{}
if user.UserType == "customer" || user.UserType == "admin" {
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
if err != nil {
return platform.Principal{}, err
@@ -569,14 +573,19 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
rows.Close()
return platform.Principal{}, err
}
if customerMenuSet[value] {
if (user.UserType == "customer" && customerMenuSet[value]) || (user.UserType == "admin" && adminMenuSet[value]) {
menus = append(menus, value)
}
}
if err := rows.Close(); err != nil {
return platform.Principal{}, err
}
rows, err = s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
}
if user.UserType == "admin" && len(menus) == 0 {
menus = append([]string(nil), adminMenus...)
}
if user.UserType == "customer" {
rows, err := s.db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID)
if err != nil {
return platform.Principal{}, err
}
@@ -90,6 +90,30 @@ FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
}
}
func TestPrincipalForAdminHonorsExplicitMenus(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := &authStore{db: db, cache: map[string]cachedSession{}}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("monitor").AddRow("operations"))
principal, err := store.principalForUser(context.Background(), authUser{
ID: 8, DisplayName: "业务管理", Username: "ln-bm", UserType: "admin", AuthProvider: "local",
})
if err != nil {
t.Fatal(err)
}
if !principal.CanMenu("operations") || principal.CanMenu("users") {
t.Fatalf("restricted admin menus were not honored: %+v", principal.MenuKeys)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -1,6 +1,7 @@
package app
import (
"crypto/sha256"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -212,6 +213,39 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
}
}
func TestRestrictedAdminCannotAccessAccountManagement(t *testing.T) {
token := "restricted-admin-token-at-least-16"
hash := sha256.Sum256([]byte(token))
authenticator := &apiAuthenticator{
mode: "enforce",
tokens: []tokenPrincipal{{
hash: hash,
principal: platform.Principal{
Name: "业务管理", Username: "ln-bm", Role: "admin", UserType: "admin",
MenuKeys: []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations"},
},
}},
}
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
handler := authenticator.middleware(next)
usersRequest := httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil)
usersRequest.Header.Set("Authorization", "Bearer "+token)
usersResponse := httptest.NewRecorder()
handler.ServeHTTP(usersResponse, usersRequest)
if usersResponse.Code != http.StatusForbidden || !strings.Contains(usersResponse.Body.String(), "MENU_PERMISSION_DENIED") {
t.Fatalf("restricted admin account management status=%d body=%s", usersResponse.Code, usersResponse.Body.String())
}
operationsRequest := httptest.NewRequest(http.MethodGet, "/api/v2/operations/source-rules", nil)
operationsRequest.Header.Set("Authorization", "Bearer "+token)
operationsResponse := httptest.NewRecorder()
handler.ServeHTTP(operationsResponse, operationsRequest)
if operationsResponse.Code != http.StatusNoContent {
t.Fatalf("restricted admin should retain other admin permissions, status=%d", operationsResponse.Code)
}
}
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
req := httptest.NewRequest(http.MethodPost, path, nil)
@@ -224,6 +258,29 @@ func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
}
}
func TestBatchVehicleLookupAllowsEveryCustomerMenuScope(t *testing.T) {
const token = "customer-token-at-least-16"
hash := sha256.Sum256([]byte(token))
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
for _, menu := range customerMenuKeys {
t.Run(menu, func(t *testing.T) {
authenticator := &apiAuthenticator{mode: "enforce", tokens: []tokenPrincipal{{
hash: hash,
principal: platform.Principal{
Name: "客户甲", Role: "customer", UserType: "customer", MenuKeys: []string{menu},
},
}}}
req := httptest.NewRequest(http.MethodPost, "/api/vehicle-service/overviews", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
authenticator.middleware(next).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("customer with %s menu should reach batch lookup, status=%d body=%s", menu, rec.Code, rec.Body.String())
}
})
}
}
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
cases := []config.Config{
{AuthMode: "enforce"},
@@ -620,6 +620,13 @@ components:
format: double
nullable: true
description: 单日用氢量,kg;无数据时为 null
calculationPhase:
type: string
enum: [PRELIMINARY, FINAL]
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
algorithmVersion:
type: string
description: 氢耗计算算法版本
status:
$ref: '#/components/schemas/DataStatus'
MileageResult:
@@ -49,6 +49,8 @@ type HydrogenResult struct {
PlateNumber string `json:"plateNumber"`
Date string `json:"date"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
CalculationPhase string `json:"calculationPhase,omitempty"`
AlgorithmVersion string `json:"algorithmVersion,omitempty"`
Status string `json:"status"`
}
@@ -281,11 +283,13 @@ type AppCredential struct {
}
type DailyHydrogen struct {
VIN string
Date string
ConsumptionKg float64
SampleCount int
QualityStatus string
VIN string
Date string
ConsumptionKg float64
SampleCount int
QualityStatus string
CalculationPhase string
AlgorithmVersion string
}
type DailyMileage struct {
@@ -312,7 +312,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
return map[string]DailyHydrogen{}, nil
}
query, args := inQuery(`
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,
calculation_phase,algorithm_version
FROM vehicle_open_daily_energy
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
rows, err := r.db.QueryContext(ctx, query, args...)
@@ -323,7 +324,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
out := make(map[string]DailyHydrogen, len(vins))
for rows.Next() {
var value DailyHydrogen
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
return nil, err
}
out[value.VIN] = value
@@ -391,10 +391,14 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
// Sampling sufficiency is decided by the producer and persisted in
// quality_status. Imported refuelling-ledger rows can be authoritative
// with one transaction, while pressure-derived rows require two samples.
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal
if value, ok := values[vehicle.VIN]; ok {
item.CalculationPhase = value.CalculationPhase
item.AlgorithmVersion = value.AlgorithmVersion
if strings.EqualFold(value.QualityStatus, "OK") {
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal
}
}
results = append(results, item)
}
@@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
},
hydrogen: map[string]DailyHydrogen{
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion},
},
mileage: map[string]DailyMileage{
"LTEST32960VIN0001": {
@@ -282,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
t.Fatalf("hydrogen = %#v", hydrogen)
}
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
}
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
if err != nil {
t.Fatal(err)
@@ -387,6 +387,33 @@ func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64
return capacities, rows.Err()
}
func LoadHydrogenBrandVINs(ctx context.Context, db *sql.DB, brand string) ([]string, error) {
brand = strings.TrimSpace(brand)
if brand == "" {
return nil, errors.New("hydrogen vehicle brand is required")
}
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin))
FROM vehicle_profile
WHERE TRIM(brand_name)=?
ORDER BY vin`, brand)
if err != nil {
return nil, err
}
defer rows.Close()
vins := make([]string, 0)
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return nil, err
}
vin = strings.ToUpper(strings.TrimSpace(vin))
if validHydrogenVIN(vin) {
vins = append(vins, vin)
}
}
return vins, rows.Err()
}
func LoadHydrogenCalculationParameters(ctx context.Context, db *sql.DB, date time.Time) (map[string]HydrogenCalculationParameters, error) {
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),battery_capacity_kwh,hydrogen_energy_kwh_per_kg
FROM vehicle_hydrogen_energy_parameter
@@ -811,7 +838,7 @@ func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (f
}
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
return replaceHydrogenDailyStats(ctx, db, date, "", stats, false)
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, false)
}
// ReplaceHydrogenDailyStatsAndSeedStream performs the current-day deployment
@@ -819,7 +846,7 @@ func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, sta
// included in the rebuild, so Kafka backlog already covered by the rebuild is
// ignored instead of counted twice.
func ReplaceHydrogenDailyStatsAndSeedStream(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
return replaceHydrogenDailyStats(ctx, db, date, "", stats, true)
return replaceHydrogenDailyStats(ctx, db, date, nil, stats, true)
}
func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat) error {
@@ -827,10 +854,30 @@ func ReplaceHydrogenDailyStatsForVIN(ctx context.Context, db *sql.DB, date, vin
if !validHydrogenVIN(vin) {
return fmt.Errorf("invalid VIN %q", vin)
}
return replaceHydrogenDailyStats(ctx, db, date, vin, stats, false)
return replaceHydrogenDailyStats(ctx, db, date, []string{vin}, stats, false)
}
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string, stats []HydrogenDailyStat, seedStream bool) error {
func ReplaceHydrogenDailyStatsForVINs(ctx context.Context, db *sql.DB, date string, vins []string, stats []HydrogenDailyStat) error {
normalized := make([]string, 0, len(vins))
seen := make(map[string]struct{}, len(vins))
for _, vin := range vins {
vin = strings.ToUpper(strings.TrimSpace(vin))
if !validHydrogenVIN(vin) {
return fmt.Errorf("invalid VIN %q", vin)
}
if _, exists := seen[vin]; exists {
continue
}
seen[vin] = struct{}{}
normalized = append(normalized, vin)
}
if len(normalized) == 0 {
return errors.New("at least one scoped VIN is required")
}
return replaceHydrogenDailyStats(ctx, db, date, normalized, stats, false)
}
func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, scopedVINs []string, stats []HydrogenDailyStat, seedStream bool) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
@@ -839,12 +886,26 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
// A pressure-based rebuild is authoritative for the whole day. Delete every
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
// for vehicles without valid pressure observations in this run.
if vin == "" {
allowedVINs := make(map[string]struct{}, len(scopedVINs))
for _, vin := range scopedVINs {
allowedVINs[vin] = struct{}{}
}
if len(scopedVINs) == 0 {
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
return err
}
} else if len(scopedVINs) == 1 {
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, scopedVINs[0]); err != nil {
return err
}
} else {
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin=?`, date, vin); err != nil {
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(scopedVINs)), ",")
args := make([]any, 0, len(scopedVINs)+1)
args = append(args, date)
for _, vin := range scopedVINs {
args = append(args, vin)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (`+placeholders+`)`, args...); err != nil {
return err
}
}
@@ -854,8 +915,11 @@ func replaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date, vin string
}
}
for _, stat := range stats {
if vin != "" && !strings.EqualFold(strings.TrimSpace(stat.VIN), vin) {
return fmt.Errorf("refusing to write VIN %q during scoped rebuild for %q", stat.VIN, vin)
statVIN := strings.ToUpper(strings.TrimSpace(stat.VIN))
if len(allowedVINs) > 0 {
if _, allowed := allowedVINs[statVIN]; !allowed {
return fmt.Errorf("refusing to write VIN %q outside scoped rebuild", stat.VIN)
}
}
parameterJSON, err := json.Marshal(stat.CalculationParameters)
if err != nil {
@@ -872,8 +936,8 @@ INSERT INTO vehicle_open_daily_energy(
raw_consumption_kg,battery_soc_delta_pct,battery_discharge_kwh,battery_equivalent_kg,
soc_balanced_consumption_kg,mixed_mileage_km,pure_electric_mileage_km,
consumption_kg_per_100km,soc_balanced_kg_per_100km,charge_count,valid_segment_count,
invalid_segment_count,algorithm_version,parameter_json,evidence_json
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
invalid_segment_count,algorithm_version,calculation_phase,parameter_json,evidence_json
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3),?,?,?,?,?,?,?,?,?,?,?,?,?,'FINAL',?,?)`,
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
stat.ConsumptionKg, stat.BatterySOCDeltaPct, stat.BatteryDischargeKWh, stat.BatteryEquivalentKg,
@@ -883,7 +947,10 @@ INSERT INTO vehicle_open_daily_energy(
); err != nil {
return err
}
if seedStream {
// A NO_DATA row can legitimately have no effective boundary observation.
// Keep its daily result, but do not write a zero timestamp into the stream
// watermark table; the next valid realtime frame will create the state.
if seedStream && !stat.LastObservation.ObservedAt.IsZero() {
stateJSON, err := hydrogenSegmentStreamSeedJSON(stat)
if err != nil {
return err
@@ -893,10 +960,10 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
vin,stat_date,source_endpoint,finalized_consumption_kg,projected_consumption_kg,
sample_count,refuel_count,abnormal_drop_count,eligible_interval_count,qualified_segment_count,
last_mass_kg,last_event_time,last_event_id,state_json,calculation_method,quality_status,quality_reason
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_SEGMENT_MEDIAN_5',?,?)`,
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5',?,?)`,
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.ConsumptionKg,
stat.SampleCount, stat.RefuelCount, stat.AbnormalDropCount, stat.EligibleIntervalCount,
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, "",
stat.QualifiedSegmentCount, stat.LastMassKg, stat.LastObservation.ObservedAt, stat.LastObservation.EventID,
stateJSON, stat.QualityStatus, stat.QualityReason,
); err != nil {
return err
@@ -907,54 +974,113 @@ INSERT INTO vehicle_open_hydrogen_segment_stream_state(
}
func hydrogenSegmentStreamSeedJSON(stat HydrogenDailyStat) ([]byte, error) {
type segmentWindow struct {
Count int `json:"count"`
First []any `json:"first"`
Tail []any `json:"tail"`
CycleMinimumMassKg float64 `json:"cycleMinimumMassKg"`
type point struct {
EventID string `json:"eventId"`
ObservedAt time.Time `json:"observedAt"`
MassKg float64 `json:"massKg"`
PressureMpa float64 `json:"pressureMpa"`
TemperatureC float64 `json:"temperatureC"`
NoiseKg float64 `json:"noiseKg"`
MileageKm float64 `json:"mileageKm"`
MileageKnown bool `json:"mileageKnown"`
SocPercent float64 `json:"socPercent"`
SocKnown bool `json:"socKnown"`
RunningMode int `json:"runningMode"`
FuelCellActive bool `json:"fuelCellActive"`
FuelCellStateKnown bool `json:"fuelCellStateKnown"`
FuelCellPowerKw float64 `json:"fuelCellPowerKw"`
FuelCellPowerKnown bool `json:"fuelCellPowerKnown"`
}
toPoint := func(value HydrogenObservation) point {
return point{
EventID: value.EventID, ObservedAt: value.ObservedAt, MassKg: value.MassKg,
PressureMpa: value.PressureMPa, TemperatureC: value.TemperatureC, NoiseKg: value.NoiseKg,
MileageKm: value.MileageKm, MileageKnown: value.MileageKnown,
SocPercent: value.SOCPercent, SocKnown: value.SOCKnown, RunningMode: value.RunningMode,
FuelCellActive: value.FuelCellActive, FuelCellStateKnown: value.FuelCellStateKnown,
FuelCellPowerKw: value.FuelCellVoltageV * value.FuelCellCurrentA / 1000,
FuelCellPowerKnown: value.FuelCellPowerKnown,
}
}
first, last := toPoint(stat.FirstObservation), toPoint(stat.LastObservation)
lastIntervalType := ""
mixedCycles := 0
for _, interval := range stat.Intervals {
if interval.Type == "MIXED" {
mixedCycles++
}
lastIntervalType = interval.Type
}
cycle := map[string]any{
"first": last, "last": last, "hasRun": !last.ObservedAt.IsZero(),
"startsPure": lastIntervalType == "PURE_ELECTRIC", "mixedLocked": lastIntervalType == "MIXED",
"mixedConfirmed": lastIntervalType == "MIXED", "mixedStart": last,
}
state := struct {
SourceEndpoint string `json:"sourceEndpoint"`
FinalizedConsumptionKg float64 `json:"finalizedConsumptionKg"`
SampleCount int `json:"sampleCount"`
RefuelCount int `json:"refuelCount"`
AbnormalDropCount int `json:"abnormalDropCount"`
EligibleIntervalCount int `json:"eligibleIntervalCount"`
QualifiedSegmentCount int `json:"qualifiedSegmentCount"`
FirstMassKg float64 `json:"firstMassKg"`
RemainingMassKg float64 `json:"remainingMassKg"`
MetadataCycleMinimumKg float64 `json:"metadataCycleMinimumKg"`
LastObservedMassKg float64 `json:"lastObservedMassKg"`
TankCapacityLiters float64 `json:"tankCapacityLiters"`
FirstPressureMpa float64 `json:"firstPressureMpa"`
LastPressureMpa float64 `json:"lastPressureMpa"`
FirstTemperatureC float64 `json:"firstTemperatureC"`
LastTemperatureC float64 `json:"lastTemperatureC"`
FirstEventTime time.Time `json:"firstEventTime"`
LastEventTime time.Time `json:"lastEventTime"`
LastEventID string `json:"lastEventId"`
LastFuelCellActive bool `json:"lastFuelCellActive"`
LastFuelCellStateKnown bool `json:"lastFuelCellStateKnown"`
Segment segmentWindow `json:"segment"`
AlgorithmVersion string `json:"algorithmVersion"`
SourceEndpoint string `json:"sourceEndpoint"`
SampleCount int `json:"sampleCount"`
LastEventID string `json:"lastEventId"`
LastEventTime time.Time `json:"lastEventTime"`
Parameters map[string]float64 `json:"parameters"`
FirstMassKg float64 `json:"firstMassKg"`
LastMassKg float64 `json:"lastMassKg"`
FirstEffective point `json:"firstEffective"`
LastEffective point `json:"lastEffective"`
HasEffective bool `json:"hasEffective"`
EffectiveRunCount int `json:"effectiveRunCount"`
Cycle map[string]any `json:"cycle"`
InitialStateUsed bool `json:"initialStateUsed"`
ChargeCount int `json:"chargeCount"`
ChargeEnergyKWh float64 `json:"chargeEnergyKWh"`
CompletedPureKm float64 `json:"completedPureKm"`
CompletedMixedKm float64 `json:"completedMixedKm"`
CompletedSOCDelta float64 `json:"completedSocDelta"`
CompletedMixedCycles int `json:"completedMixedCycles"`
HydrogenSegmentStart point `json:"hydrogenSegmentStart"`
LastHydrogenRun point `json:"lastHydrogenRun"`
HasHydrogenSegment bool `json:"hasHydrogenSegment"`
HydrogenSegmentRuns int `json:"hydrogenSegmentRuns"`
FinalizedHydrogenKg float64 `json:"finalizedHydrogenKg"`
HydrogenSegments int `json:"hydrogenSegments"`
RefuelCount int `json:"refuelCount"`
RefuelAmountKg float64 `json:"refuelAmountKg"`
MinimumPressureMpa float64 `json:"minimumPressureMpa"`
MinimumMassKg float64 `json:"minimumMassKg"`
MinimumObservedAt time.Time `json:"minimumObservedAt"`
PreviousSample point `json:"previousSample"`
HasPreviousSample bool `json:"hasPreviousSample"`
AbnormalDropCount int `json:"abnormalDropCount"`
InvalidSampleCount int `json:"invalidSampleCount"`
SuspectedLeakCount int `json:"suspectedLeakCount"`
SuspectedLeakMaxMpa float64 `json:"suspectedLeakMaxMpa"`
}{
SourceEndpoint: stat.Source, FinalizedConsumptionKg: stat.ConsumptionKg,
SampleCount: stat.SampleCount, RefuelCount: stat.RefuelCount,
AbnormalDropCount: stat.AbnormalDropCount, EligibleIntervalCount: stat.EligibleIntervalCount,
QualifiedSegmentCount: stat.QualifiedSegmentCount,
FirstMassKg: stat.FirstMassKg, RemainingMassKg: stat.LastMassKg,
MetadataCycleMinimumKg: stat.CycleMinimumMassKg,
LastObservedMassKg: stat.LastObservation.MassKg,
TankCapacityLiters: stat.LastObservation.TankCapacityLiter,
FirstPressureMpa: stat.FirstObservation.PressureMPa, LastPressureMpa: stat.LastObservation.PressureMPa,
FirstTemperatureC: stat.FirstObservation.TemperatureC, LastTemperatureC: stat.LastObservation.TemperatureC,
FirstEventTime: stat.FirstObservation.ObservedAt, LastEventTime: stat.LastObservation.ObservedAt,
LastFuelCellActive: stat.LastObservation.FuelCellActive,
LastFuelCellStateKnown: stat.LastObservation.FuelCellStateKnown,
Segment: segmentWindow{First: []any{}, Tail: []any{}},
AlgorithmVersion: trustedHydrogenAlgorithmVersion, SourceEndpoint: stat.Source,
SampleCount: stat.SampleCount, LastEventID: stat.LastObservation.EventID, LastEventTime: stat.LastObservation.ObservedAt,
Parameters: map[string]float64{"batteryCapacityKWh": stat.CalculationParameters.BatteryCapacityKWh, "hydrogenEnergyKWhPerKg": stat.CalculationParameters.HydrogenEnergyKWhKg},
FirstMassKg: stat.FirstMassKg, LastMassKg: stat.LastMassKg,
FirstEffective: first, LastEffective: last, HasEffective: !last.ObservedAt.IsZero(), EffectiveRunCount: stat.EligibleIntervalCount,
Cycle: cycle, InitialStateUsed: true, ChargeCount: stat.ChargeCount, ChargeEnergyKWh: stat.ChargeEnergyKWh,
CompletedPureKm: stat.PureElectricMileageKm, CompletedMixedKm: stat.MixedMileageKm,
CompletedSOCDelta: valueOrZero(stat.BatterySOCDeltaPct), CompletedMixedCycles: mixedCycles,
HydrogenSegmentStart: last, LastHydrogenRun: last, HasHydrogenSegment: !last.ObservedAt.IsZero(), HydrogenSegmentRuns: 1,
FinalizedHydrogenKg: stat.ConsumptionKg, HydrogenSegments: stat.QualifiedSegmentCount,
RefuelCount: stat.RefuelCount, RefuelAmountKg: stat.RefuelAmountKg,
MinimumPressureMpa: last.PressureMpa, MinimumMassKg: last.MassKg, MinimumObservedAt: last.ObservedAt,
PreviousSample: last, HasPreviousSample: !last.ObservedAt.IsZero(),
AbnormalDropCount: stat.AbnormalDropCount, InvalidSampleCount: stat.InvalidSegmentCount,
SuspectedLeakCount: stat.SuspectedLeakCount, SuspectedLeakMaxMpa: stat.SuspectedLeakMaxPressureDropMPa,
}
return json.Marshal(state)
}
func valueOrZero(value *float64) float64 {
if value == nil {
return 0
}
return *value
}
func numericValue(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
@@ -301,6 +301,26 @@ func TestLoadHydrogenObservationVINsFiltersMissingCapacity(t *testing.T) {
}
}
func TestLoadHydrogenBrandVINsUsesExactBrandAndValidVINs(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
mock.ExpectQuery(regexp.QuoteMeta("SELECT UPPER(TRIM(vin))")).
WithArgs("现代").
WillReturnRows(sqlmock.NewRows([]string{"vin"}).
AddRow("lb9a32a25r0ls1452").
AddRow("invalid"))
vins, err := LoadHydrogenBrandVINs(context.Background(), db, " 现代 ")
if err != nil || len(vins) != 1 || vins[0] != "LB9A32A25R0LS1452" {
t.Fatalf("vins=%v err=%v", vins, err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestHydrogenObservationDateRangeForAllUsesBoundedQueries(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -351,6 +371,37 @@ func TestReplaceHydrogenDailyStatsForVINDeletesOnlyScopedVehicle(t *testing.T) {
}
}
func TestReplaceHydrogenDailyStatsForVINsDeletesOnlyScopedBrandVehicles(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
vins := []string{"LB9A32A24R0LS1376", "LB9A32A25R0LS1452"}
stat := HydrogenDailyStat{
VIN: vins[0], Date: "2026-08-12", Source: "source-a", ConsumptionKg: 2.5,
FirstMassKg: 12, LastMassKg: 9.5, SampleCount: 500, QualityStatus: "OK",
}
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN' AND vin IN (?,?)")).
WithArgs(stat.Date, vins[0], vins[1]).
WillReturnResult(sqlmock.NewResult(0, 2))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
WithArgs(stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
stat.SampleCount, 0, stat.QualityStatus, "", stat.ConsumptionKg,
sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
if err := ReplaceHydrogenDailyStatsForVINs(context.Background(), db, stat.Date, vins, []HydrogenDailyStat{stat}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
@@ -396,7 +447,37 @@ func TestReplaceHydrogenDailyStatsAndSeedStreamIsAtomic(t *testing.T) {
if err := json.Unmarshal(encoded, &state); err != nil {
t.Fatal(err)
}
if state["lastObservedMassKg"] != 9.4 || state["finalizedConsumptionKg"] != 2.6 || state["lastEventTime"] == "" {
if state["lastMassKg"] != 9.4 || state["finalizedHydrogenKg"] != 2.6 || state["lastEventTime"] == "" || state["algorithmVersion"] != trustedHydrogenAlgorithmVersion {
t.Fatalf("seed state=%s", encoded)
}
}
func TestReplaceHydrogenDailyStatsAndSeedStreamSkipsMissingWatermark(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
stat := HydrogenDailyStat{
VIN: "LB9A32A24R0LS1376", Date: "2026-09-04", Source: "source-a",
QualityStatus: "NO_DATA", QualityReason: "有效运行分界点不足2条",
}
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'")).
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta("DELETE FROM vehicle_open_hydrogen_segment_stream_state WHERE stat_date=?")).
WithArgs("2026-09-04").WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO vehicle_open_daily_energy(")).
WithArgs(stat.VIN, stat.Date, stat.Source, 0.0, 0.0, 0.0, 0, 0, stat.QualityStatus, stat.QualityReason,
0.0, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), 0.0, 0.0,
sqlmock.AnyArg(), sqlmock.AnyArg(), 0, 0, 0, "", sqlmock.AnyArg(), sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
if err := ReplaceHydrogenDailyStatsAndSeedStream(context.Background(), db, stat.Date, []HydrogenDailyStat{stat}); err != nil {
t.Fatal(err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
@@ -441,7 +441,7 @@ func TestHandlerVehicleCoverage(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426"} {
for _, want := range []string{"sourceCount", "onlineSourceCount", "protocols", "LB9A32A24R0LS1426", `"brandName":"飞驰"`, `"modelName":"新能源运营车"`} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("response missing %q: %s", want, rec.Body.String())
}
@@ -776,6 +776,8 @@ func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatu
}
func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
m.profileMu.RLock()
defer m.profileMu.RUnlock()
vehicles := filterVehicles(m.vehicles, query)
byVIN := map[string]*VehicleCoverageRow{}
for _, vehicle := range vehicles {
@@ -805,6 +807,12 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
}
items := make([]VehicleCoverageRow, 0, len(byVIN))
for _, row := range byVIN {
if profile, ok := m.profiles[row.VIN]; ok {
row.BrandName = firstNonEmpty(profile.BrandName, row.OEM)
row.ModelName = profile.ModelName
} else {
row.BrandName = row.OEM
}
row.MissingProtocols = missingCanonicalProtocols(row.Protocols)
onlineProtocols := make([]string, 0, row.OnlineSourceCount)
for _, vehicle := range vehicles {
@@ -1230,8 +1238,24 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
var allowedVINs map[string]bool
if query.ScopeVINs != nil {
allowedVINs = make(map[string]bool, len(query.ScopeVINs))
for _, vin := range query.ScopeVINs {
allowedVINs[strings.ToUpper(strings.TrimSpace(vin))] = true
}
}
for _, keyword := range normalizedKeywords(query.Keywords) {
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
if allowedVINs != nil {
scoped := vehicles[:0]
for _, vehicle := range vehicles {
if allowedVINs[strings.ToUpper(strings.TrimSpace(vehicle.VIN))] {
scoped = append(scoped, vehicle)
}
}
vehicles = scoped
}
resolution := buildVehicleIdentityResolution(keyword, vehicles)
identity := resolveVehicleIdentity(keyword, vehicles)
resolvedVIN := ""
@@ -1241,7 +1265,11 @@ func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOver
resolvedVIN = resolution.VIN
}
if resolvedVIN == "" {
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
if query.ScopeVINs != nil {
missing.VIN = ""
}
items = append(items, missing)
continue
}
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
@@ -1351,6 +1351,8 @@ type VehicleCoverageRow struct {
Plate string `json:"plate"`
Phone string `json:"phone"`
OEM string `json:"oem"`
BrandName string `json:"brandName"`
ModelName string `json:"modelName"`
Protocols []string `json:"protocols"`
MissingProtocols []string `json:"missingProtocols"`
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
@@ -159,6 +159,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
}
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
`LEFT JOIN vehicle_profile p ON p.vin = v.vin ` +
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
@@ -169,6 +170,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
Text: `SELECT v.vin, ` +
`COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, ` +
`COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, ` +
`COALESCE(NULLIF(MAX(NULLIF(p.brand_name, '')), ''), NULLIF(b.oem, ''), '') AS brand_name, ` +
`COALESCE(MAX(p.model_name), '') AS model_name, ` +
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
`COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END ORDER BY s.protocol SEPARATOR ','), '') AS online_protocols, ` +
`COUNT(DISTINCT s.protocol) AS source_count, ` +
@@ -41,14 +41,17 @@ func (p Principal) Clone() Principal {
}
func (p Principal) CanMenu(key string) bool {
if p.UserType == "admin" || p.Role == "admin" {
return true
}
for _, value := range p.MenuKeys {
if value == key {
return true
}
}
// Legacy admin principals did not carry an explicit menu list. Keep those
// principals unrestricted while allowing persisted admin menus to remove a
// sensitive surface such as account management.
if len(p.MenuKeys) == 0 && (p.UserType == "admin" || p.Role == "admin") {
return true
}
return false
}
@@ -321,7 +321,7 @@ func (s *ProductionStore) VehicleCoverage(ctx context.Context, query url.Values)
var protocols string
var onlineProtocols string
var online int
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.BrandName, &row.ModelName, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.LastSeen, &row.BindingStatus); err != nil {
return Page[VehicleCoverageRow]{}, err
}
row.Protocols = splitCSV(protocols)
@@ -503,7 +503,11 @@ func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query Veh
continue
}
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
missing := *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
if query.ScopeVINs != nil {
missing.VIN = ""
}
items = append(items, missing)
}
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
@@ -518,6 +522,18 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
protocolJoin = " AND s.protocol = ? "
args = append(args, protocol)
}
scopeWhere := ""
if query.ScopeVINs != nil {
scopeVINs := normalizedKeywords(query.ScopeVINs)
if len(scopeVINs) == 0 {
scopeWhere = " AND 1 = 0 "
} else {
scopeWhere = " AND i.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(scopeVINs)), ",") + ") "
for _, vin := range scopeVINs {
args = append(args, strings.ToUpper(vin))
}
}
}
return SQLQuery{
Text: `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
`COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` +
@@ -534,7 +550,7 @@ func buildVehicleServiceOverviewBatchSQL(query VehicleOverviewBatchQuery) SQLQue
`WHERE ` + realtimeWhere + ` ` +
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
`WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`,
`WHERE i.vin IS NOT NULL AND i.vin <> ''` + scopeWhere + `GROUP BY i.vin`,
Args: args,
}
}
@@ -143,6 +143,23 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
}
}
func TestBuildVehicleServiceOverviewBatchSQLRestrictsCustomerVINScope(t *testing.T) {
built := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{
Keywords: []string{"粤A"}, ScopeVINs: []string{"vin001", "VIN002"},
})
if !strings.Contains(built.Text, "i.vin IN (?,?)") {
t.Fatalf("batch overview SQL should restrict results to granted VINs, got %s", built.Text)
}
if got := built.Args[len(built.Args)-2:]; got[0] != "VIN001" || got[1] != "VIN002" {
t.Fatalf("customer VIN scope should be normalized and bound last, got %#v", built.Args)
}
empty := buildVehicleServiceOverviewBatchSQL(VehicleOverviewBatchQuery{Keywords: []string{"粤A"}, ScopeVINs: []string{}})
if !strings.Contains(empty.Text, "AND 1 = 0") {
t.Fatalf("empty customer VIN scope must fail closed, got %s", empty.Text)
}
}
func TestHydrogenRatePer100KmUsesMatchedPureHydrogenMileage(t *testing.T) {
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
if rate == nil || *rate < 3.77 || *rate > 3.78 {
@@ -77,7 +77,7 @@ func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
func TestBuildVehicleCoverageSQL(t *testing.T) {
query := url.Values{"keyword": {"粤A"}, "protocol": {"GB32960"}, "coverage": {"multi"}, "online": {"online"}, "bindingStatus": {"bound"}, "limit": {"8"}, "offset": {"16"}}
built := buildVehicleCoverageSQL(query)
for _, want := range []string{"GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
for _, want := range []string{"LEFT JOIN vehicle_profile p ON p.vin = v.vin", "brand_name", "model_name", "GROUP BY v.vin", "HAVING", "GROUP_CONCAT(DISTINCT s.protocol", "source_count", "online_source_count", "ORDER BY MAX(s.updated_at) DESC, v.vin ASC"} {
if !strings.Contains(built.Text, want) {
t.Fatalf("SQL missing %q: %s", want, built.Text)
}
@@ -91,10 +91,11 @@ type RawFrameQuery struct {
}
type VehicleOverviewBatchQuery struct {
Keywords []string `json:"keywords"`
Protocol string `json:"protocol"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Keywords []string `json:"keywords"`
Protocol string `json:"protocol"`
Limit int `json:"limit"`
Offset int `json:"offset"`
ScopeVINs []string `json:"-"`
}
type Service struct {
@@ -869,10 +870,12 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
}
}
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
scopeVINs := principalVehicleVINScope(ctx)
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{keyword},
Protocol: protocol,
Limit: 1,
Keywords: []string{keyword},
Protocol: protocol,
Limit: 1,
ScopeVINs: scopeVINs,
})
if err != nil {
return VehicleServiceOverview{}, err
@@ -927,11 +930,13 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
query.Keywords = keywords
query.Limit = limit
query.Offset = offset
query.ScopeVINs = principalVehicleVINScope(ctx)
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, query)
if err != nil {
return Page[VehicleServiceOverview]{}, err
}
page = restrictVehicleOverviewPage(ctx, page)
page.Total = total
page.Limit = limit
page.Offset = offset
@@ -948,6 +953,34 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func principalVehicleVINScope(ctx context.Context) []string {
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return nil
}
scope := make([]string, 0, len(principal.VehicleVINs))
for _, vin := range principal.VehicleVINs {
if vin = strings.ToUpper(strings.TrimSpace(vin)); vin != "" {
scope = append(scope, vin)
}
}
return scope
}
func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] {
principal, ok := PrincipalFromContext(ctx)
if !ok || principal.UserType != "customer" {
return page
}
for index := range page.Items {
if principal.CanVIN(strings.ToUpper(strings.TrimSpace(page.Items[index].VIN))) {
continue
}
page.Items[index] = *buildVehicleServiceOverview("", "", &VehicleIdentityResolution{Protocols: []string{}}, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
}
return page
}
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
keyword := strings.TrimSpace(vin)
protocol = strings.TrimSpace(protocol)
@@ -547,6 +547,7 @@ type countingStore struct {
vehiclesCalls int
vehicleRealtimeCalls int
overviewBatchCalls int
lastOverviewBatchQuery VehicleOverviewBatchQuery
lastVehicleQuery url.Values
lastRealtimeQuery url.Values
lastHistoryQuery url.Values
@@ -1010,6 +1011,7 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi
func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++
s.lastOverviewBatchQuery = query
return s.MockStore.VehicleServiceOverviews(ctx, query)
}
@@ -1046,6 +1048,26 @@ func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
}
}
func TestCustomerVehicleServiceOverviewsStayInsideGrantedVINScope(t *testing.T) {
store := newCountingStore()
service := NewService(store)
ctx := WithPrincipal(context.Background(), Principal{
Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"},
})
page, err := service.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200,
})
if err != nil {
t.Fatalf("customer batch lookup returned error: %v", err)
}
if len(store.lastOverviewBatchQuery.ScopeVINs) != 1 || store.lastOverviewBatchQuery.ScopeVINs[0] != "LB9A32A24R0LS1426" {
t.Fatalf("customer grant scope was not passed to batch store: %+v", store.lastOverviewBatchQuery.ScopeVINs)
}
if page.Total != 2 || len(page.Items) != 2 || page.Items[0].VIN != "LB9A32A24R0LS1426" || page.Items[1].VIN != "" {
t.Fatalf("batch lookup must preserve input order without exposing ungranted vehicles: %+v", page)
}
}
func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) {
service := NewService(NewMockStore())
summary, err := service.VehicleServiceSummary(context.Background())
@@ -19,9 +19,21 @@ func Handler(dir string, fallback http.Handler) http.Handler {
}
path := filepath.Join(dir, filepath.Clean(r.URL.Path))
if info, err := os.Stat(path); err == nil && !info.IsDir() {
setCachePolicy(w, r.URL.Path)
fs.ServeHTTP(w, r)
return
}
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
})
}
func setCachePolicy(w http.ResponseWriter, path string) {
if path == "/" || path == "/index.html" || path == "/app-config.js" {
w.Header().Set("Cache-Control", "no-store")
return
}
if strings.HasPrefix(path, "/assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
}
@@ -0,0 +1,45 @@
package static
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func TestHandlerDisablesCachingForApplicationShell(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "app-config.js"), []byte("config"), 0o600); err != nil {
t.Fatal(err)
}
handler := Handler(dir, http.NotFoundHandler())
for _, path := range []string{"/", "/index.html", "/app-config.js", "/users"} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if got := response.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("%s Cache-Control=%q, want no-store", path, got)
}
}
}
func TestHandlerCachesHashedAssetsImmutably(t *testing.T) {
dir := t.TempDir()
assets := filepath.Join(dir, "assets")
if err := os.Mkdir(assets, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(assets, "app-1234.js"), []byte("app"), 0o600); err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
Handler(dir, http.NotFoundHandler()).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/assets/app-1234.js", nil))
if got := response.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("asset Cache-Control=%q", got)
}
}