diff --git a/docs/ops/vehicle-ingest-runbook.md b/docs/ops/vehicle-ingest-runbook.md index 709de23a..94da5a06 100644 --- a/docs/ops/vehicle-ingest-runbook.md +++ b/docs/ops/vehicle-ingest-runbook.md @@ -118,6 +118,8 @@ WAL 在分组 `fsync` 成功后才向协议处理线程返回接受成功,网 GB32960 登录密码在鉴权完成后会从 `parsed_fields` 删除,只保留 `password_present`;协议原始帧仍可能包含凭据,因此 RAW 查询接口和 TDengine `raw_hex` 必须按敏感数据控制访问。JT808 的 `0x0102` 会先匹配配置的公共鉴权码,再按规范化手机号匹配 `jt808_registration.auth_token` 的只读内存快照,全程不查询 MySQL;指标 `source=device` 表示命中终端历史鉴权码。强制模式拒绝的鉴权帧不会覆盖该可信鉴权码和最近鉴权成功时间。JT808 的 `enforce` 不会拒绝未发注册/认证、直接上报 `0x0200` 的兼容平台;这类来源继续通过身份缺口指标治理。 +GB32960 的 `Feichi` 平台账号使用独立的 `0x80` 处理:按同一 TCP 连接的平台登录账号精确匹配 `Feichi`,以两字节大端声明长度读取整个自定义块,不套用广东演示扩展的固定字段含义。完整帧继续保存在 RAW `raw_hex`;各块保存为 `gb32960.feichi_custom_80.blocks.block_1.*`、`block_2.*` 等字段,包含 `declared_length`、`actual_length`、`length_hex`、`payload_hex` 和包含类型字节的 `raw_hex`。声明长度超出剩余报文或长度字节不完整时保留所有已收到字节,标记 `PARTIAL`,并在块内保存 `parse_error=truncated_payload/truncated_length`。`Hyundai`、`YueJin`、未登录及其他账号继续使用原解析;`Feichi` 的其他数据单元保持原解析。外部直推和本机 API 桥接都使用此账号,因此按账号生效,不按来源 IP 选择。 + ## 标准发布流程 Go 原生服务发布必须使用 Linux amd64 构建产物,不要直接上传本机默认 `go build` 结果。仓库提供统一脚本: diff --git a/go/vehicle-gateway/cmd/gateway/main.go b/go/vehicle-gateway/cmd/gateway/main.go index 0880d102..a30874b0 100644 --- a/go/vehicle-gateway/cmd/gateway/main.go +++ b/go/vehicle-gateway/cmd/gateway/main.go @@ -66,12 +66,13 @@ func main() { protocols := []gateway.TCPProtocol{ { - Protocol: envelope.ProtocolGB32960, - Addr: env("GB32960_TCP_ADDR", ":32960"), - Extract: gb32960.ExtractFrames, - Parse: gb32960.ParseFrame, - Authenticate: gb32960Authenticator, - Respond: gb32960.AutoResponse, + Protocol: envelope.ProtocolGB32960, + Addr: env("GB32960_TCP_ADDR", ":32960"), + Extract: gb32960.ExtractFrames, + Parse: gb32960.ParseFrame, + ParseForPlatform: gb32960.ParseFrameForPlatform, + Authenticate: gb32960Authenticator, + Respond: gb32960.AutoResponse, }, { Protocol: envelope.ProtocolJT808, diff --git a/go/vehicle-gateway/cmd/mileage-baseline-repair/main.go b/go/vehicle-gateway/cmd/mileage-baseline-repair/main.go index dcbcdca3..a54c7d34 100644 --- a/go/vehicle-gateway/cmd/mileage-baseline-repair/main.go +++ b/go/vehicle-gateway/cmd/mileage-baseline-repair/main.go @@ -14,6 +14,7 @@ import ( "os" "sort" "strconv" + "strings" "time" ) @@ -28,10 +29,13 @@ type change struct { Total string `json:"latest_total_mileage_km"` LastTime string `json:"latest_event_time"` Daily string `json:"daily_mileage_km"` + Status string `json:"quality_status"` + Selected int `json:"is_selected"` } `json:"before"` Baseline struct { - Total string `json:"total"` - Time string `json:"time"` + Source string `json:"source_key"` + Total string `json:"total"` + Time string `json:"time"` } `json:"baseline"` Daily string `json:"daily"` Status string `json:"status"` @@ -42,6 +46,7 @@ func main() { input := flag.String("input", "", "reviewed JSON plan") apply := flag.Bool("apply", false, "apply plan and projections in one transaction") manifest := flag.String("backup-manifest", "", "verified backup manifest required for apply") + selectedOnly := flag.Bool("selected-projections-only", false, "preserve final results when only unselected candidates change") flag.Parse() data, err := os.ReadFile(*input) must(err) @@ -67,24 +72,44 @@ func main() { affected := map[string]change{} for _, c := range changes { b := c.Before - total, stamp, found, err := stats.LookupLatestSourceBaselineBefore(ctx, tx, b.VIN, b.Date, envelope.Protocol(b.Protocol), b.Source) + baselineSource := b.Source + if c.Baseline.Source != "" && c.Baseline.Source != b.Source { + // Historical IP keys and current platform keys may describe the + // same terminal. Verify that identity against the registry before + // accepting an explicitly reviewed alias boundary. + currentCanonical, err := canonicalRepairSource(ctx, tx, b.Protocol, b.Source) + must(err) + baselineCanonical, err := canonicalRepairSource(ctx, tx, b.Protocol, c.Baseline.Source) + must(err) + if currentCanonical != baselineCanonical { + panic("repair cannot mix independent source counters") + } + baselineSource = c.Baseline.Source + } + total, stamp, found, err := stats.LookupLatestSourceBaselineBefore(ctx, tx, b.VIN, b.Date, envelope.Protocol(b.Protocol), baselineSource) must(err) expected, err := strconv.ParseFloat(c.Baseline.Total, 64) must(err) if !found || math.Abs(total-expected) > 0.0001 || stamp.Format("2006-01-02 15:04:05") != c.Baseline.Time { panic(fmt.Sprintf("baseline changed: %s %s", b.VIN, b.Date)) } + oldStatus := b.Status + if oldStatus == "" { + oldStatus = stats.QualityOK + } result, err := tx.ExecContext(ctx, `UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=?,first_event_time=?,daily_mileage_km=?,quality_status=?,quality_reason=? WHERE vin=? AND stat_date=? AND protocol=? AND source_key=? AND first_total_mileage_km <=> ? AND first_event_time <=> ? AND latest_total_mileage_km=? - AND latest_event_time=? AND daily_mileage_km=? AND quality_status='OK'`, c.Baseline.Total, c.Baseline.Time, c.Daily, c.Status, c.Reason, b.VIN, b.Date, b.Protocol, b.Source, b.First, b.FirstTime, b.Total, b.LastTime, b.Daily) + AND latest_event_time=? AND daily_mileage_km=? AND quality_status=?`, c.Baseline.Total, c.Baseline.Time, c.Daily, c.Status, c.Reason, b.VIN, b.Date, b.Protocol, b.Source, b.First, b.FirstTime, b.Total, b.LastTime, b.Daily, oldStatus) must(err) n, err := result.RowsAffected() must(err) if n != 1 { panic(fmt.Sprintf("concurrent change: %s %s %s", b.VIN, b.Date, b.Source)) } - affected[b.Date+"|"+b.VIN+"|"+b.Protocol] = c + if !*selectedOnly || b.Selected == 1 { + affected[b.Date+"|"+b.VIN+"|"+b.Protocol] = c + } } keys := make([]string, 0, len(affected)) for key := range affected { @@ -98,6 +123,43 @@ func main() { must(tx.Commit()) must(json.NewEncoder(os.Stdout).Encode(map[string]any{"updatedSources": len(changes), "reprojectedDays": len(keys), "committed": true})) } + +func canonicalRepairSource(ctx context.Context, db stats.Queryer, protocol, key string) (string, error) { + if protocol != "JT808" { + return "", fmt.Errorf("source alias repair is only supported for JT808") + } + if strings.Contains(key, "@PLATFORM:") || strings.HasSuffix(key, "@DIRECT") { + return key, nil + } + at := strings.LastIndex(key, "@") + if at < 0 || !strings.HasPrefix(key, protocol+":") { + return "", fmt.Errorf("unsupported legacy source key") + } + rows, err := db.QueryContext(ctx, `SELECT COALESCE(source_code,''),source_kind +FROM vehicle_data_source WHERE protocol=? AND source_ip=?`, protocol, key[at+1:]) + if err != nil { + return "", err + } + defer rows.Close() + if !rows.Next() { + return "", fmt.Errorf("source registry is missing the legacy IP") + } + var code, kind string + if err := rows.Scan(&code, &kind); err != nil { + return "", err + } + if err := rows.Err(); err != nil { + return "", err + } + if kind == "PLATFORM" && strings.TrimSpace(code) != "" { + return key[:at] + "@PLATFORM:" + strings.TrimSpace(code), nil + } + if kind == "DIRECT" { + return key[:at] + "@DIRECT", nil + } + return "", fmt.Errorf("legacy source has no verified platform or direct-terminal identity") +} + func must(err error) { if err != nil { panic(err) diff --git a/go/vehicle-gateway/cmd/mileage-baseline-repair/main_test.go b/go/vehicle-gateway/cmd/mileage-baseline-repair/main_test.go new file mode 100644 index 00000000..6b339101 --- /dev/null +++ b/go/vehicle-gateway/cmd/mileage-baseline-repair/main_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestCanonicalRepairSourceRequiresVerifiedRegistryIdentity(t *testing.T) { + for _, tc := range []struct { + name, key, code, kind, want string + missing, wantError bool + }{ + {name: "platform", key: "JT808:41456413939@117.132.196.192", code: "guangan_beidou", kind: "PLATFORM", want: "JT808:41456413939@PLATFORM:guangan_beidou"}, + {name: "direct", key: "JT808:41456413939@117.132.196.192", kind: "DIRECT", want: "JT808:41456413939@DIRECT"}, + {name: "unknown", key: "JT808:41456413939@117.132.196.192", kind: "UNKNOWN", wantError: true}, + {name: "missing", key: "JT808:41456413939@117.132.196.192", missing: true, wantError: true}, + {name: "platform-without-code", key: "JT808:41456413939@117.132.196.192", kind: "PLATFORM", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + rows := sqlmock.NewRows([]string{"code", "kind"}) + if !tc.missing { + rows.AddRow(tc.code, tc.kind) + } + mock.ExpectQuery("SELECT COALESCE").WithArgs("JT808", "117.132.196.192").WillReturnRows(rows) + got, err := canonicalRepairSource(context.Background(), db, "JT808", tc.key) + if (err != nil) != tc.wantError || got != tc.want { + t.Fatalf("got=%q err=%v want=%q wantError=%v", got, err, tc.want, tc.wantError) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestCanonicalRepairSourceKeepsIndependentCounters(t *testing.T) { + for _, key := range []string{"JT808:terminal-a@PLATFORM:g7s", "JT808:terminal-b@PLATFORM:g7s", "JT808:terminal-a@PLATFORM:dongfang_beidou"} { + got, err := canonicalRepairSource(context.Background(), nil, "JT808", key) + if err != nil || got != key { + t.Fatalf("independent identity changed: %s %v", got, err) + } + } + if _, err := canonicalRepairSource(context.Background(), nil, "GB32960", "GB32960:vin@PLATFORM:g7s"); err == nil { + t.Fatal("alias repair must not silently change other protocol rules") + } +} diff --git a/go/vehicle-gateway/cmd/mileage-eventtime-repair/main.go b/go/vehicle-gateway/cmd/mileage-eventtime-repair/main.go new file mode 100644 index 00000000..4ef7c36e --- /dev/null +++ b/go/vehicle-gateway/cmd/mileage-eventtime-repair/main.go @@ -0,0 +1,180 @@ +// Apply reviewed event-time boundary corrections with a verified backup. +package main + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "math" + "os" + "sort" + "strconv" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +type row struct { + VIN string `json:"vin"` + Date string `json:"stat_date"` + Protocol string `json:"protocol"` + Source string `json:"source_key"` + First string `json:"first_total_mileage_km"` + Last string `json:"latest_total_mileage_km"` + FirstTime string `json:"first_event_time"` + LastTime string `json:"latest_event_time"` + Daily string `json:"daily_mileage_km"` + Status string `json:"quality_status"` + Reason string `json:"quality_reason"` + Count int64 `json:"sample_count"` +} +type change struct { + Before row `json:"before"` + After row `json:"after"` +} + +func main() { + input := flag.String("input", "", "reviewed event boundary plan") + manifest := flag.String("backup-manifest", "", "verified backup manifest") + apply := flag.Bool("apply", false, "apply changes atomically") + flag.Parse() + data, err := os.ReadFile(*input) + must(err) + var plan []change + must(json.Unmarshal(data, &plan)) + loc := time.FixedZone("Asia/Shanghai", 8*3600) + for _, c := range plan { + must(validateChange(c, loc)) + } + if !*apply { + fmt.Printf("planned=%d dry_run=true\n", len(plan)) + return + } + var backup struct { + File string `json:"backup_file"` + SHA string `json:"backup_sha256"` + PlanSHA string `json:"plan_sha256"` + Verified bool `json:"gzip_and_json_verified"` + } + b, err := os.ReadFile(*manifest) + must(err) + must(json.Unmarshal(b, &backup)) + digest := sha256.Sum256(data) + if !backup.Verified || hex.EncodeToString(digest[:]) != backup.PlanSHA { + panic("reviewed plan is not the backed-up plan") + } + compressed, err := os.ReadFile(backup.File) + must(err) + digest = sha256.Sum256(compressed) + if hex.EncodeToString(digest[:]) != backup.SHA { + panic("backup checksum mismatch") + } + db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN")) + must(err) + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + tx, err := db.BeginTx(ctx, nil) + must(err) + defer tx.Rollback() + sort.Slice(plan, func(i, j int) bool { + a, b := plan[i].Before, plan[j].Before + return a.Date+a.VIN+a.Source < b.Date+b.VIN+b.Source + }) + targets := map[string]row{} + for _, c := range plan { + b, a := c.Before, c.After + res, err := tx.ExecContext(ctx, `UPDATE vehicle_daily_mileage_source SET first_total_mileage_km=?,first_event_time=?,latest_total_mileage_km=?,latest_event_time=?,daily_mileage_km=?,quality_status=?,quality_reason=? +WHERE vin=? AND stat_date=? AND protocol='JT808' AND source_key=? +AND first_total_mileage_km <=> ? AND first_event_time <=> ? AND latest_total_mileage_km=? AND latest_event_time=? +AND daily_mileage_km=? AND quality_status=?`, a.First, a.FirstTime, a.Last, a.LastTime, a.Daily, a.Status, a.Reason, + b.VIN, b.Date, b.Source, b.First, b.FirstTime, b.Last, b.LastTime, b.Daily, b.Status) + must(err) + n, err := res.RowsAffected() + must(err) + if n != 1 { + panic(fmt.Sprintf("concurrent change: %s %s %s", b.VIN, b.Date, b.Source)) + } + targets[b.Date+"|"+b.VIN] = b + } + keys := make([]string, 0, len(targets)) + for k := range targets { + keys = append(keys, k) + } + sort.Strings(keys) + projected, preserved := 0, 0 + for _, k := range keys { + b := targets[k] + rows, err := tx.QueryContext(ctx, `SELECT source_ip,COALESCE(quality_reason,'') FROM vehicle_daily_mileage_source WHERE vin=? AND stat_date=? AND protocol='JT808' AND is_selected=1 FOR UPDATE`, b.VIN, b.Date) + must(err) + manual := false + for rows.Next() { + var ip, reason string + must(rows.Scan(&ip, &reason)) + manual = manual || strings.HasPrefix(ip, "manual") || strings.HasPrefix(ip, "legacy-mysql") || strings.HasPrefix(reason, "manual") + } + must(rows.Err()) + must(rows.Close()) + if manual { + preserved++ + continue + } + must(stats.ProjectDailyMileage(ctx, tx, b.VIN, b.Date, envelope.ProtocolJT808)) + projected++ + } + must(tx.Commit()) + must(json.NewEncoder(os.Stdout).Encode(map[string]any{"committed": true, "updatedSources": len(plan), "reprojectedDays": projected, "preservedManualDays": preserved})) +} + +func validateChange(c change, loc *time.Location) error { + b, a := c.Before, c.After + if b.Protocol != "JT808" || b.VIN == "" || b.Source == "" { + return fmt.Errorf("invalid GPS identity") + } + first, err := strconv.ParseFloat(a.First, 64) + if err != nil { + return err + } + last, err := strconv.ParseFloat(a.Last, 64) + if err != nil { + return err + } + daily, err := strconv.ParseFloat(a.Daily, 64) + if err != nil { + return err + } + firstTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.FirstTime, loc) + if err != nil { + return err + } + lastTime, err := time.ParseInLocation("2006-01-02 15:04:05", a.LastTime, loc) + if err != nil { + return err + } + oldEnd, err := time.ParseInLocation("2006-01-02 15:04:05", b.LastTime, loc) + if err != nil { + return err + } + if lastTime.Before(oldEnd) || firstTime.After(lastTime) || lastTime.Format("2006-01-02") != b.Date || first <= 0 || last <= 0 { + return fmt.Errorf("invalid event-time boundary %s %s", b.VIN, b.Date) + } + sample := stats.SourceMileageSample{DailyKM: last - first, FirstEventTime: firstTime, LatestEventTime: lastTime, QualityStatus: stats.QualityOK, QualityReason: a.Reason} + stats.ApplyMileageQualityRules(&sample) + if math.IsNaN(daily) || math.IsInf(daily, 0) || math.Abs(sample.DailyKM-daily) > 0.0001 || sample.QualityStatus != a.Status { + return fmt.Errorf("incorrect reviewed mileage %s %s", b.VIN, b.Date) + } + return nil +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/go/vehicle-gateway/cmd/mileage-eventtime-repair/main_test.go b/go/vehicle-gateway/cmd/mileage-eventtime-repair/main_test.go new file mode 100644 index 00000000..11c8f3bd --- /dev/null +++ b/go/vehicle-gateway/cmd/mileage-eventtime-repair/main_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + "time" +) + +func TestReviewedEventTimeRepairRequiresConsistentForwardTime(t *testing.T) { + loc := time.FixedZone("Asia/Shanghai", 8*3600) + c := change{Before: row{VIN: "LGAX2BG42H1003112", Date: "2026-10-21", Protocol: "JT808", Source: "JT808:terminal@PLATFORM:forwarder", LastTime: "2026-10-21 08:59:34"}, + After: row{First: "44124.600", Last: "44402.100", FirstTime: "2026-10-20 23:29:53", LastTime: "2026-10-21 23:59:55", Daily: "277.500", Status: "OK", Reason: "historical_source_baseline"}} + if err := validateChange(c, loc); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*change){ + func(c *change) { c.After.LastTime = "2026-10-21 08:00:00" }, + func(c *change) { c.After.Daily = "353.800" }, + func(c *change) { c.Before.Protocol = "GB32960" }, + func(c *change) { c.After.FirstTime = "2026-10-22 00:00:00" }, + } { + bad := c + mutate(&bad) + if err := validateChange(bad, loc); err == nil { + t.Fatalf("unsafe reviewed plan accepted: %+v", bad) + } + } +} diff --git a/go/vehicle-gateway/cmd/stat-writer/main.go b/go/vehicle-gateway/cmd/stat-writer/main.go index 73f51e88..69a7f3f7 100644 --- a/go/vehicle-gateway/cmd/stat-writer/main.go +++ b/go/vehicle-gateway/cmd/stat-writer/main.go @@ -75,6 +75,28 @@ func main() { os.Exit(1) } } + if loaded, err := writer.ReloadSourceIdentities(ctx); err != nil { + logger.Error("source identity snapshot load failed", "error", err) + os.Exit(1) + } else { + registry.SetGauge("vehicle_stat_source_identity_snapshot_entries", nil, float64(loaded)) + } + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if loaded, err := writer.ReloadSourceIdentities(ctx); err != nil { + logger.Warn("source identity snapshot refresh failed; retaining previous snapshot", "error", err) + } else { + registry.SetGauge("vehicle_stat_source_identity_snapshot_entries", nil, float64(loaded)) + } + } + } + }() if cfg.HydrogenCapacitySyncEnabled { syncHydrogenCapacities(ctx, logger, registry, db, writer, cfg) } else if loaded, err := writer.ReloadHydrogenTankCapacities(ctx); err != nil { diff --git a/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen.go b/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen.go new file mode 100644 index 00000000..6fb224b1 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen.go @@ -0,0 +1,185 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "math" + "sort" + "strings" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +const customerHydrogenAlgorithm = "MODE_INTERVAL_V1" + +type customerHydrogenDay struct { + VIN, Date, Protocol, SourceKey string + HydrogenKM, ObservedKM, ClassifiedKM float64 + Samples, KnownSamples, Intervals, Rejected int64 + First, Last time.Time + PreviousKM float64 +} + +func addCustomerHydrogenSample(days map[string]*customerHydrogenDay, s stats.MetricSample) { + if s.Protocol != envelope.ProtocolGB32960 && s.Protocol != envelope.ProtocolYutongMQTT { + return + } + if math.IsNaN(s.TotalMileageKM) || math.IsInf(s.TotalMileageKM, 0) || s.TotalMileageKM < 0 { + return + } + key := s.VIN + "/" + s.StatDate + "/" + string(s.Protocol) + "/" + s.SourceKey + d := days[key] + if d == nil { + d = &customerHydrogenDay{VIN: s.VIN, Date: s.StatDate, Protocol: string(s.Protocol), SourceKey: s.SourceKey, First: s.EventTime, Last: s.EventTime, PreviousKM: s.TotalMileageKM, Samples: 1} + if s.PureHydrogenModeKnown { + d.KnownSamples = 1 + } + days[key] = d + return + } + // Do not count duplicate or out-of-order reports as extra evidence. + if !s.EventTime.After(d.Last) { + return + } + elapsed := s.EventTime.Sub(d.Last) + delta := s.TotalMileageKM - d.PreviousKM + d.Samples++ + if s.PureHydrogenModeKnown { + d.KnownSamples++ + } + if delta < -0.2 || delta > math.Max(0.5, elapsed.Hours()*130) || elapsed > 15*time.Minute { + d.Rejected++ + } else { + delta = math.Max(0, delta) + d.ObservedKM += delta + d.Intervals++ + if s.PureHydrogenModeKnown { + d.ClassifiedKM += delta + if s.PureHydrogenActive { + d.HydrogenKM += delta + } + } + } + d.Last = s.EventTime + d.PreviousKM = s.TotalMileageKM +} + +// This path reads raw work-mode evidence and only writes the dedicated customer +// monitor projection. It never resets/reprojects the existing GPS/daily tables. +func runCustomerHydrogenBackfill(ctx context.Context, mysqlDB, td *sql.DB, cfg config) error { + return runCustomerHydrogenBackfillWithLease(ctx, mysqlDB, td, cfg, "") +} + +func runCustomerHydrogenBackfillWithLease(ctx context.Context, mysqlDB, td *sql.DB, cfg config, owner string) error { + if cfg.Reset { + return fmt.Errorf("hydrogen-only backfill does not allow reset") + } + if cfg.Limit > 0 && !cfg.DryRun { + return fmt.Errorf("limited scan cannot publish a daily result") + } + for _, p := range cfg.Protocols { + if p != envelope.ProtocolGB32960 && p != envelope.ProtocolYutongMQTT { + return fmt.Errorf("hydrogen-only backfill accepts GB32960,YUTONG_MQTT only") + } + } + rows, err := queryRawFrames(ctx, td, cfg) + if err != nil { + return err + } + defer rows.Close() + days := map[string]*customerHydrogenDay{} + var scanned, skipped int64 + for rows.Next() { + scanned++ + row, err := scanRawFrame(rows) + if err != nil { + return err + } + text := strings.TrimSpace(row.ParsedJSON) + if isChunkManifest(text) { + text, err = readChunkedPayload(ctx, td, cfg.TDengineDatabase, row.EventID, "parsed_fields") + if err != nil { + return fmt.Errorf("read complete mode evidence: %w", err) + } + } + fields := fieldsForStats(row.Protocol, row.VIN, text) + if len(fields) == 0 && row.Protocol == envelope.ProtocolYutongMQTT { + fields = fieldsForStats(row.Protocol, row.VIN, row.RawText) + } + samples, err := stats.SamplesFromEnvelope(envelope.FrameEnvelope{EventID: row.EventID, Protocol: row.Protocol, MessageID: row.MessageID, VIN: row.VIN, Phone: row.Phone, DeviceID: row.DeviceID, SourceEndpoint: row.SourceEndpoint, EventTimeMS: row.EventTimeMS, ReceivedAtMS: row.ReceivedAtMS, Fields: fields, ParsedFields: fields, ParseStatus: envelope.ParseOK}, cfg.Location) + if err != nil { + return err + } + if len(samples) == 0 { + skipped++ + } + for _, s := range samples { + if s.StatDate >= cfg.DateFrom && s.StatDate <= cfg.DateTo { + addCustomerHydrogenSample(days, s) + } + } + if cfg.ProgressEvery > 0 && scanned%cfg.ProgressEvery == 0 { + slog.Info("customer hydrogen scan", "scanned", scanned, "source_days", len(days), "skipped", skipped) + } + } + if err = rows.Err(); err != nil { + return err + } + var known, samples int64 + var hydrogen float64 + for _, d := range days { + known += d.KnownSamples + samples += d.Samples + hydrogen += d.HydrogenKM + } + slog.Info("customer hydrogen evidence", "scanned", scanned, "samples", samples, "known_mode_samples", known, "source_days", len(days), "hydrogen_km_across_sources", hydrogen, "dry_run", cfg.DryRun) + if cfg.DryRun { + return nil + } + return publishCustomerHydrogenDays(ctx, mysqlDB, days, cfg.DateFrom, owner) +} + +func publishCustomerHydrogenDays(ctx context.Context, mysqlDB *sql.DB, days map[string]*customerHydrogenDay, date, owner string) error { + // Publish only after a complete successful scan. Upserts are deterministic; + // failures roll back the entire projection update. + tx, err := mysqlDB.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if owner != "" { + // Fence publication with the same row lock used to claim jobs. A stale + // worker must not overwrite results even before its next heartbeat. + var valid int + if err = tx.QueryRowContext(ctx, `SELECT 1 FROM platform_customer_hydrogen_job WHERE stat_date=? AND lease_owner=? AND status='running' AND lease_until>NOW(3) FOR UPDATE`, date, owner).Scan(&valid); err != nil { + return fmt.Errorf("customer hydrogen publication lease lost: %w", err) + } + } + keys := make([]string, 0, len(days)) + for k := range days { + keys = append(keys, k) + } + sort.Strings(keys) + for start := 0; start < len(keys); start += 200 { + end := start + 200 + if end > len(keys) { + end = len(keys) + } + values := []string{} + args := []any{} + for _, key := range keys[start:end] { + d := days[key] + values = append(values, "(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NOW(3))") + args = append(args, d.VIN, d.Date, d.Protocol, d.SourceKey, d.HydrogenKM, d.ObservedKM, d.ClassifiedKM, d.Samples, d.KnownSamples, d.Intervals, d.Rejected, d.First, d.Last, customerHydrogenAlgorithm) + } + _, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_hydrogen_daily(vin,stat_date,protocol,source_key,hydrogen_km,observed_km,classified_km,sample_count,known_sample_count,interval_count,rejected_intervals,first_event_time,last_event_time,algorithm_version,calculated_at) VALUES `+strings.Join(values, ",")+` ON DUPLICATE KEY UPDATE hydrogen_km=VALUES(hydrogen_km),observed_km=VALUES(observed_km),classified_km=VALUES(classified_km),sample_count=VALUES(sample_count),known_sample_count=VALUES(known_sample_count),interval_count=VALUES(interval_count),rejected_intervals=VALUES(rejected_intervals),first_event_time=VALUES(first_event_time),last_event_time=VALUES(last_event_time),algorithm_version=VALUES(algorithm_version),calculated_at=VALUES(calculated_at)`, args...) + if err != nil { + return err + } + } + return tx.Commit() +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen_test.go b/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen_test.go new file mode 100644 index 00000000..9322bf95 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" + "testing" + "time" +) + +func TestCustomerHydrogenIntervalsClassifyElectricZeroAndUnknown(t *testing.T) { + start := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC) + days := map[string]*customerHydrogenDay{} + sample := func(min int, km float64, known, active bool) stats.MetricSample { + return stats.MetricSample{VIN: "VIN", StatDate: "2026-09-29", Protocol: envelope.ProtocolGB32960, SourceKey: "source", EventTime: start.Add(time.Duration(min) * time.Minute), TotalMileageKM: km, PureHydrogenModeKnown: known, PureHydrogenActive: active} + } + for _, s := range []stats.MetricSample{sample(0, 100, true, false), sample(5, 105, true, false), sample(10, 110, true, true), sample(15, 115, false, false), sample(15, 115, true, true), sample(60, 130, true, true), sample(65, 140, true, true)} { + addCustomerHydrogenSample(days, s) + } + for _, d := range days { + if d.HydrogenKM != 15 || d.ObservedKM != 25 || d.ClassifiedKM != 20 || d.Samples != 6 || d.KnownSamples != 5 || d.Rejected != 1 { + t.Fatalf("incorrect evidence %+v", d) + } + } + zero := map[string]*customerHydrogenDay{} + addCustomerHydrogenSample(zero, sample(0, 100, true, false)) + addCustomerHydrogenSample(zero, sample(5, 105, true, false)) + for _, d := range zero { + if d.HydrogenKM != 0 || d.ClassifiedKM != 5 || d.KnownSamples != 2 { + t.Fatal("observed electric-only day not distinguished from missing") + } + } +} +func TestCustomerHydrogenSeparatesSourcesDaysAndRejectsJumps(t *testing.T) { + days := map[string]*customerHydrogenDay{} + s := stats.MetricSample{VIN: "VIN", StatDate: "2026-09-29", Protocol: envelope.ProtocolGB32960, SourceKey: "A", EventTime: time.Now(), TotalMileageKM: 100, PureHydrogenModeKnown: true, PureHydrogenActive: true} + addCustomerHydrogenSample(days, s) + s.EventTime = s.EventTime.Add(time.Minute) + s.TotalMileageKM = 1100 + addCustomerHydrogenSample(days, s) + s.SourceKey = "B" + s.TotalMileageKM = 100 + addCustomerHydrogenSample(days, s) + s.StatDate = "2026-09-30" + addCustomerHydrogenSample(days, s) + if len(days) != 3 { + t.Fatal("source/day evidence mixed") + } + for _, d := range days { + if d.HydrogenKM != 0 { + t.Fatal("odometer jump counted as hydrogen") + } + } +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen_worker.go b/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen_worker.go new file mode 100644 index 00000000..d03e37fc --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/customer_hydrogen_worker.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "log/slog" + "os" + "os/signal" + "syscall" + "time" +) + +func runCustomerHydrogenWorker(parent context.Context, mysqlDB, td *sql.DB, cfg config) error { + ctx, stop := signal.NotifyContext(parent, os.Interrupt, syscall.SIGTERM) + defer stop() + maxJobs := envInt("CUSTOMER_HYDROGEN_MAX_JOBS", 0) + completedJobs := 0 + for ctx.Err() == nil { + date, owner, err := claimCustomerHydrogenDay(ctx, mysqlDB) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + slog.Error("customer hydrogen claim failed", "error", err) + } + if err != nil { + select { + case <-ctx.Done(): + return nil + case <-time.After(5 * time.Second): + continue + } + } + dayCtx, cancel := context.WithTimeout(ctx, 2*time.Hour) + heartbeatDone := make(chan struct{}) + go func() { + defer close(heartbeatDone) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-dayCtx.Done(): + return + case <-ticker.C: + result, e := mysqlDB.ExecContext(dayCtx, `UPDATE platform_customer_hydrogen_job SET lease_until=DATE_ADD(NOW(3),INTERVAL 5 MINUTE) WHERE stat_date=? AND lease_owner=? AND status='running'`, date, owner) + if e != nil { + cancel() + return + } + n, e := result.RowsAffected() + if e != nil || n != 1 { + cancel() + return + } + } + } + }() + task := cfg + task.DateFrom = date + task.DateTo = date + task.Limit = 0 + task.DryRun = false + task.Reset = false + task.VINs = nil + slog.Info("customer hydrogen job started", "date", date) + err = runCustomerHydrogenBackfillWithLease(dayCtx, mysqlDB, td, task, owner) + cancel() + <-heartbeatDone + status, message := "complete", "" + if err != nil { + status, message = "failed", "原始报文计算失败,请检查任务日志" + slog.Error("customer hydrogen job failed", "date", date, "error", err) + } + // A cancelled/expired worker cannot acknowledge another worker's lease. + finishCtx, finishCancel := context.WithTimeout(context.Background(), 5*time.Second) + _, saveErr := mysqlDB.ExecContext(finishCtx, `UPDATE platform_customer_hydrogen_job SET status=?,error_message=?,completed_at=IF(?='complete',NOW(3),completed_at),lease_until=NULL,lease_owner='' WHERE stat_date=? AND lease_owner=?`, status, message, status, date, owner) + finishCancel() + if saveErr != nil { + slog.Error("customer hydrogen job completion failed", "date", date, "error", saveErr) + } + completedJobs++ + if maxJobs > 0 && completedJobs >= maxJobs { + return nil + } + } + return nil +} + +func claimCustomerHydrogenDay(ctx context.Context, db *sql.DB) (string, string, error) { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return "", "", err + } + defer tx.Rollback() + var date string + err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM platform_customer_hydrogen_job WHERE status='pending' OR (status='running' AND lease_untilNOW.*FOR UPDATE").WithArgs("2026-09-29", "old-owner").WillReturnRows(sqlmock.NewRows([]string{"valid"})) + mock.ExpectRollback() + days := map[string]*customerHydrogenDay{"sample": {VIN: "VIN", Date: "2026-09-29", HydrogenKM: 10}} + if err = publishCustomerHydrogenDays(context.Background(), db, days, "2026-09-29", "old-owner"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("stale worker must fail before writing: %v", err) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestCustomerHydrogenPublicationLocksCurrentLease(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectBegin() + mock.ExpectQuery("SELECT 1.*lease_until>NOW.*FOR UPDATE").WithArgs("2026-09-29", "current-owner").WillReturnRows(sqlmock.NewRows([]string{"valid"}).AddRow(1)) + mock.ExpectExec("INSERT INTO platform_customer_hydrogen_daily").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + days := map[string]*customerHydrogenDay{"sample": {VIN: "VIN", Date: "2026-09-29", HydrogenKM: 10}} + if err = publishCustomerHydrogenDays(context.Background(), db, days, "2026-09-29", "current-owner"); err != nil { + t.Fatal(err) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} +func TestCustomerHydrogenIdleWorkerDoesNotCreateJobs(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectBegin() + mock.ExpectQuery("SELECT DATE_FORMAT.*FOR UPDATE").WillReturnRows(sqlmock.NewRows([]string{"date"})) + mock.ExpectRollback() + if _, _, err = claimCustomerHydrogenDay(context.Background(), db); !errors.Is(err, sql.ErrNoRows) { + t.Fatal(err) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/event_time_boundary.go b/go/vehicle-gateway/cmd/stats-backfill/event_time_boundary.go new file mode 100644 index 00000000..3ae8624b --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/event_time_boundary.go @@ -0,0 +1,88 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +var errNoUsableSourceBoundary = errors.New("source has no usable mileage boundary") + +// FIRST/LAST are ordered by TDengine's storage timestamp, which represents +// receipt order. Keep the fast aggregate path, but independently verify both +// ends against extrema of the vehicle event timestamp and fetch the matching +// complete row whenever late delivery has moved either end. +type sourceBoundaryEvidence struct { + vin, phone, deviceID, endpoint string + firstTS, lastTS time.Time + firstParsed, firstRaw string + lastParsed, lastRaw string + count, minimumMS, maximumMS int64 +} + +func correctSourceEventBoundaries(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, where string, includeFirst bool, e *sourceBoundaryEvidence) error { + _, firstUsable := mileageFromEvidence(protocol, e.vin, e.firstParsed, e.firstRaw, e.firstTS, cfg.Location) + _, lastUsable := mileageFromEvidence(protocol, e.vin, e.lastParsed, e.lastRaw, e.lastTS, cfg.Location) + if e.lastTS.UnixMilli() != e.maximumMS || !lastUsable { + stamp, parsed, raw, err := querySourceEventBoundary(ctx, db, cfg, protocol, where, *e, e.maximumMS, false) + if err != nil { + if errors.Is(err, sql.ErrNoRows) && !firstUsable && !lastUsable { + return errNoUsableSourceBoundary + } + return err + } + slog.Info("corrected last source event boundary", "vin", e.vin, "endpoint", e.endpoint, "storage_last", e.lastTS, "event_last", stamp) + e.lastTS, e.lastParsed, e.lastRaw = stamp, parsed, raw + } + if !includeFirst { + e.firstTS, e.firstParsed, e.firstRaw = e.lastTS, e.lastParsed, e.lastRaw + return nil + } + if e.firstTS.UnixMilli() != e.minimumMS || !firstUsable { + stamp, parsed, raw, err := querySourceEventBoundary(ctx, db, cfg, protocol, where, *e, e.minimumMS, true) + if err != nil { + return err + } + slog.Info("corrected first source event boundary", "vin", e.vin, "endpoint", e.endpoint, "storage_first", e.firstTS, "event_first", stamp) + e.firstTS, e.firstParsed, e.firstRaw = stamp, parsed, raw + } + return nil +} + +func querySourceEventBoundary(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, where string, e sourceBoundaryEvidence, eventMS int64, first bool) (time.Time, string, string, error) { + // An equal event timestamp is resolved consistently by newest storage ts. + // Never obtain the timestamp and payload through independent aggregates. + comparison, order := "<=", "DESC" + if first { + comparison, order = ">=", "ASC" + } + query := fmt.Sprintf(`SELECT event_time, parsed_json, raw_text +FROM %s.raw_frames +WHERE %s AND vin = '%s' AND phone = '%s' AND device_id = '%s' + AND source_endpoint = '%s' AND CAST(event_time AS BIGINT) %s %d +ORDER BY event_time %s, ts DESC`, ident(cfg.TDengineDatabase), where, quote(e.vin), quote(e.phone), quote(e.deviceID), quote(e.endpoint), comparison, eventMS, order) + rows, err := db.QueryContext(ctx, query) + if err != nil { + return time.Time{}, "", "", fmt.Errorf("resolve event-time boundary %s/%s/%d: %w", e.vin, e.endpoint, eventMS, err) + } + defer rows.Close() + for rows.Next() { + var stamp time.Time + var parsed, raw string + if err := rows.Scan(&stamp, &parsed, &raw); err != nil { + return time.Time{}, "", "", err + } + if _, ok := mileageFromEvidence(protocol, e.vin, parsed, raw, stamp, cfg.Location); ok { + return stamp, parsed, raw, nil + } + } + if err := rows.Err(); err != nil { + return time.Time{}, "", "", err + } + return time.Time{}, "", "", sql.ErrNoRows +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/event_time_boundary_test.go b/go/vehicle-gateway/cmd/stats-backfill/event_time_boundary_test.go new file mode 100644 index 00000000..a4710eaf --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/event_time_boundary_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "fmt" + "math" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +func TestDailySourceEndUsesVehicleEventTimeAfterLateDelivery(t *testing.T) { + for _, tc := range []struct { + vin, phone, ip, code string + lateHour, lateMin int + endHour, endMin int + lateKM, endKM float64 + }{ + {"LGAX2BG42H1003112", "40409727033", "122.152.221.156", "dongfang_beidou", 8, 59, 23, 59, 44478.4, 44402.1}, + {"LA9HE60A0PBAF4002", "13307765803", "115.231.168.135", "xinda", 20, 46, 21, 14, 6194.8, 6195.1}, + } { + t.Run(tc.vin, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.SetMaxOpenConns(1) + loc := time.FixedZone("Asia/Shanghai", 8*3600) + first := time.Date(2026, 10, 21, 0, 0, 0, 0, loc) + late := time.Date(2026, 10, 21, tc.lateHour, tc.lateMin, 0, 0, loc) + last := time.Date(2026, 10, 21, tc.endHour, tc.endMin, 0, 0, loc) + parsed := func(km float64) string { return fmt.Sprintf(`{"jt808.location.total_mileage_km":%f}`, km) } + mock.ExpectQuery("(?s)SELECT vin.*MIN\\(CAST\\(event_time AS BIGINT\\)\\).*MAX\\(CAST\\(event_time AS BIGINT\\)\\)"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "minimum", "maximum"}). + AddRow(tc.vin, tc.phone, "", tc.ip+":1234", first, parsed(tc.endKM-100), "", late, parsed(tc.lateKM), "", 100, first.UnixMilli(), last.UnixMilli())).RowsWillBeClosed() + mock.ExpectQuery(fmt.Sprintf("(?s)SELECT event_time, parsed_json, raw_text.*phone = '%s'.*CAST\\(event_time AS BIGINT\\) <= %d.*ORDER BY event_time DESC, ts DESC", tc.phone, last.UnixMilli())). + WillReturnRows(sqlmock.NewRows([]string{"event_time", "parsed_json", "raw_text"}).AddRow(last, parsed(tc.endKM), "")) + cfg := config{TDengineDatabase: "lingniu_vehicle_ts", Location: loc} + result, err := queryDailyLastSourceRows(context.Background(), db, cfg, envelope.ProtocolJT808, "2026-10-21") + if err != nil { + t.Fatal(err) + } + got := result[tc.vin][0] + if !got.TS.Equal(last) || math.Abs(got.TotalKM-tc.endKM) > 0.0001 || got.RawSampleCount != 100 { + t.Fatalf("late delivery replaced the vehicle day end: %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestDailySourceFirstAlsoUsesVehicleEventTime(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + loc := time.FixedZone("Asia/Shanghai", 8*3600) + first := time.Date(2026, 10, 21, 8, 0, 0, 0, loc) + storageFirst := first.Add(time.Hour) + last := first.Add(10 * time.Hour) + mock.ExpectQuery("(?s)SELECT vin.*MIN\\(CAST").WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "minimum", "maximum"}). + AddRow("VIN", "phone", "", "1.2.3.4:123", storageFirst, `{"jt808.location.total_mileage_km":101}`, "", last, `{"jt808.location.total_mileage_km":110}`, "", 5, first.UnixMilli(), last.UnixMilli())) + mock.ExpectQuery(fmt.Sprintf("(?s)SELECT event_time.*CAST\\(event_time AS BIGINT\\) >= %d", first.UnixMilli())).WillReturnRows(sqlmock.NewRows([]string{"event", "parsed", "raw"}).AddRow(first, `{"jt808.location.total_mileage_km":100}`, "")) + got, err := queryDailyLastSourceRows(context.Background(), db, config{TDengineDatabase: "db", Location: loc}, envelope.ProtocolJT808, "2026-10-21") + if err != nil { + t.Fatal(err) + } + if got["VIN"][0].FirstTotalKM != 100 || !got["VIN"][0].FirstTS.Equal(first) { + t.Fatalf("wrong first event: %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMissingVehicleEventBoundaryFailsBeforeWriting(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + stamp := time.Date(2026, 10, 20, 20, 0, 0, 0, time.UTC) + mock.ExpectQuery("(?s)SELECT vin.*MAX\\(CAST").WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "last", "parsed", "raw", "count", "maximum"}).AddRow("VIN", "phone", "", "1.2.3.4:123", stamp, `{"jt808.location.total_mileage_km":100}`, "", 2, stamp.Add(time.Hour).UnixMilli())) + mock.ExpectQuery("SELECT event_time").WillReturnRows(sqlmock.NewRows([]string{"event", "parsed", "raw"})) + if _, err := queryPreviousLastSourceRows(context.Background(), db, config{TDengineDatabase: "db", Location: time.UTC}, envelope.ProtocolJT808, "2026-10-21"); err == nil { + t.Fatal("missing event boundary silently accepted") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestZeroMileageTailDoesNotHideLastUsableVehicleEvent(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + loc := time.FixedZone("Asia/Shanghai", 8*3600) + first := time.Date(2026, 10, 21, 8, 0, 0, 0, loc) + last := first.Add(time.Hour) + zero := last.Add(time.Hour) + mock.ExpectQuery("(?s)SELECT vin.*MIN\\(CAST").WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "minimum", "maximum"}).AddRow("VIN", "phone", "", "1.2.3.4:123", first, `{"jt808.location.total_mileage_km":100}`, "", zero, `{"jt808.location.total_mileage_km":0}`, "", 3, first.UnixMilli(), zero.UnixMilli())) + mock.ExpectQuery("(?s)SELECT event_time.*ORDER BY event_time DESC, ts DESC").WillReturnRows(sqlmock.NewRows([]string{"event", "parsed", "raw"}).AddRow(zero, `{"jt808.location.total_mileage_km":0}`, "").AddRow(last, `{"jt808.location.total_mileage_km":110}`, "")) + got, err := queryDailyLastSourceRows(context.Background(), db, config{TDengineDatabase: "db", Location: loc}, envelope.ProtocolJT808, "2026-10-21") + if err != nil { + t.Fatal(err) + } + if got["VIN"][0].TotalKM != 110 || !got["VIN"][0].TS.Equal(last) { + t.Fatalf("non-positive frame replaced usable end: %+v", got) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/main.go b/go/vehicle-gateway/cmd/stats-backfill/main.go index 6efc3e8d..1f028c8e 100644 --- a/go/vehicle-gateway/cmd/stats-backfill/main.go +++ b/go/vehicle-gateway/cmd/stats-backfill/main.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log/slog" "os" @@ -43,6 +44,7 @@ type config struct { ListHydrogenVINs bool ListActiveVINs bool Location *time.Location + SourceCatalog map[backfillSourceID]stats.SourceIdentity } type rawFrameRow struct { @@ -150,9 +152,27 @@ func main() { return } - schemaWriter := stats.NewWriter(mysqlDB, cfg.Location) - if err := schemaWriter.EnsureSchema(ctx); err != nil { - fail("ensure schema", err) + if envBool("CUSTOMER_HYDROGEN_WORKER", false) { + if err := runCustomerHydrogenWorker(ctx, mysqlDB, td, cfg); err != nil { + fail("customer hydrogen worker", err) + } + return + } + if envBool("BACKFILL_HYDROGEN_ONLY", false) { + if err := runCustomerHydrogenBackfill(ctx, mysqlDB, td, cfg); err != nil { + fail("customer hydrogen backfill", err) + } + return + } + cfg.SourceCatalog, err = loadBackfillSourceCatalog(ctx, mysqlDB) + if err != nil { + fail("load backfill source catalog", err) + } + if !cfg.DryRun { + schemaWriter := stats.NewWriter(mysqlDB, cfg.Location) + if err := schemaWriter.EnsureSchema(ctx); err != nil { + fail("ensure schema", err) + } } if cfg.Reset && !cfg.DryRun { deleted, err := resetStats(ctx, mysqlDB, cfg) @@ -178,6 +198,9 @@ func main() { if err != nil { fail("cleanup sub-threshold GPS mileage candidates", err) } + if cfg.Debug { + logBackfillCandidates(aggregates) + } var written int64 var normalized int if !cfg.DryRun { @@ -250,6 +273,7 @@ func main() { ParsedFields: fields, ParseStatus: envelope.ParseOK, } + env.SourceCode, env.PlatformName, env.SourceKind = backfillSourceMetadata(cfg, row.Protocol, row.SourceEndpoint) samples, err := stats.SamplesFromEnvelope(env, cfg.Location) if err != nil { slog.Warn("skip invalid sample", "event_id", row.EventID, "error", err) @@ -347,6 +371,23 @@ func activeModeSampleCount(sample stats.MetricSample) int64 { } func writeAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) { + gps, other := map[string]*metricAgg{}, map[string]*metricAgg{} + for key, agg := range aggregates { + if agg.Protocol == envelope.ProtocolJT808 { + gps[key] = agg + } else { + other[key] = agg + } + } + written, err := writeProtectedGPSAggregates(ctx, db, gps) + if err != nil { + return written, err + } + n, err := writeLegacyAggregates(ctx, db, other, batchSize) + return written + n, err +} + +func writeLegacyAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg, batchSize int) (int64, error) { if len(aggregates) == 0 { return 0, nil } @@ -429,11 +470,25 @@ func normalizeHistoricalPlatformSources(ctx context.Context, db *sql.DB, cfg con continue } for _, date := range dates { - count, err := stats.NormalizePlatformSourceMileageForDate(ctx, db, date, protocol) + if len(cfg.VINs) > 0 { + for _, vin := range cfg.VINs { + if err := normalizeProtectedGPSDay(ctx, db, vin, date); err != nil { + return normalized, err + } + normalized++ + } + continue + } + vins, err := stats.PlatformSourceMileageLegacyVINs(ctx, db, date, protocol) if err != nil { return normalized, err } - normalized += count + for _, vin := range vins { + if err := normalizeProtectedGPSDay(ctx, db, vin, date); err != nil { + return normalized, err + } + normalized++ + } } } return normalized, nil @@ -592,7 +647,8 @@ FROM vehicle_daily_mileage_source WHERE stat_date >= ? AND stat_date <= ? AND quality_reason = ? AND daily_mileage_km >= 0 AND daily_mileage_km < ? - AND protocol IN (` + strings.Join(placeholders, ",") + `) + AND protocol IN (` + strings.Join(placeholders, ",") + `)` + query = appendBackfillVINScope(query, cfg, "vin") + ` GROUP BY vin, stat_date, protocol ORDER BY stat_date, protocol, vin` rows, err := db.QueryContext(ctx, query, args...) @@ -697,6 +753,9 @@ func addProtocolGPSCoordinateFallbackAggregates(ctx context.Context, mysqlDB *sq "latitude BETWEEN -90 AND 90", "NOT (longitude = 0 AND latitude = 0)", } + if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" { + where = append(where, predicate) + } if protocol != envelope.ProtocolJT808 { where = append(where, "speed_kmh > 0") } @@ -1060,13 +1119,17 @@ func queryDailyLastSourceRows(ctx context.Context, db *sql.DB, cfg config, proto if predicate := mileageBearingFramePredicate(protocol); predicate != "" { where = append(where, predicate) } + if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" { + where = append(where, predicate) + } sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, FIRST(event_time), FIRST(parsed_json), FIRST(raw_text), - LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*) + LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*), + MIN(CAST(event_time AS BIGINT)), MAX(CAST(event_time AS BIGINT)) FROM %s.raw_frames WHERE %s GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) - return querySourceRows(ctx, db, cfg, protocol, sqlText, true) + return querySourceRows(ctx, db, cfg, protocol, sqlText, true, strings.Join(where, " AND ")) } func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, date string) (map[string][]dailySourceLast, error) { @@ -1085,12 +1148,15 @@ func queryPreviousLastSourceRows(ctx context.Context, db *sql.DB, cfg config, pr if predicate := mileageBearingFramePredicate(protocol); predicate != "" { where = append(where, predicate) } + if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" { + where = append(where, predicate) + } sqlText := fmt.Sprintf(`SELECT vin, phone, device_id, source_endpoint, - LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*) + LAST(event_time), LAST(parsed_json), LAST(raw_text), COUNT(*), MAX(CAST(event_time AS BIGINT)) FROM %s.raw_frames WHERE %s GROUP BY vin, phone, device_id, source_endpoint`, ident(cfg.TDengineDatabase), strings.Join(where, " AND ")) - return querySourceRows(ctx, db, cfg, protocol, sqlText, false) + return querySourceRows(ctx, db, cfg, protocol, sqlText, false, strings.Join(where, " AND ")) } func backfillBeforePredicates(cfg config, eventDateExclusive string) []string { @@ -1141,6 +1207,7 @@ WHERE l.protocol = ? if cfg.StationaryCarry { args = append(args, date) } + sqlText = appendBackfillVINScope(sqlText, cfg, "l.vin") rows, err := db.QueryContext(ctx, sqlText, args...) if err != nil { return nil, err @@ -1167,7 +1234,7 @@ WHERE l.protocol = ? if protocol == envelope.ProtocolYutongMQTT { deviceID = vin } - sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint) + sourceCode, platformName, sourceKind := backfillSourceMetadata(cfg, protocol, sourceEndpoint) sourceKey := normalizedSourceKeyForSource(string(protocol), "", deviceID, sourceEndpoint, sourceKind, sourceCode) if sourceKey == "" { continue @@ -1194,37 +1261,46 @@ WHERE l.protocol = ? return out, nil } -func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool) (map[string][]dailySourceLast, error) { +func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envelope.Protocol, sqlText string, includeFirst bool, boundaryWhere string) (map[string][]dailySourceLast, error) { rows, err := db.QueryContext(ctx, sqlText) if err != nil { return nil, err } defer rows.Close() - latestBySource := map[string]dailySourceLast{} + var evidence []sourceBoundaryEvidence for rows.Next() { - var vin string - var phone string - var deviceID string - var sourceEndpoint string - var firstTS time.Time - var firstParsedJSON string - var firstRawText string - var ts time.Time - var parsedJSON string - var rawText string - var rawSampleCount int64 + var e sourceBoundaryEvidence if includeFirst { - if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &firstTS, &firstParsedJSON, &firstRawText, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil { + if err := rows.Scan(&e.vin, &e.phone, &e.deviceID, &e.endpoint, &e.firstTS, &e.firstParsed, &e.firstRaw, &e.lastTS, &e.lastParsed, &e.lastRaw, &e.count, &e.minimumMS, &e.maximumMS); err != nil { return nil, err } } else { - if err := rows.Scan(&vin, &phone, &deviceID, &sourceEndpoint, &ts, &parsedJSON, &rawText, &rawSampleCount); err != nil { + if err := rows.Scan(&e.vin, &e.phone, &e.deviceID, &e.endpoint, &e.lastTS, &e.lastParsed, &e.lastRaw, &e.count, &e.maximumMS); err != nil { return nil, err } - firstTS = ts - firstParsedJSON = parsedJSON - firstRawText = rawText + e.minimumMS = e.maximumMS } + evidence = append(evidence, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + // Release the aggregate cursor before resolving exceptional event-time + // boundaries; this also works with a single TDengine connection. + if err := rows.Close(); err != nil { + return nil, err + } + latestBySource := map[string]dailySourceLast{} + for _, e := range evidence { + if err := correctSourceEventBoundaries(ctx, db, cfg, protocol, boundaryWhere, includeFirst, &e); err != nil { + if errors.Is(err, errNoUsableSourceBoundary) { + continue + } + return nil, err + } + vin, phone, deviceID, sourceEndpoint := e.vin, e.phone, e.deviceID, e.endpoint + firstTS, firstParsedJSON, firstRawText := e.firstTS, e.firstParsed, e.firstRaw + ts, parsedJSON, rawText, rawSampleCount := e.lastTS, e.lastParsed, e.lastRaw, e.count latestTotalKM, ok := mileageFromEvidence(protocol, vin, parsedJSON, rawText, ts, cfg.Location) if !ok { continue @@ -1235,7 +1311,7 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel firstTotalKM = parsedFirst } } - sourceCode, platformName, sourceKind := knownPlatformSourceMetadata(protocol, sourceEndpoint) + sourceCode, platformName, sourceKind := backfillSourceMetadata(cfg, protocol, sourceEndpoint) sourceKey := normalizedSourceKeyForSource(string(protocol), phone, deviceID, sourceEndpoint, sourceKind, sourceCode) if sourceKey == "" { continue @@ -1256,9 +1332,10 @@ func querySourceRows(ctx context.Context, db *sql.DB, cfg config, protocol envel RawSampleCount: rawSampleCount, } key := row.VIN + "|" + row.SourceKey - if existing, ok := latestBySource[key]; !ok || row.TS.After(existing.TS) { - latestBySource[key] = row + if existing, ok := latestBySource[key]; ok { + row = mergeDailySourceRows(existing, row) } + latestBySource[key] = row } if err := rows.Err(); err != nil { return nil, err @@ -1731,6 +1808,9 @@ func resetStats(ctx context.Context, db *sql.DB, cfg config) (int64, error) { } clauses = append(clauses, "protocol IN ("+strings.Join(placeholders, ",")+")") } + if predicate := backfillVINPredicate(cfg, "vin"); predicate != "" { + clauses = append(clauses, predicate) + } var deleted int64 for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage"} { result, err := db.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+strings.Join(clauses, " AND "), args...) diff --git a/go/vehicle-gateway/cmd/stats-backfill/main_test.go b/go/vehicle-gateway/cmd/stats-backfill/main_test.go index cd7e4504..59de6fe1 100644 --- a/go/vehicle-gateway/cmd/stats-backfill/main_test.go +++ b/go/vehicle-gateway/cmd/stats-backfill/main_test.go @@ -302,23 +302,21 @@ func TestBuildLastDiffAggregatesRetainsBoundaryAfterEmptyDays(t *testing.T) { dayThreeTS := time.Date(2026, 7, 11, 17, 30, 0, 0, loc) currentRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", - }) + "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MIN_EVENT_MS", "MAX_EVENT_MS"}) } previousRows := func() *sqlmock.Rows { return sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", - }) + "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MAX_EVENT_MS"}) } mock.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(previousRows()) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-09 00:00:00'.*protocol = 'YUTONG_MQTT'"). - WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4))) + WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", dayOneTS, `{"data":{"TOTAL_MILEAGE":100000}}`, "", int64(4), dayOneTS.UnixMilli(), dayOneTS.UnixMilli())) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-10 00:00:00'.*protocol = 'YUTONG_MQTT'"). WillReturnRows(currentRows()) mock.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-07-11 00:00:00'.*protocol = 'YUTONG_MQTT'"). - WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6))) + WillReturnRows(currentRows().AddRow(vin, "", vin, endpoint, dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", dayThreeTS, `{"data":{"TOTAL_MILEAGE":120000}}`, "", int64(6), dayThreeTS.UnixMilli(), dayThreeTS.UnixMilli())) aggregates, err := buildLastDiffAggregates(context.Background(), nil, tdDB, config{ TDengineDatabase: "lingniu_vehicle_ts", @@ -632,20 +630,7 @@ func TestQueryDailyLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { lastTS := time.Date(2026, 7, 8, 18, 0, 0, 0, loc) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", - }).AddRow( - "LMRKH9AC6R1004108", - "", - "LMRKH9AC6R1004108", - "mqtt://yutong/ytforward/shln/3", - firstTS, - `{"yutong_mqtt.data.latitude":"30.0"}`, - `{"data":{"TOTAL_MILEAGE":65422000}}`, - lastTS, - `{"yutong_mqtt.data.latitude":"30.1"}`, - `{"data":{"TOTAL_MILEAGE":65423000}}`, - int64(42), - )) + "vin", "phone", "device_id", "source_endpoint", "FIRST(event_time)", "FIRST(parsed_json)", "FIRST(raw_text)", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MIN_EVENT_MS", "MAX_EVENT_MS"}).AddRow("LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", firstTS, `{"yutong_mqtt.data.latitude":"30.0"}`, `{"data":{"TOTAL_MILEAGE":65422000}}`, lastTS, `{"yutong_mqtt.data.latitude":"30.1"}`, `{"data":{"TOTAL_MILEAGE":65423000}}`, int64(42), firstTS.UnixMilli(), lastTS.UnixMilli())) rows, err := queryDailyLastSourceRows(context.Background(), db, config{ TDengineDatabase: "lingniu_vehicle_ts", @@ -677,17 +662,7 @@ func TestQueryPreviousLastSourceRowsFiltersYutongToMileageFrames(t *testing.T) { lastTS := time.Date(2026, 7, 7, 23, 58, 0, 0, loc) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*event_time < '2026-07-08 00:00:00'.*protocol = 'YUTONG_MQTT'.*parsed_json LIKE '%yutong_mqtt\\.data\\.total_mileage%'.*parsed_json LIKE '%TOTAL_MILEAGE%'"). WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", - }).AddRow( - "LMRKH9AC6R1004108", - "", - "LMRKH9AC6R1004108", - "mqtt://yutong/ytforward/shln/3", - lastTS, - `{"yutong_mqtt.data.latitude":"30.0"}`, - `{"data":{"TOTAL_MILEAGE":65377000}}`, - int64(31), - )) + "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MAX_EVENT_MS"}).AddRow("LMRKH9AC6R1004108", "", "LMRKH9AC6R1004108", "mqtt://yutong/ytforward/shln/3", lastTS, `{"yutong_mqtt.data.latitude":"30.0"}`, `{"data":{"TOTAL_MILEAGE":65377000}}`, int64(31), lastTS.UnixMilli())) rows, err := queryPreviousLastSourceRows(context.Background(), db, config{ TDengineDatabase: "lingniu_vehicle_ts", @@ -735,17 +710,7 @@ func TestQueryPreviousLastSourceRowsNormalizesScannedTimestampToBusinessTimezone utcInstant := time.Date(2026, 7, 12, 15, 59, 59, 0, time.UTC) mock.ExpectQuery("(?s)FROM lingniu_vehicle_ts\\.raw_frames.*protocol = 'YUTONG_MQTT'"). WillReturnRows(sqlmock.NewRows([]string{ - "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", - }).AddRow( - "LMRKH9AC7R1004098", - "", - "LMRKH9AC7R1004098", - "mqtt://yutong/ytforward/shln/4", - utcInstant, - `{"yutong_mqtt.data.total_mileage":"41249000"}`, - "", - int64(1), - )) + "vin", "phone", "device_id", "source_endpoint", "LAST(event_time)", "LAST(parsed_json)", "LAST(raw_text)", "COUNT(*)", "MAX_EVENT_MS"}).AddRow("LMRKH9AC7R1004098", "", "LMRKH9AC7R1004098", "mqtt://yutong/ytforward/shln/4", utcInstant, `{"yutong_mqtt.data.total_mileage":"41249000"}`, "", int64(1), utcInstant.UnixMilli())) rows, err := queryPreviousLastSourceRows(context.Background(), db, config{ TDengineDatabase: "lingniu_vehicle_ts", @@ -866,106 +831,6 @@ func TestClearBackfillTargetMileageClearsExactKey(t *testing.T) { } } -func TestWriteAggregatesClearsTargetRowsBeforeStaleCandidateUpsert(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("sqlmock.New() error = %v", err) - } - defer db.Close() - mock.MatchExpectationsInOrder(true) - - projNow := time.Date(2026, 7, 8, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) - aggregates := map[string]*metricAgg{ - "LA9GG64L7PBAF4001|2026-07-08|JT808|JT808:13307765812@115.231.168.135": { - VIN: "LA9GG64L7PBAF4001", - Date: "2026-07-08", - Protocol: envelope.ProtocolJT808, - FirstKM: 4100, - LatestKM: 4101, - Count: 1, - SourceKey: "JT808:13307765812@115.231.168.135", - Phone: "13307765812", - SourceEndpoint: "115.231.168.135:20215", - FirstEventTime: projNow, - LatestEventTime: projNow, - QualityStatus: stats.QualityInvalidDelta, - QualityReason: "outside_daily_range", - }, - } - - mock.ExpectExec(`DELETE FROM vehicle_daily_mileage_source WHERE vin = \? AND stat_date = \? AND protocol = \?`). - WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`DELETE FROM vehicle_daily_mileage WHERE vin = \? AND stat_date = \? AND protocol = \?`). - WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808"). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`INSERT INTO vehicle_data_source`). - WithArgs("JT808", "115.231.168.135", "115.231.168.135:20215", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectExec(`INSERT INTO vehicle_daily_mileage_source`). - WithArgs( - "LA9GG64L7PBAF4001", - "2026-07-08", - "JT808", - "JT808:13307765812@115.231.168.135", - "115.231.168.135", - "115.231.168.135:20215", - "13307765812", - "", - "", - float64(4100), - float64(4101), - float64(1), - float64(0), - int64(1), - int64(0), - projNow, - projNow, - false, - false, - stats.QualityInvalidDelta, - "outside_daily_range", - ). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectBegin() - mock.ExpectExec(`INSERT INTO vehicle_daily_mileage`). - WithArgs("LA9GG64L7PBAF4001", "2026-07-08", "JT808", int64(2500)). - WillReturnResult(sqlmock.NewResult(1, 0)) - mock.ExpectExec(`UPDATE vehicle_daily_mileage_source s`). - WithArgs( - "LA9GG64L7PBAF4001", - "2026-07-08", - "JT808", - int64(2500), - "LA9GG64L7PBAF4001", - "2026-07-08", - "JT808", - ). - WillReturnResult(sqlmock.NewResult(0, 0)) - mock.ExpectExec(`DELETE FROM vehicle_daily_mileage`). - WithArgs( - "LA9GG64L7PBAF4001", - "2026-07-08", - "JT808", - "LA9GG64L7PBAF4001", - "2026-07-08", - "JT808", - ). - WillReturnResult(sqlmock.NewResult(0, 1)) - mock.ExpectCommit() - - written, err := writeAggregates(context.Background(), db, aggregates, 500) - if err != nil { - t.Fatalf("writeAggregates() error = %v", err) - } - if written != 1 { - t.Fatalf("written = %d, want 1", written) - } - if err := mock.ExpectationsWereMet(); err != nil { - t.Fatalf("sql expectations: %v", err) - } -} - func TestWriteAggregatesSkipsBlankSourceBeforeClearingTarget(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { diff --git a/go/vehicle-gateway/cmd/stats-backfill/protected_write.go b/go/vehicle-gateway/cmd/stats-backfill/protected_write.go new file mode 100644 index 00000000..6549bc17 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/protected_write.go @@ -0,0 +1,239 @@ +package main + +import ( + "context" + "database/sql" + "log/slog" + "sort" + "strings" + "time" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +// Rebuild each GPS vehicle-day atomically. Retained RAW is not guaranteed to +// contain every source or a later sample already accepted by the live writer. +// Preserve those durable candidates instead of deleting the complete day. +func writeProtectedGPSAggregates(ctx context.Context, db *sql.DB, aggregates map[string]*metricAgg) (int64, error) { + groups := map[string][]*metricAgg{} + for _, agg := range aggregates { + if strings.TrimSpace(stats.NormalizeSourceIP(agg.SourceEndpoint)) == "" { + continue + } + key := agg.VIN + "|" + agg.Date + groups[key] = append(groups[key], agg) + } + keys := make([]string, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + sort.Strings(keys) + var written int64 + for _, key := range keys { + group := groups[key] + sort.Slice(group, func(i, j int) bool { return group[i].SourceKey < group[j].SourceKey }) + n, err := writeProtectedGPSDay(ctx, db, group) + if err != nil { + return written, err + } + written += n + } + return written, nil +} + +func writeProtectedGPSDay(ctx context.Context, db *sql.DB, group []*metricAgg) (int64, error) { + first := group[0] + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + existing, selectedManual, err := lockGPSCandidates(ctx, tx, first.VIN, first.Date) + if err != nil { + return 0, err + } + var written int64 + for _, agg := range group { + candidate := backfillCandidate(agg) + if old, ok := existing[candidate.SourceKey]; ok { + if isManualGPSCandidate(old) { + continue + } + candidate = mergeProtectedGPSCandidate(old, candidate) + } + identity := stats.SourceIdentity{Protocol: envelope.ProtocolJT808, SourceIP: candidate.SourceIP, + SourceEndpoint: candidate.SourceEndpoint, SourceCode: agg.SourceCode, PlatformName: agg.PlatformName, SourceKind: agg.SourceKind} + if candidate.QualityReason != stats.QualityReasonGPSCoordinate { + if err := stats.UpsertDataSource(ctx, tx, identity, candidate.LatestEventTime); err != nil { + return 0, err + } + } + if err := replaceBackfillCandidate(ctx, tx, candidate); err != nil { + return 0, err + } + written++ + } + // An explicitly selected imported report is a business override. Keep that + // final result while still correcting the underlying automatic candidates. + if !selectedManual { + if err := stats.ProjectDailyMileage(ctx, tx, first.VIN, first.Date, envelope.ProtocolJT808); err != nil { + return 0, err + } + } + if err := tx.Commit(); err != nil { + return 0, err + } + return written, nil +} + +func normalizeProtectedGPSDay(ctx context.Context, db *sql.DB, vin, date string) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + _, manual, err := lockGPSCandidates(ctx, tx, vin, date) + if err != nil { + return err + } + if manual { + return tx.Commit() + } + if err := stats.NormalizePlatformSourceMileage(ctx, tx, vin, date, envelope.ProtocolJT808); err != nil { + return err + } + if err := stats.ProjectDailyMileage(ctx, tx, vin, date, envelope.ProtocolJT808); err != nil { + return err + } + return tx.Commit() +} + +func isManualGPSCandidate(c stats.SourceMileageSample) bool { + return strings.HasPrefix(c.QualityReason, "manual") || strings.HasPrefix(c.SourceIP, "manual") || strings.HasPrefix(c.SourceIP, "legacy-mysql") +} + +func backfillCandidate(agg *metricAgg) stats.SourceMileageSample { + c := stats.SourceMileageSample{ + VIN: agg.VIN, StatDate: agg.Date, Protocol: agg.Protocol, SourceKey: agg.SourceKey, + SourceIP: stats.NormalizeSourceIP(agg.SourceEndpoint), SourceEndpoint: agg.SourceEndpoint, + Phone: agg.Phone, DeviceID: agg.DeviceID, PlatformName: agg.PlatformName, + FirstTotalKM: agg.FirstKM, LatestTotalKM: agg.LatestKM, + DailyKM: stats.DailyMileageFromDayBoundary(agg.FirstKM, agg.LatestKM), + PureHydrogenMileageKM: agg.PureHydrogenMileageKM, SampleCount: agg.Count, + PureHydrogenSampleCount: agg.PureHydrogenSampleCount, + FirstEventTime: agg.FirstEventTime, LatestEventTime: agg.LatestEventTime, + LatestPureHydrogenActive: agg.LatestPureHydrogenActive, LatestPureHydrogenModeKnown: agg.LatestPureHydrogenModeKnown, + QualityStatus: agg.QualityStatus, QualityReason: agg.QualityReason, + } + stats.ApplyMileageQualityRules(&c) + return c +} + +func mergeProtectedGPSCandidate(old, next stats.SourceMileageSample) stats.SourceMileageSample { + if next.QualityReason == stats.QualityReasonGPSCoordinate || old.QualityReason == stats.QualityReasonGPSCoordinate { + if old.LatestEventTime.After(next.LatestEventTime) { + return old + } + return next + } + if old.LatestEventTime.After(next.LatestEventTime) { + slog.Info("preserved newer durable GPS end", "vin", next.VIN, "date", next.StatDate, + "source_key", next.SourceKey, "raw_end", next.LatestEventTime, "durable_end", old.LatestEventTime) + next.LatestEventTime, next.LatestTotalKM = old.LatestEventTime, old.LatestTotalKM + next.SourceEndpoint = old.SourceEndpoint + next.LatestPureHydrogenActive, next.LatestPureHydrogenModeKnown = old.LatestPureHydrogenActive, old.LatestPureHydrogenModeKnown + } + // Prefer the newest prior-day boundary; otherwise retain the earliest + // current-day sample. Always move the timestamp and odometer together. + if preferDurableGPSFirst(old.FirstEventTime, next.FirstEventTime, next.StatDate) { + next.FirstEventTime, next.FirstTotalKM = old.FirstEventTime, old.FirstTotalKM + } + if old.SampleCount > next.SampleCount { + next.SampleCount = old.SampleCount + } + if old.PureHydrogenSampleCount > next.PureHydrogenSampleCount { + next.PureHydrogenSampleCount = old.PureHydrogenSampleCount + } + if old.PureHydrogenMileageKM > next.PureHydrogenMileageKM { + next.PureHydrogenMileageKM = old.PureHydrogenMileageKM + } + next.DailyKM = stats.DailyMileageFromDayBoundary(next.FirstTotalKM, next.LatestTotalKM) + next.QualityStatus = stats.QualityOK + if next.FirstEventTime.Format("2006-01-02") < next.StatDate { + next.QualityReason = stats.QualityReasonHistorical + } else { + next.QualityReason = stats.QualityReasonCurrentDayFirst + } + stats.ApplyMileageQualityRules(&next) + return next +} + +func preferDurableGPSFirst(old, next time.Time, date string) bool { + if old.IsZero() { + return false + } + if next.IsZero() { + return true + } + oldBefore, nextBefore := old.Format("2006-01-02") < date, next.Format("2006-01-02") < date + if oldBefore != nextBefore { + return oldBefore + } + if oldBefore { + return old.After(next) + } + return old.Before(next) +} + +func lockGPSCandidates(ctx context.Context, tx *sql.Tx, vin, date string) (map[string]stats.SourceMileageSample, bool, error) { + rows, err := tx.QueryContext(ctx, `SELECT source_key,source_ip,COALESCE(source_endpoint,''),COALESCE(phone,''),COALESCE(device_id,''),COALESCE(platform_name,''), +first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,pure_hydrogen_mileage_km,sample_count,pure_hydrogen_sample_count, +first_event_time,latest_event_time,latest_pure_hydrogen_active,latest_pure_hydrogen_mode_known,quality_status,COALESCE(quality_reason,''),is_selected +FROM vehicle_daily_mileage_source WHERE vin=? AND stat_date=? AND protocol='JT808' FOR UPDATE`, vin, date) + if err != nil { + return nil, false, err + } + defer rows.Close() + out := map[string]stats.SourceMileageSample{} + manual := false + for rows.Next() { + c := stats.SourceMileageSample{VIN: vin, StatDate: date, Protocol: envelope.ProtocolJT808} + var first, last sql.NullTime + var firstKM, lastKM sql.NullFloat64 + var selected bool + if err := rows.Scan(&c.SourceKey, &c.SourceIP, &c.SourceEndpoint, &c.Phone, &c.DeviceID, &c.PlatformName, + &firstKM, &lastKM, &c.DailyKM, &c.PureHydrogenMileageKM, &c.SampleCount, &c.PureHydrogenSampleCount, + &first, &last, &c.LatestPureHydrogenActive, &c.LatestPureHydrogenModeKnown, &c.QualityStatus, &c.QualityReason, &selected); err != nil { + return nil, false, err + } + c.FirstEventTime, c.LatestEventTime = first.Time, last.Time + c.FirstTotalKM, c.LatestTotalKM = firstKM.Float64, lastKM.Float64 + out[c.SourceKey] = c + if selected && isManualGPSCandidate(c) { + manual = true + } + } + return out, manual, rows.Err() +} + +func replaceBackfillCandidate(ctx context.Context, tx *sql.Tx, c stats.SourceMileageSample) error { + _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_daily_mileage_source +(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,device_id,platform_name, +first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,pure_hydrogen_mileage_km, +sample_count,pure_hydrogen_sample_count,first_event_time,latest_event_time, +latest_pure_hydrogen_active,latest_pure_hydrogen_mode_known,quality_status,quality_reason) +VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +ON DUPLICATE KEY UPDATE source_ip=VALUES(source_ip),source_endpoint=VALUES(source_endpoint),phone=VALUES(phone),device_id=VALUES(device_id), +platform_name=VALUES(platform_name),first_total_mileage_km=VALUES(first_total_mileage_km),latest_total_mileage_km=VALUES(latest_total_mileage_km), +daily_mileage_km=VALUES(daily_mileage_km),pure_hydrogen_mileage_km=VALUES(pure_hydrogen_mileage_km), +sample_count=VALUES(sample_count),pure_hydrogen_sample_count=VALUES(pure_hydrogen_sample_count), +first_event_time=VALUES(first_event_time),latest_event_time=VALUES(latest_event_time), +latest_pure_hydrogen_active=VALUES(latest_pure_hydrogen_active),latest_pure_hydrogen_mode_known=VALUES(latest_pure_hydrogen_mode_known), +quality_status=VALUES(quality_status),quality_reason=VALUES(quality_reason),updated_at=CURRENT_TIMESTAMP`, + c.VIN, c.StatDate, string(c.Protocol), c.SourceKey, c.SourceIP, c.SourceEndpoint, c.Phone, c.DeviceID, c.PlatformName, + c.FirstTotalKM, c.LatestTotalKM, c.DailyKM, c.PureHydrogenMileageKM, c.SampleCount, c.PureHydrogenSampleCount, + c.FirstEventTime.Truncate(time.Second), c.LatestEventTime.Truncate(time.Second), + c.LatestPureHydrogenActive, c.LatestPureHydrogenModeKnown, c.QualityStatus, c.QualityReason) + return err +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/protected_write_mysql_test.go b/go/vehicle-gateway/cmd/stats-backfill/protected_write_mysql_test.go new file mode 100644 index 00000000..63ec5664 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/protected_write_mysql_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "os" + "reflect" + "testing" + "time" + + mysql "github.com/go-sql-driver/mysql" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +// A lost session must never reconnect without its temporary shadow tables. +type oneSessionConnector struct { + driver.Connector + used bool +} + +func (c *oneSessionConnector) Connect(ctx context.Context) (driver.Conn, error) { + if c.used { + return nil, errors.New("temporary-table integration session cannot reconnect") + } + c.used = true + return c.Connector.Connect(ctx) +} + +func TestProtectedGPSWriteMySQLTemporaryTables(t *testing.T) { + dsn := os.Getenv("BACKFILL_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("set BACKFILL_TEST_MYSQL_DSN for isolated temporary-table integration") + } + cfg, err := mysql.ParseDSN(dsn) + if err != nil { + t.Fatal("invalid integration DSN") + } + cfg.ParseTime = true + cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600) + connector, err := mysql.NewConnector(cfg) + if err != nil { + t.Fatal("integration connector unavailable") + } + db := sql.OpenDB(&oneSessionConnector{Connector: connector}) + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + defer db.Close() + ctx := context.Background() + for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage", "vehicle_data_source"} { + if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE gps_fixture_"+table+" LIKE "+table); err != nil { + t.Fatal(err) + } + if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE "+table+" LIKE gps_fixture_"+table); err != nil { + t.Fatal(err) + } + } + loc := cfg.Loc + prev := time.Date(2026, 10, 20, 23, 59, 0, 0, loc) + end := time.Date(2026, 10, 21, 23, 59, 0, 0, loc) + const vin = "LTESTGPSPROTECT001" + key := "JT808:fixture@PLATFORM:g7s" + if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage_source +(vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,platform_name,first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,sample_count,first_event_time,latest_event_time,quality_status,quality_reason) +VALUES (?,'2026-10-21','JT808',?,'1.2.3.4','1.2.3.4:1','fixture','G7s',100,120,20,200,?,?,'OK','historical_source_baseline'), +(?,'2026-10-21','JT808','JT808:other@PLATFORM:other','2.3.4.5','2.3.4.5:2','other','Other',100,101,1,3,?,?,'OK','historical_source_baseline')`, vin, key, prev, end, vin, prev, end.Add(-time.Minute)); err != nil { + t.Fatal(err) + } + // Keep the existing manual final selected. This exercises the protected + // write path without MySQL's temporary-table self-join restriction in the + // independent projection query (covered separately by replay verification). + if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage_source (vin,stat_date,protocol,source_key,source_ip,daily_mileage_km,sample_count,quality_status,quality_reason,is_selected) +VALUES (?,'2026-10-21','JT808','manual-report','manual-import',88,1,'OK','manual_gps_report',1)`, vin); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage (vin,stat_date,protocol,source_id,daily_mileage_km) VALUES (?,'2026-10-21','JT808',0,88)`, vin); err != nil { + t.Fatal(err) + } + agg := &metricAgg{VIN: vin, Date: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: key, Phone: "fixture", SourceEndpoint: "1.2.3.4:3", SourceCode: "g7s", SourceKind: "PLATFORM", PlatformName: "G7s", FirstKM: 90, LatestKM: 115, Count: 100, FirstEventTime: prev.Add(-24 * time.Hour), LatestEventTime: end.Add(-time.Hour), QualityStatus: "OK", QualityReason: "historical_source_baseline"} + read := func() []any { + var first, last, daily float64 + var count int + var firstTS, lastTS time.Time + if err := db.QueryRow(`SELECT first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,sample_count,first_event_time,latest_event_time FROM vehicle_daily_mileage_source WHERE vin=? AND source_key=?`, vin, key).Scan(&first, &last, &daily, &count, &firstTS, &lastTS); err != nil { + t.Fatal(err) + } + return []any{first, last, daily, count, firstTS, lastTS} + } + var before []any + for run := 0; run < 2; run++ { + if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil { + t.Fatal(err) + } + got := read() + if got[0] != float64(100) || got[1] != float64(120) || got[2] != float64(20) || got[3] != 200 { + t.Fatalf("durable data overwritten: %+v", got) + } + if run == 1 && !reflect.DeepEqual(before, got) { + t.Fatalf("repeated backfill changed business values: before=%+v after=%+v", before, got) + } + before = got + } + var sources int + if err := db.QueryRow("SELECT COUNT(*) FROM vehicle_daily_mileage_source WHERE vin=?", vin).Scan(&sources); err != nil { + t.Fatal(err) + } + if sources != 3 { + t.Fatalf("missing durable alternate source: %d", sources) + } + // Newer event with a lower counter wins by time, not MAX(odometer). + agg.FirstKM, agg.FirstEventTime = 100, prev + agg.LatestKM, agg.LatestEventTime = 119, end.Add(time.Second) + if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil { + t.Fatal(err) + } + if got := read(); got[1] != float64(119) || got[2] != float64(19) { + t.Fatalf("newer event rejected: %+v", got) + } + agg.LatestKM, agg.LatestEventTime = 121, end.Add(2*time.Second) + if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil { + t.Fatal(err) + } + var final float64 + if err := db.QueryRow("SELECT daily_mileage_km FROM vehicle_daily_mileage WHERE vin=?", vin).Scan(&final); err != nil { + t.Fatal(err) + } + if final != 88 { + t.Fatalf("manual final overwritten: %v", final) + } + t.Log("temporary-table scenarios passed: incomplete RAW, alternate source retention, repeated backfill, event ordering, manual final; production rows modified=0") +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/protected_write_test.go b/go/vehicle-gateway/cmd/stats-backfill/protected_write_test.go new file mode 100644 index 00000000..88fdae90 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/protected_write_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +func TestDurableGPSBoundariesSurviveIncompleteRawAndRepeatedBackfill(t *testing.T) { + loc := time.FixedZone("Asia/Shanghai", 8*3600) + previous := time.Date(2026, 10, 20, 23, 59, 0, 0, loc) + last := time.Date(2026, 10, 21, 23, 59, 0, 0, loc) + old := stats.SourceMileageSample{VIN: "VIN", StatDate: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:phone@PLATFORM:g7s", SourceEndpoint: "1.2.3.4:1", FirstEventTime: previous, FirstTotalKM: 100, LatestEventTime: last, LatestTotalKM: 120, DailyKM: 20, SampleCount: 200, QualityStatus: stats.QualityOK, QualityReason: stats.QualityReasonHistorical} + partial := old + partial.FirstEventTime, partial.FirstTotalKM = previous.Add(-24*time.Hour), 90 + partial.LatestEventTime, partial.LatestTotalKM = last.Add(-time.Hour), 115 + partial.SampleCount = 100 + for i := 0; i < 3; i++ { + got := mergeProtectedGPSCandidate(old, partial) + if !reflect.DeepEqual(got, old) { + t.Fatalf("run %d replaced durable boundaries or double-counted: got=%+v old=%+v", i, got, old) + } + old = got + } + newer := partial + newer.FirstEventTime, newer.FirstTotalKM = previous, 100 + newer.LatestEventTime, newer.LatestTotalKM = last.Add(time.Minute), 119 + got := mergeProtectedGPSCandidate(old, newer) + if got.LatestTotalKM != 119 || got.DailyKM != 19 { + t.Fatalf("event ordering incorrectly replaced with maximum odometer: %+v", got) + } +} + +func TestBackfillCurrentDayFirstBoundaryRetainsEarliestEvent(t *testing.T) { + day := time.Date(2026, 10, 21, 8, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + if !preferDurableGPSFirst(day, day.Add(time.Hour), "2026-10-21") { + t.Fatal("earliest current-day event lost") + } + if preferDurableGPSFirst(day.Add(-48*time.Hour), day.Add(-24*time.Hour), "2026-10-21") { + t.Fatal("older previous-day baseline won") + } +} + +func gpsLockedRows() *sqlmock.Rows { + return sqlmock.NewRows([]string{"source_key", "source_ip", "endpoint", "phone", "device", "platform", "first_km", "last_km", "daily_km", "hydrogen_km", "count", "hydrogen_count", "first_time", "last_time", "active", "known", "status", "reason", "selected"}) +} + +func TestGPSBackfillKeepsManualFinalAndRollsBackPartialWrites(t *testing.T) { + for _, fail := range []bool{false, true} { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + stamp := time.Date(2026, 10, 21, 23, 59, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + agg := &metricAgg{VIN: "VIN", Date: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: "JT808:phone@PLATFORM:g7s", SourceEndpoint: "1.2.3.4:123", FirstKM: 100, LatestKM: 120, FirstEventTime: stamp.Add(-24 * time.Hour), LatestEventTime: stamp, Count: 5, QualityStatus: stats.QualityOK, QualityReason: stats.QualityReasonHistorical} + mock.ExpectBegin() + mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs("VIN", "2026-10-21").WillReturnRows(gpsLockedRows().AddRow("manual-report", "manual-import", "", "", "", "G7s", nil, nil, 88, 0, 1, 0, nil, nil, false, false, "OK", "manual_gps_report", true)) + mock.ExpectExec("INSERT INTO vehicle_data_source").WillReturnResult(sqlmock.NewResult(0, 1)) + if fail { + mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WillReturnError(errors.New("write failed")) + mock.ExpectRollback() + } else { + mock.ExpectExec("(?s)INSERT INTO vehicle_daily_mileage_source.*sample_count=VALUES\\(sample_count\\)").WillReturnResult(sqlmock.NewResult(0, 1)) + // No clearing other candidates, source-count increment or final + // projection is permitted over a selected manual report. + mock.ExpectCommit() + } + n, err := writeAggregates(context.Background(), db, map[string]*metricAgg{"target": agg}, 500) + if fail && (err == nil || n != 0) { + t.Fatalf("failed target committed: n=%d err=%v", n, err) + } + if !fail && (err != nil || n != 1) { + t.Fatalf("manual override write: n=%d err=%v", n, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + db.Close() + } +} + +func TestGPSNormalizationDoesNotRewriteSelectedManualEvidence(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectBegin() + mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs("VIN", "2026-07-01").WillReturnRows(gpsLockedRows().AddRow("manual-report", "manual-import", "", "", "", "G7s", nil, nil, 88, 0, 1, 0, nil, nil, false, false, "OK", "manual_gps_report", true)) + // Do not normalize/delete the selected manual source, lose its selection + // flag, or reproject over the manual final result. + mock.ExpectCommit() + if err := normalizeProtectedGPSDay(context.Background(), db, "VIN", "2026-07-01"); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/source_catalog.go b/go/vehicle-gateway/cmd/stats-backfill/source_catalog.go new file mode 100644 index 00000000..526ecd15 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/source_catalog.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sort" + "strings" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +type backfillSourceID struct { + protocol envelope.Protocol + ip string +} + +// Resolve forwarding endpoints before grouping RAW or looking up durable daily +// boundaries. Normalizing only after writing cannot repair an IP-key lookup +// that has already missed the newer @PLATFORM boundary. +func loadBackfillSourceCatalog(ctx context.Context, db *sql.DB) (map[backfillSourceID]stats.SourceIdentity, error) { + rows, err := db.QueryContext(ctx, `SELECT protocol, source_ip, + COALESCE(source_code, ''), COALESCE(platform_name, ''), source_kind +FROM vehicle_data_source WHERE protocol = 'JT808'`) + if err != nil { + return nil, err + } + defer rows.Close() + catalog := map[backfillSourceID]stats.SourceIdentity{} + for rows.Next() { + var protocol string + var source stats.SourceIdentity + if err := rows.Scan(&protocol, &source.SourceIP, &source.SourceCode, &source.PlatformName, &source.SourceKind); err != nil { + return nil, err + } + source.Protocol = envelope.Protocol(strings.TrimSpace(protocol)) + source.SourceIP = stats.NormalizeSourceIP(source.SourceIP) + source.SourceCode = strings.TrimSpace(source.SourceCode) + source.PlatformName = strings.TrimSpace(source.PlatformName) + source.SourceKind = strings.ToUpper(strings.TrimSpace(source.SourceKind)) + if source.SourceIP != "" { + catalog[backfillSourceID{source.Protocol, source.SourceIP}] = source + } + } + return catalog, rows.Err() +} + +func backfillSourceMetadata(cfg config, protocol envelope.Protocol, endpoint string) (string, string, string) { + if source, ok := cfg.SourceCatalog[backfillSourceID{protocol, stats.NormalizeSourceIP(endpoint)}]; ok { + return source.SourceCode, source.PlatformName, source.SourceKind + } + return knownPlatformSourceMetadata(protocol, endpoint) +} + +func mergeDailySourceRows(existing, incoming dailySourceLast) dailySourceLast { + latest := existing + if incoming.TS.After(existing.TS) { + latest = incoming + } + if incoming.FirstTS.Before(existing.FirstTS) { + latest.FirstTS, latest.FirstTotalKM = incoming.FirstTS, incoming.FirstTotalKM + } else { + latest.FirstTS, latest.FirstTotalKM = existing.FirstTS, existing.FirstTotalKM + } + latest.RawSampleCount = existing.RawSampleCount + incoming.RawSampleCount + return latest +} + +func backfillVINPredicate(cfg config, column string) string { + if len(cfg.VINs) == 0 { + return "" + } + values := make([]string, 0, len(cfg.VINs)) + for _, vin := range cfg.VINs { + values = append(values, "'"+quote(vin)+"'") + } + return column + " IN (" + strings.Join(values, ",") + ")" +} + +func appendBackfillVINScope(query string, cfg config, column string) string { + if predicate := backfillVINPredicate(cfg, column); predicate != "" { + query += "\n AND " + predicate + } + return query +} + +func logBackfillCandidates(aggregates map[string]*metricAgg) { + keys := make([]string, 0, len(aggregates)) + for key := range aggregates { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + a := aggregates[key] + slog.Info("backfill candidate", "vin", a.VIN, "date", a.Date, "protocol", a.Protocol, + "source_key", a.SourceKey, "platform", a.PlatformName, + "baseline_time", a.FirstEventTime.Format("2006-01-02 15:04:05"), "baseline_km", a.FirstKM, + "end_time", a.LatestEventTime.Format("2006-01-02 15:04:05"), "end_km", a.LatestKM, + "daily_km", fmt.Sprintf("%.3f", stats.DailyMileageFromDayBoundary(a.FirstKM, a.LatestKM)), + "samples", a.Count, "quality", a.QualityStatus) + } +} diff --git a/go/vehicle-gateway/cmd/stats-backfill/source_catalog_test.go b/go/vehicle-gateway/cmd/stats-backfill/source_catalog_test.go new file mode 100644 index 00000000..0f05d0f8 --- /dev/null +++ b/go/vehicle-gateway/cmd/stats-backfill/source_catalog_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "errors" + "fmt" + "math" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats" +) + +func TestBackfillPlatformBoundarySurvivesRawGapAndRepeatedRuns(t *testing.T) { + for _, tc := range []struct { + vin, terminal, ip, code string + old, previous, end float64 + }{ + {"LMRKH9AC9R1004099", "64341233471", "115.159.85.149", "g7s", 62749.7, 64432.7, 65136}, + {"LMRKH9AC7R1004120", "64341233712", "115.159.85.149", "g7s", 51126.2, 52704.4, 53614.1}, + {"LMRKH9AC3R1004129", "64341233795", "122.152.221.156", "dongfang_beidou", 57481.7, 59072.7, 59434.6}, + } { + t.Run(tc.vin, func(t *testing.T) { + mysqlDB, mysql, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer mysqlDB.Close() + tdDB, td, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer tdDB.Close() + mysql.ExpectQuery("SELECT protocol, source_ip").WillReturnRows(sqlmock.NewRows([]string{"protocol", "ip", "code", "name", "kind"}). + AddRow("JT808", tc.ip, tc.code, tc.code, "PLATFORM")) + catalog, err := loadBackfillSourceCatalog(context.Background(), mysqlDB) + if err != nil { + t.Fatal(err) + } + loc := time.FixedZone("Asia/Shanghai", 8*3600) + oldTS := time.Date(2026, 10, 16, 15, 26, 0, 0, loc) + previousTS := time.Date(2026, 10, 18, 23, 59, 0, 0, loc) + firstTS := time.Date(2026, 10, 19, 11, 0, 0, 0, loc) + endTS := time.Date(2026, 10, 19, 23, 59, 0, 0, loc) + parsed := func(km float64) string { return fmt.Sprintf(`{"jt808.location.total_mileage_km":%f}`, km) } + key := "JT808:" + tc.terminal + "@PLATFORM:" + tc.code + cfg := config{DateFrom: "2026-10-19", DateTo: "2026-10-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808}, + VINs: []string{tc.vin}, TDengineDatabase: "lingniu_vehicle_ts", Location: loc, SourceCatalog: catalog} + // Run twice against the same durable October 18 boundary. A replay must + // neither use October 16 RAW nor subtract today's already-written result. + for run := 0; run < 2; run++ { + td.ExpectQuery("(?s)SELECT vin.*LAST\\(event_time\\).*event_time < '2026-10-19 00:00:00'.*vin IN \\('" + tc.vin + "'\\)"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "last", "parsed", "raw", "count", "MAX_EVENT_MS"}). + AddRow(tc.vin, tc.terminal, "", tc.ip+":1000", oldTS, parsed(tc.old), "", 10, oldTS.UnixMilli())) + td.ExpectQuery("(?s)SELECT vin.*FIRST\\(event_time\\).*event_time >= '2026-10-19 00:00:00'.*vin IN \\('" + tc.vin + "'\\)"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "phone", "device", "endpoint", "first", "first_parsed", "first_raw", "last", "last_parsed", "last_raw", "count", "MIN_EVENT_MS", "MAX_EVENT_MS"}). + AddRow(tc.vin, tc.terminal, "", tc.ip+":2000", firstTS, parsed(tc.previous+1), "", firstTS.Add(time.Hour), parsed(tc.previous+10), "", 100, firstTS.UnixMilli(), firstTS.Add(time.Hour).UnixMilli()). + AddRow(tc.vin, tc.terminal, "", tc.ip+":3000", firstTS.Add(2*time.Hour), parsed(tc.previous+11), "", endTS, parsed(tc.end), "", 200, firstTS.Add(2*time.Hour).UnixMilli(), endTS.UnixMilli())) + mysql.ExpectQuery("SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source"). + WithArgs(tc.vin, "2026-10-19", "JT808", key). + WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(tc.previous, previousTS)) + aggregates, err := buildLastDiffAggregates(context.Background(), mysqlDB, tdDB, cfg) + if err != nil { + t.Fatal(err) + } + a := aggregates[tc.vin+"|2026-10-19|JT808|"+key] + if len(aggregates) != 1 || a == nil || a.FirstKM != tc.previous || !a.FirstEventTime.Equal(previousTS) || a.Count != 300 { + t.Fatalf("run %d: wrong boundary or connection grouping: %+v", run, a) + } + if math.Abs((a.LatestKM-a.FirstKM)-(tc.end-tc.previous)) > 0.0001 || a.SourceCode != tc.code { + t.Fatalf("run %d: recounted historical mileage: %+v", run, a) + } + } + if err := mysql.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + if err := td.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestBackfillSourceCatalogPreservesIndependentAndUnknownCounters(t *testing.T) { + cfg := config{SourceCatalog: map[backfillSourceID]stats.SourceIdentity{ + {envelope.ProtocolJT808, "1.2.3.4"}: {SourceCode: "registered", SourceKind: "PLATFORM"}, + {envelope.ProtocolJT808, "2.3.4.5"}: {SourceKind: "DIRECT"}, + }} + key := func(protocol envelope.Protocol, terminal, endpoint string) string { + code, _, kind := backfillSourceMetadata(cfg, protocol, endpoint) + return normalizedSourceKeyForSource(string(protocol), terminal, "", endpoint, kind, code) + } + for _, tc := range []struct{ terminal, endpoint, want string }{ + {"terminal-a", "1.2.3.4:123", "JT808:terminal-a@PLATFORM:registered"}, + {"terminal-b", "1.2.3.4:456", "JT808:terminal-b@PLATFORM:registered"}, + {"terminal-a", "2.3.4.5:123", "JT808:terminal-a@DIRECT"}, + {"terminal-a", "3.4.5.6:123", "JT808:terminal-a@3.4.5.6"}, + } { + if got := key(envelope.ProtocolJT808, tc.terminal, tc.endpoint); got != tc.want { + t.Fatalf("key=%s want=%s", got, tc.want) + } + } + if got := key(envelope.ProtocolGB32960, "terminal-a", "1.2.3.4:123"); strings.Contains(got, "PLATFORM") { + t.Fatalf("JT808 metadata leaked into GB32960: %s", got) + } +} + +func TestBackfillSourceCatalogFailureIsNotIgnored(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("SELECT protocol, source_ip").WillReturnError(errors.New("database unavailable")) + if _, err := loadBackfillSourceCatalog(context.Background(), db); err == nil { + t.Fatal("missing source catalog must fail before recomputation") + } +} + +func TestBackfillVINScopeIncludesCleanupAndRealtimeFallback(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + cfg := config{VINs: []string{"ONLY-THIS-VIN"}, DateFrom: "2026-10-19", DateTo: "2026-10-19", Protocols: []envelope.Protocol{envelope.ProtocolJT808}} + mock.ExpectQuery("(?s)FROM vehicle_daily_mileage_source.*AND vin IN \\('ONLY-THIS-VIN'\\).*GROUP BY"). + WithArgs(cfg.DateFrom, cfg.DateTo, stats.QualityReasonGPSCoordinate, stats.GPSMinimumDailyDistanceKM, "JT808"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "count"})) + if _, _, err := cleanupSubthresholdGPSMileageCandidates(context.Background(), db, cfg, true); err != nil { + t.Fatal(err) + } + mock.ExpectQuery("(?s)FROM vehicle_realtime_location.*AND l.vin IN \\('ONLY-THIS-VIN'\\)"). + WithArgs("YUTONG_MQTT", cfg.DateFrom, cfg.DateFrom). + WillReturnRows(sqlmock.NewRows([]string{"vin", "peer", "time", "total"})) + if _, err := queryRealtimeLocationLastRows(context.Background(), db, cfg, envelope.ProtocolYutongMQTT, cfg.DateFrom); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestBackfillScopedNormalizationDoesNotScanOtherVehicles(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + vin, date := "ONLY-THIS-VIN", "2026-10-19" + mock.ExpectBegin() + mock.ExpectQuery("(?s)SELECT source_key.*FOR UPDATE").WithArgs(vin, date).WillReturnRows(gpsLockedRows()) + mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(vin, date, "JT808").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("DELETE s").WithArgs(vin, date, "JT808").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO vehicle_daily_mileage").WithArgs(vin, date, "JT808", int64(2500)).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("DELETE FROM vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + _, err = normalizeHistoricalPlatformSources(context.Background(), db, config{VINs: []string{vin}, DateFrom: date, DateTo: date, Protocols: []envelope.Protocol{envelope.ProtocolJT808}}) + if err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMergeDailySourceRowsIsIndependentOfConnectionOrder(t *testing.T) { + first := dailySourceLast{FirstTS: time.Unix(100, 0), TS: time.Unix(200, 0), FirstTotalKM: 10, TotalKM: 20, RawSampleCount: 4} + last := dailySourceLast{FirstTS: time.Unix(300, 0), TS: time.Unix(400, 0), FirstTotalKM: 30, TotalKM: 40, RawSampleCount: 6} + for _, merged := range []dailySourceLast{mergeDailySourceRows(first, last), mergeDailySourceRows(last, first)} { + if merged.FirstTotalKM != 10 || merged.TotalKM != 40 || merged.RawSampleCount != 10 { + t.Fatalf("lost connection boundary: %+v", merged) + } + } + // Scope quoting is shared by SQL paths, including escaped input. + if got := backfillVINPredicate(config{VINs: []string{"A'B"}}, "vin"); got != "vin IN ('A''B')" { + t.Fatalf("unexpected predicate: %s", got) + } +} diff --git a/go/vehicle-gateway/internal/gateway/feichi_custom_test.go b/go/vehicle-gateway/internal/gateway/feichi_custom_test.go new file mode 100644 index 00000000..11ba2e8e --- /dev/null +++ b/go/vehicle-gateway/internal/gateway/feichi_custom_test.go @@ -0,0 +1,117 @@ +package gateway + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + "testing" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960" +) + +func TestFeichiCustom80UsesConnectionLoginAndSurvivesCanonicalRaw(t *testing.T) { + payload, _ := hex.DecodeString("3B1428271014282710024E01560000") + body := []byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0, 0x80, 0, 15} + body = append(body, payload...) + body = append(body, 0x80, 0, 3, 0x05, 0x80, 0x02) + body = append(body, 0x05, 0, 0x07, 0x36, 0x50, 0x40, 0x01, 0xd2, 0x4f, 0) + frame := buildGBFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", body) + for _, tc := range []struct { + account string + password string + wantCustom bool + }{ + {"Feichi", "test-secret", true}, + {"Feichi", "wrong-secret", false}, + {"Hyundai", "test-secret", false}, + {"YueJin", "test-secret", false}, + } { + t.Run(tc.account+"/"+tc.password, func(t *testing.T) { + sink := &recordingSink{} + server := newTestServer(t, TCPProtocol{ + Protocol: envelope.ProtocolGB32960, Addr: ":0", + Extract: gb32960.ExtractFrames, Parse: gb32960.ParseFrame, + ParseForPlatform: gb32960.ParseFrameForPlatform, + Authenticate: authentication.NewGB32960PlatformAuthenticator(authentication.ModeEnforce, map[string][]string{ + "Feichi": {"test-secret"}, "Hyundai": {"test-secret"}, "YueJin": {"test-secret"}, + }), + }, sink) + state := &connectionState{} + login := []byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0, 0, 1} + login = append(login, fixedASCIIBytes(tc.account, 12)...) + login = append(login, fixedASCIIBytes(tc.password, 20)...) + login = append(login, 1) + server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "", login), "183.239.48.25:2147", state) + server.handleFrame(context.Background(), nil, frame, "183.239.48.25:2147", state) + if len(sink.raw) != 2 { + t.Fatalf("raw frames=%d", len(sink.raw)) + } + raw := sink.raw[1] + if raw.RawHex != strings.ToUpper(hex.EncodeToString(frame)) || len(raw.Parsed) != 0 { + t.Fatal("canonical whole RAW frame was changed or contains duplicate tree") + } + prefix := "gb32960.feichi_custom_80.blocks." + _, custom := raw.ParsedFields[prefix+"block_1.payload_hex"] + if custom != tc.wantCustom { + t.Fatalf("account=%s wantCustom=%v fields=%#v", tc.account, tc.wantCustom, raw.ParsedFields) + } + if !tc.wantCustom { + if raw.ParsedFields["gb32960.gd_fc_demo_extension.declared_length"] != "15" { + t.Fatal("legacy decoder was not retained") + } + return + } + for key, want := range map[string]string{ + prefix + "block_1.declared_length": "15", + prefix + "block_1.actual_length": "15", + prefix + "block_1.payload_hex": "3B1428271014282710024E01560000", + prefix + "block_1.raw_hex": "80000F3B1428271014282710024E01560000", + prefix + "block_2.payload_hex": "058002", + prefix + "block_2.raw_hex": "800003058002", + "gb32960.position.longitude": "121", + "gb32960.position.latitude": "30.56", + } { + if got := fmt.Sprint(raw.ParsedFields[key]); got != want { + t.Fatalf("%s=%s want=%s", key, got, want) + } + } + if raw.PlatformName != "Feichi" || raw.ParseStatus != envelope.ParseOK { + t.Fatalf("platform=%q status=%s", raw.PlatformName, raw.ParseStatus) + } + // A fresh TCP connection does not inherit the previous account. + server.handleFrame(context.Background(), nil, frame, "183.239.48.25:9999", &connectionState{}) + if _, exists := sink.raw[2].ParsedFields[prefix+"block_1.payload_hex"]; exists { + t.Fatal("Feichi account leaked into a fresh connection") + } + }) + } +} + +func TestFeichiCustom80TruncationIsPreservedThroughRawProjection(t *testing.T) { + sink := &recordingSink{} + server := newTestServer(t, TCPProtocol{ + Protocol: envelope.ProtocolGB32960, Addr: ":0", + Extract: gb32960.ExtractFrames, Parse: gb32960.ParseFrame, + ParseForPlatform: gb32960.ParseFrameForPlatform, + }, sink) + frame := buildGBFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", []byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0, 0x80, 0, 15, 0x3b, 0x14}) + server.handleFrame(context.Background(), nil, frame, "183.239.48.25:2147", &connectionState{platformName: "Feichi"}) + raw := sink.raw[0] + for key, want := range map[string]string{ + "gb32960.feichi_custom_80.blocks.block_1.declared_length": "15", + "gb32960.feichi_custom_80.blocks.block_1.actual_length": "2", + "gb32960.feichi_custom_80.blocks.block_1.payload_hex": "3B14", + "gb32960.feichi_custom_80.blocks.block_1.raw_hex": "80000F3B14", + "gb32960.feichi_custom_80.blocks.block_1.parse_error": "truncated_payload", + } { + if got := fmt.Sprint(raw.ParsedFields[key]); got != want { + t.Fatalf("%s=%s want=%s", key, got, want) + } + } + if raw.ParseStatus != envelope.ParsePartial || raw.RawHex != strings.ToUpper(hex.EncodeToString(frame)) { + t.Fatalf("status=%s raw=%s", raw.ParseStatus, raw.RawHex) + } +} diff --git a/go/vehicle-gateway/internal/gateway/tcp_server.go b/go/vehicle-gateway/internal/gateway/tcp_server.go index 9c401581..f67c5f7d 100644 --- a/go/vehicle-gateway/internal/gateway/tcp_server.go +++ b/go/vehicle-gateway/internal/gateway/tcp_server.go @@ -25,15 +25,18 @@ type FrameExtractor func([]byte) (frames [][]byte, remainder []byte, err error) type FrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) +type PlatformFrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint, platformAccount string) (envelope.FrameEnvelope, error) + type FrameResponder func(raw []byte, env envelope.FrameEnvelope) (response []byte, ok bool, err error) type TCPProtocol struct { - Protocol envelope.Protocol - Addr string - Extract FrameExtractor - Parse FrameParser - Authenticate authentication.Authenticator - Respond FrameResponder + Protocol envelope.Protocol + Addr string + Extract FrameExtractor + Parse FrameParser + ParseForPlatform PlatformFrameParser + Authenticate authentication.Authenticator + Respond FrameResponder } type connectionState struct { @@ -283,7 +286,17 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte, defer cancelFrame() receivedAtMS := time.Now().UnixMilli() - env, err := s.protocol.Parse(raw, receivedAtMS, source) + var env envelope.FrameEnvelope + var err error + if s.protocol.ParseForPlatform != nil { + platformAccount := "" + if state != nil { + platformAccount = state.platformName + } + env, err = s.protocol.ParseForPlatform(raw, receivedAtMS, source, platformAccount) + } else { + env, err = s.protocol.Parse(raw, receivedAtMS, source) + } if err != nil { env = envelope.FrameEnvelope{ Protocol: s.protocol.Protocol, diff --git a/go/vehicle-gateway/internal/protocol/gb32960/feichi_custom_test.go b/go/vehicle-gateway/internal/protocol/gb32960/feichi_custom_test.go new file mode 100644 index 00000000..ad5d6e90 --- /dev/null +++ b/go/vehicle-gateway/internal/protocol/gb32960/feichi_custom_test.go @@ -0,0 +1,140 @@ +package gb32960 + +import ( + "bytes" + "encoding/hex" + "fmt" + "reflect" + "strings" + "testing" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +const feichiCustom80Payload = "3B1428271014282710024E01560000" + +func TestFeichiCustom80UsesDeclaredLengthAndContinuesWithStandardData(t *testing.T) { + for _, command := range []byte{0x02, 0x03} { + for _, length := range []int{0, 1, 9, 15, 32, 513} { + t.Run(fmt.Sprintf("command_%02X_length_%d", command, length), func(t *testing.T) { + payload := bytes.Repeat([]byte{0x80, 0x05, 0x02, 0x23, 0x23}, (length+4)/5)[:length] + if length == 15 { + payload, _ = hex.DecodeString(feichiCustom80Payload) + } + body := []byte{0x1a, 0x0a, 0x17, 0x0a, 0x00, 0x00, 0x80, byte(length >> 8), byte(length)} + body = append(body, payload...) + body = append(body, feichiTestPositionUnit()...) + body = append(body, 0x34, 0, 0, 0, 0, 0, 0, 0x2b) + frame := buildFrame(command, 0xfe, "LA9GG68L7PBAF4803", body) + env, err := ParseFrameForPlatform(frame, 1234, "183.239.48.25:2147", "Feichi") + if err != nil { + t.Fatal(err) + } + if env.ParseStatus != envelope.ParseOK || env.RawHex != strings.ToUpper(hex.EncodeToString(frame)) { + t.Fatalf("status=%s raw=%s", env.ParseStatus, env.RawHex) + } + units := env.Parsed["data_units"].([]map[string]any) + if len(units) != 3 || units[0]["name"] != "feichi_custom_80" || units[1]["name"] != "position" || units[2]["name"] != "gd_fc_vehicle_info" { + t.Fatalf("wrong unit boundaries: %#v", units) + } + block := feichiBlock(t, units[0], 1) + if block["declared_length"] != length || block["actual_length"] != length || block["payload_hex"] != strings.ToUpper(hex.EncodeToString(payload)) { + t.Fatalf("block=%#v", block) + } + if want := fmt.Sprintf("80%04X%X", length, payload); block["raw_hex"] != want { + t.Fatalf("raw=%v want=%s", block["raw_hex"], want) + } + assertFloatField(t, env, envelope.FieldLongitude, 121) + assertFloatField(t, env, envelope.FieldLatitude, 30.56) + assertFloatField(t, env, "gd_fc_vehicle_hydrogen_mass_kg", 4.3) + if _, exists := env.Fields["gd_fc_demo_stack_temp_c"]; exists { + t.Fatal("Feichi opaque bytes must not become Guangdong measurement fields") + } + }) + } + } +} + +func TestFeichiCustom80TruncationKeepsEveryReceivedByte(t *testing.T) { + for _, tc := range []struct { + name string + data []byte + error string + payload string + }{ + {"missing_length", nil, "truncated_length", ""}, + {"partial_length", []byte{0}, "truncated_length", ""}, + {"short_payload", []byte{0, 15, 0x3b, 0x14, 0x28}, "truncated_payload", "3B1428"}, + {"oversized_length", []byte{0xff, 0xff, 0x80}, "truncated_payload", "80"}, + } { + t.Run(tc.name, func(t *testing.T) { + body := append([]byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0}, feichiTestPositionUnit()...) + body = append(body, 0x80) + body = append(body, tc.data...) + frame := buildFrame(0x02, 0xfe, "LA9GG68L7PBAF4803", body) + env, err := ParseFrameForPlatform(frame, 1234, "source", "Feichi") + if err != nil { + t.Fatal(err) + } + if env.ParseStatus != envelope.ParsePartial || !strings.Contains(env.ParseError, tc.error) { + t.Fatalf("status=%s error=%q", env.ParseStatus, env.ParseError) + } + if env.RawHex != strings.ToUpper(hex.EncodeToString(frame)) { + t.Fatal("whole RAW frame was changed") + } + units := env.Parsed["data_units"].([]map[string]any) + if len(units) != 2 || units[0]["name"] != "position" { + t.Fatalf("units=%#v", units) + } + assertFloatField(t, env, envelope.FieldLongitude, 121) + block := feichiBlock(t, units[1], 1) + if block["raw_hex"] != fmt.Sprintf("80%X", tc.data) || block["payload_hex"] != tc.payload || block["parse_error"] != tc.error { + t.Fatalf("received bytes not preserved: %#v", block) + } + }) + } +} + +func TestPlatformParserPreservesExisting32960Accounts(t *testing.T) { + frame, err := hex.DecodeString(realFuelCellFrame) + if err != nil { + t.Fatal(err) + } + want, err := ParseFrame(frame, 1234, "same-source") + if err != nil { + t.Fatal(err) + } + for _, account := range []string{"", "Hyundai", "YueJin", "GuangJiaoTou", "feichi", "Feichi-other"} { + t.Run(account, func(t *testing.T) { + got, err := ParseFrameForPlatform(frame, 1234, "same-source", account) + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("legacy account changed: account=%q err=%v", account, err) + } + }) + } + assertIntField(t, want, "gd_fc_demo_stack_temp_c", 65) + assertFloatField(t, want, "gd_fc_vehicle_hydrogen_mass_kg", 4.3) +} + +func TestFeichiFramesWithoutCustom80KeepExistingResults(t *testing.T) { + for _, command := range []byte{0x02, 0x03} { + frame := buildFrame(command, 0xfe, "LA9GG68L7PBAF4803", append([]byte{0x1a, 0x0a, 0x17, 0x0a, 0, 0}, feichiTestPositionUnit()...)) + want, err := ParseFrame(frame, 1234, "same-source") + if err != nil { + t.Fatal(err) + } + got, err := ParseFrameForPlatform(frame, 1234, "same-source", "Feichi") + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("standard Feichi frame changed: %v", err) + } + } +} + +func feichiBlock(t *testing.T, unit map[string]any, index int) map[string]any { + t.Helper() + return unit["value"].(map[string]any)["blocks"].(map[string]any)[fmt.Sprintf("block_%d", index)].(map[string]any) +} + +func feichiTestPositionUnit() []byte { + return []byte{0x05, 0x00, 0x07, 0x36, 0x50, 0x40, 0x01, 0xd2, 0x4f, 0x00} +} diff --git a/go/vehicle-gateway/internal/protocol/gb32960/parser.go b/go/vehicle-gateway/internal/protocol/gb32960/parser.go index aa9017c8..fe389d63 100644 --- a/go/vehicle-gateway/internal/protocol/gb32960/parser.go +++ b/go/vehicle-gateway/internal/protocol/gb32960/parser.go @@ -51,6 +51,17 @@ func ExtractFrames(stream []byte) (frames [][]byte, remainder []byte, err error) } func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) { + return parseFrame(raw, receivedAtMS, sourceEndpoint, false) +} + +// ParseFrameForPlatform selects Feichi's opaque, length-prefixed 0x80 format +// using the account established by this TCP connection's platform login. +// All other accounts retain the existing Guangdong extension decoder. +func ParseFrameForPlatform(raw []byte, receivedAtMS int64, sourceEndpoint, platformAccount string) (envelope.FrameEnvelope, error) { + return parseFrame(raw, receivedAtMS, sourceEndpoint, strings.TrimSpace(platformAccount) == "Feichi") +} + +func parseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string, feichiCustom80 bool) (envelope.FrameEnvelope, error) { if len(raw) < minFrameLen { return envelope.FrameEnvelope{}, fmt.Errorf("%w: len=%d", ErrFrameTooShort, len(raw)) } @@ -86,7 +97,7 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope eventTimeMS := receivedAtMS if command == 0x02 || command == 0x03 { - eventTime, units := parseDataBody(version, body, fields) + eventTime, units := parseDataBodyWithCustom80(version, body, fields, feichiCustom80) if !eventTime.IsZero() { eventTimeMS = eventTime.UnixMilli() fields["device_time"] = eventTime.Format(time.RFC3339) @@ -117,17 +128,33 @@ func ParseFrame(raw []byte, receivedAtMS int64, sourceEndpoint string) (envelope Fields: fields, ParseStatus: envelope.ParseOK, } + if feichiCustom80 { + if units, ok := parsed["data_units"].([]map[string]any); ok { + for _, unit := range units { + if unit["name"] == "feichi_custom_80" && unit["error"] != nil { + env.ParseStatus = envelope.ParsePartial + env.ParseError = "Feichi 0x80: " + fmt.Sprint(unit["error"]) + break + } + } + } + } env.EventID = env.StableEventID() return env, nil } func parseDataBody(version string, body []byte, fields map[string]any) (time.Time, []map[string]any) { + return parseDataBodyWithCustom80(version, body, fields, false) +} + +func parseDataBodyWithCustom80(version string, body []byte, fields map[string]any, feichiCustom80 bool) (time.Time, []map[string]any) { if len(body) < 6 { return time.Time{}, nil } eventTime := parseGBTime(body[:6]) cursor := 6 var units []map[string]any + custom80Blocks := 0 for cursor < len(body) { unitType := body[cursor] cursor++ @@ -293,6 +320,16 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim fields["gd_fc_vehicle_hydrogen_mass_kg"] = unit["hydrogen_mass_kg"] cursor += 7 case 0x80: + if feichiCustom80 { + custom80Blocks++ + unit, size := parseFeichiCustom80Data(body[cursor:], custom80Blocks) + units = append(units, unit) + cursor += size + if unit["error"] != nil { + return eventTime, units + } + continue + } if len(body[cursor:]) < 11 { units = append(units, map[string]any{"type": "0x80", "error": "truncated"}) return eventTime, units @@ -323,6 +360,43 @@ func parseDataBody(version string, body []byte, fields map[string]any) (time.Tim return eventTime, units } +func parseFeichiCustom80Data(data []byte, blockNumber int) (map[string]any, int) { + size := len(data) + lengthSize := min(2, len(data)) + payload := []byte(nil) + block := map[string]any{ + "length_hex": strings.ToUpper(hex.EncodeToString(data[:lengthSize])), + } + parseError := "" + if len(data) < 2 { + parseError = "truncated_length" + } else { + declared := int(binary.BigEndian.Uint16(data[:2])) + block["declared_length"] = declared + size = min(2+declared, len(data)) + payload = data[2:size] + if size < 2+declared { + parseError = "truncated_payload" + } + } + block["actual_length"] = len(payload) + block["payload_hex"] = strings.ToUpper(hex.EncodeToString(payload)) + block["raw_hex"] = "80" + strings.ToUpper(hex.EncodeToString(data[:size])) + unit := map[string]any{ + "type": "0x80", + "name": "feichi_custom_80", + "value": map[string]any{ + // Distinct keys preserve repeated 0x80 blocks in flattened RAW fields. + "blocks": map[string]any{fmt.Sprintf("block_%d", blockNumber): block}, + }, + } + if parseError != "" { + unit["error"] = parseError + block["parse_error"] = parseError + } + return unit, size +} + func frameLength(frameStart []byte, bodyLen int) int { standardLen := headerLen + bodyLen + 1 if len(frameStart) < headerLen || frameStart[2] != 0x05 || bodyLen != 41 { diff --git a/go/vehicle-gateway/internal/stats/daily_metric.go b/go/vehicle-gateway/internal/stats/daily_metric.go index 151bf385..57be1838 100644 --- a/go/vehicle-gateway/internal/stats/daily_metric.go +++ b/go/vehicle-gateway/internal/stats/daily_metric.go @@ -49,6 +49,7 @@ type Writer struct { lastProjection map[string]projectionCacheEntry pendingProjection map[string]pendingProjectionEntry baselineCache map[string]sourceBaselineCacheEntry + sourceIdentities map[string]SourceIdentity mileageKeysByPrefix map[string]map[string]struct{} projectionKeysByPrefix map[string]map[string]struct{} baselineKeysByPrefix map[string]map[string]struct{} @@ -355,6 +356,7 @@ func (w *Writer) AppendWithResult(ctx context.Context, env envelope.FrameEnvelop result.SamplesSkippedMissingFields = 1 return result, nil } + env = w.withSourceIdentity(env) seenAt := w.sourceSeenAt(env) w.maybeCleanupCaches(seenAt) identity, hasSource := NewSourceIdentityFromEnvelope(env) diff --git a/go/vehicle-gateway/internal/stats/source_identity_catalog.go b/go/vehicle-gateway/internal/stats/source_identity_catalog.go new file mode 100644 index 00000000..8386ebc6 --- /dev/null +++ b/go/vehicle-gateway/internal/stats/source_identity_catalog.go @@ -0,0 +1,59 @@ +package stats + +import ( + "context" + "strings" + + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +// ReloadSourceIdentities aligns live JT808 candidates with the durable platform +// keys used by backfill and daily statistics. A snapshot avoids a database +// lookup per frame; a failed refresh leaves the last successful snapshot intact. +func (w *Writer) ReloadSourceIdentities(ctx context.Context) (int, error) { + rows, err := w.query.QueryContext(ctx, `SELECT source_ip,COALESCE(source_code,''),COALESCE(platform_name,''),source_kind +FROM vehicle_data_source WHERE protocol='JT808'`) + if err != nil { + return 0, err + } + defer rows.Close() + next := map[string]SourceIdentity{} + for rows.Next() { + var source SourceIdentity + if err := rows.Scan(&source.SourceIP, &source.SourceCode, &source.PlatformName, &source.SourceKind); err != nil { + return 0, err + } + source.Protocol = envelope.ProtocolJT808 + source.SourceIP = NormalizeSourceIP(source.SourceIP) + source.SourceCode = strings.TrimSpace(source.SourceCode) + source.SourceKind = strings.ToUpper(strings.TrimSpace(source.SourceKind)) + if source.SourceIP != "" && ((source.SourceKind == "PLATFORM" && source.SourceCode != "") || source.SourceKind == "DIRECT") { + next[source.SourceIP] = source + } + } + if err := rows.Err(); err != nil { + return 0, err + } + w.mu.Lock() + w.sourceIdentities = next + w.mu.Unlock() + return len(next), nil +} + +func (w *Writer) withSourceIdentity(env envelope.FrameEnvelope) envelope.FrameEnvelope { + if env.Protocol != envelope.ProtocolJT808 { + return env + } + ip := NormalizeSourceIP(env.SourceEndpoint) + w.mu.Lock() + source, found := w.sourceIdentities[ip] + w.mu.Unlock() + if found { + env.SourceCode = source.SourceCode + env.SourceKind = source.SourceKind + if strings.TrimSpace(source.PlatformName) != "" { + env.PlatformName = source.PlatformName + } + } + return env +} diff --git a/go/vehicle-gateway/internal/stats/source_identity_catalog_test.go b/go/vehicle-gateway/internal/stats/source_identity_catalog_test.go new file mode 100644 index 00000000..c57a5ace --- /dev/null +++ b/go/vehicle-gateway/internal/stats/source_identity_catalog_test.go @@ -0,0 +1,86 @@ +package stats + +import ( + "context" + "database/sql/driver" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" +) + +func TestWriterResolvesForwardingIPBeforeDurableBaselineLookup(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + loc := time.FixedZone("Asia/Shanghai", 8*3600) + writer := NewWriter(db, loc) + mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnRows(sqlmock.NewRows([]string{"ip", "code", "name", "kind"}).AddRow("115.159.85.149", "g7s", "G7s", "PLATFORM")) + if _, err := writer.ReloadSourceIdentities(context.Background()); err != nil { + t.Fatal(err) + } + vin, phone, key := "LMRKH9AC7R1004120", "64341233712", "JT808:64341233712@PLATFORM:g7s" + previous := time.Date(2026, 10, 18, 23, 59, 49, 0, loc) + current := time.Date(2026, 10, 19, 12, 0, 0, 0, loc) + mock.ExpectExec("INSERT INTO vehicle_data_source").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("SELECT latest_total_mileage_km, latest_event_time FROM vehicle_daily_mileage_source").WithArgs(vin, "2026-10-19", "JT808", key). + WillReturnRows(sqlmock.NewRows([]string{"total", "time"}).AddRow(52704.4, previous)) + mock.ExpectBegin() + args := []driver.Value{vin, "2026-10-19", "JT808", key} + for len(args) < 21 { + args = append(args, sqlmock.AnyArg()) + } + args[9] = 52704.4 + args[10] = 52714.4 + args[11] = 10.0 + mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(args...).WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO vehicle_daily_mileage_source").WithArgs(vin, "2026-10-19", "JT808").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("DELETE s").WithArgs(vin, "2026-10-19", "JT808").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("DELETE FROM vehicle_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + fields := map[string]any{"jt808.location.total_mileage_km": 52714.4} + _, err = writer.AppendWithResult(context.Background(), envelope.FrameEnvelope{Protocol: envelope.ProtocolJT808, MessageID: "0x0200", VIN: vin, Phone: phone, SourceEndpoint: "115.159.85.149:12345", Fields: fields, ParsedFields: fields, EventTimeMS: current.UnixMilli(), ReceivedAtMS: current.UnixMilli(), ParseStatus: envelope.ParseOK}) + if err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestSourceIdentityRefreshRetainsSnapshotOnFailureAndDoesNotMixProtocols(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + writer := NewWriter(db, time.UTC) + mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnRows(sqlmock.NewRows([]string{"ip", "code", "name", "kind"}).AddRow("1.2.3.4", "g7s", "G7s", "PLATFORM").AddRow("2.3.4.5", "", "", "DIRECT").AddRow("3.4.5.6", "", "", "UNKNOWN")) + if n, err := writer.ReloadSourceIdentities(context.Background()); err != nil || n != 2 { + t.Fatalf("loaded=%d err=%v", n, err) + } + mock.ExpectQuery("SELECT source_ip,COALESCE").WillReturnError(errors.New("temporary database failure")) + if _, err := writer.ReloadSourceIdentities(context.Background()); err == nil { + t.Fatal("expected refresh failure") + } + for _, tc := range []struct { + protocol envelope.Protocol + endpoint, kind, code string + }{ + {envelope.ProtocolJT808, "1.2.3.4:10", "PLATFORM", "g7s"}, + {envelope.ProtocolJT808, "2.3.4.5:20", "DIRECT", ""}, + {envelope.ProtocolJT808, "3.4.5.6:30", "", ""}, + {envelope.ProtocolGB32960, "1.2.3.4:40", "", ""}, + } { + got := writer.withSourceIdentity(envelope.FrameEnvelope{Protocol: tc.protocol, SourceEndpoint: tc.endpoint}) + if got.SourceCode != tc.code || got.SourceKind != tc.kind { + t.Fatalf("unexpected source metadata: %+v", got) + } + } +} diff --git a/go/vehicle-gateway/internal/stats/source_mileage.go b/go/vehicle-gateway/internal/stats/source_mileage.go index 2fd85ece..bf87e8b4 100644 --- a/go/vehicle-gateway/internal/stats/source_mileage.go +++ b/go/vehicle-gateway/internal/stats/source_mileage.go @@ -282,25 +282,10 @@ func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface { if strings.TrimSpace(statDate) == "" { return 0, nil } - rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol)) + vins, err := PlatformSourceMileageLegacyVINs(ctx, db, statDate, protocol) if err != nil { return 0, err } - defer rows.Close() - - var vins []string - for rows.Next() { - var vin string - if err := rows.Scan(&vin); err != nil { - return 0, err - } - if strings.TrimSpace(vin) != "" { - vins = append(vins, vin) - } - } - if err := rows.Err(); err != nil { - return 0, err - } normalized := 0 for _, vin := range vins { @@ -315,6 +300,27 @@ func NormalizePlatformSourceMileageForDate(ctx context.Context, db interface { return normalized, nil } +// PlatformSourceMileageLegacyVINs lets batch callers apply their own atomic +// projection policy while sharing the exact registry/terminal alias rules. +func PlatformSourceMileageLegacyVINs(ctx context.Context, db Queryer, statDate string, protocol envelope.Protocol) ([]string, error) { + rows, err := db.QueryContext(ctx, selectPlatformSourceMileageLegacyVINSQL, statDate, string(protocol)) + if err != nil { + return nil, err + } + defer rows.Close() + var vins []string + for rows.Next() { + var vin string + if err := rows.Scan(&vin); err != nil { + return nil, err + } + if strings.TrimSpace(vin) != "" { + vins = append(vins, vin) + } + } + return vins, rows.Err() +} + func ShouldNormalizePlatformSourceMileage(sample SourceMileageSample) bool { return strings.Contains(sample.SourceKey, platformSourceKeyPrefix) } @@ -441,7 +447,20 @@ const upsertSourceMergedQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(` + up const upsertSourceMergedMaxMileageSQL = `(` + upsertSourceMergedQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)` -const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY s.first_event_time ASC, s.updated_at ASC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))` +// Prefer the newest boundary before this natural day. If no historical +// boundary exists, retain the earliest sample inside the day. Select the +// odometer and its timestamp from the same row so stale forwarding aliases +// cannot restore an older baseline after a repair or a cache refresh. +const normalizePlatformFirstOrderSQL = `CASE + WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN 0 + WHEN s.first_event_time IS NOT NULL THEN 1 + ELSE 2 END, + CASE WHEN s.first_event_time < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN s.first_event_time ELSE NULL END DESC, + s.first_event_time ASC, s.updated_at DESC` + +const normalizePlatformFirstTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_total_mileage_km ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DECIMAL(18,3))` + +const normalizePlatformFirstEventSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.first_event_time ORDER BY ` + normalizePlatformFirstOrderSQL + ` SEPARATOR ','), ',', 1) AS DATETIME)` const normalizePlatformLatestTotalSQL = `CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_total_mileage_km ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS DECIMAL(18,3))` @@ -454,7 +473,7 @@ const normalizePlatformDailySQL = `CASE ELSE ` + normalizePlatformDeltaSQL + ` END` -const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(MIN(s.first_event_time))))` +const normalizePlatformQualityWindowDaysSQL = `GREATEST(1, DATEDIFF(DATE(MAX(s.latest_event_time)), DATE(` + normalizePlatformFirstEventSQL + `)))` const normalizePlatformMaxMileageSQL = `(` + normalizePlatformQualityWindowDaysSQL + ` * ` + maxSelectedDailyMileageKMSQL + `)` @@ -537,7 +556,7 @@ SELECT SUM(s.pure_hydrogen_mileage_km) AS pure_hydrogen_mileage_km, SUM(s.sample_count) AS sample_count, SUM(s.pure_hydrogen_sample_count) AS pure_hydrogen_sample_count, - MIN(s.first_event_time) AS first_event_time, + ` + normalizePlatformFirstEventSQL + ` AS first_event_time, MAX(s.latest_event_time) AS latest_event_time, CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_active ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_active, CAST(SUBSTRING_INDEX(GROUP_CONCAT(s.latest_pure_hydrogen_mode_known ORDER BY s.latest_event_time DESC, s.updated_at DESC SEPARATOR ','), ',', 1) AS UNSIGNED) AS latest_pure_hydrogen_mode_known, @@ -548,7 +567,7 @@ SELECT CASE WHEN ` + normalizePlatformOutsideDailyRangeSQL + ` THEN 'outside_daily_range' WHEN ` + normalizePlatformDailySQL + ` = 0 AND ` + normalizePlatformDeltaSQL + ` < 0 THEN 'negative_jitter_clamped' - WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) + WHEN ` + normalizePlatformFirstEventSQL + ` < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME) THEN '` + QualityReasonHistorical + `' ELSE '` + QualityReasonCurrentDayFirst + `' END AS quality_reason @@ -611,10 +630,7 @@ ON DUPLICATE KEY UPDATE latest_total_mileage_km = ` + upsertSourceMergedLatestTotalSQL + `, daily_mileage_km = ` + upsertSourceMergedDailySQL + `, sample_count = sample_count + VALUES(sample_count), - first_event_time = CASE - WHEN first_event_time IS NULL OR VALUES(first_event_time) <= first_event_time THEN VALUES(first_event_time) - ELSE first_event_time - END, + first_event_time = ` + upsertSourceMergedFirstEventSQL + `, latest_event_time = CASE WHEN latest_event_time IS NULL OR VALUES(latest_event_time) >= latest_event_time THEN VALUES(latest_event_time) ELSE latest_event_time diff --git a/go/vehicle-gateway/internal/stats/source_mileage_test.go b/go/vehicle-gateway/internal/stats/source_mileage_test.go index d5c46b51..a0be4f84 100644 --- a/go/vehicle-gateway/internal/stats/source_mileage_test.go +++ b/go/vehicle-gateway/internal/stats/source_mileage_test.go @@ -344,7 +344,7 @@ func TestNormalizePlatformSourceMileageMergesLegacyIPKeys(t *testing.T) { "ON DUPLICATE KEY UPDATE", "sample_count = sample_count + VALUES(sample_count)", "s.quality_status IN ('" + QualityOK + "', '" + QualityNoPreviousBaseline + "')", - "WHEN MIN(s.first_event_time) < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)", + normalizePlatformFirstEventSQL + " < CAST(CONCAT(s.stat_date, ' 00:00:00') AS DATETIME)", "THEN '" + QualityInvalidDelta + "'", "ELSE '" + QualityOK + "'", "THEN 'negative_jitter_clamped'", @@ -392,6 +392,25 @@ func TestNormalizePlatformSourceMileageExcludesIncompleteLegacyTotals(t *testing } } +func TestNormalizePlatformSourceMileagePreservesTheNewerBoundaryPair(t *testing.T) { + // A stale IP-key candidate must not change the canonical baseline's time + // back to September while retaining October's first_total_mileage_km. + for _, expression := range []string{normalizePlatformFirstTotalSQL, normalizePlatformFirstEventSQL} { + if !strings.Contains(expression, "ORDER BY "+normalizePlatformFirstOrderSQL) { + t.Fatal("baseline total and timestamp must select the same legacy row") + } + } + if !strings.Contains(normalizePlatformFirstOrderSQL, "THEN s.first_event_time ELSE NULL END DESC") { + t.Fatal("prior-day boundaries must prefer the newest timestamp") + } + if !strings.Contains(normalizePlatformSourceMileageInsertSQL, "first_event_time = "+upsertSourceMergedFirstEventSQL) { + t.Fatal("legacy upsert must use the same boundary preference as its odometer") + } + if strings.Contains(normalizePlatformSourceMileageInsertSQL, "MIN(s.first_event_time)") { + t.Fatal("an old forwarding alias must not restore an older boundary") + } +} + func TestNormalizePlatformSourceMileageForDateNormalizesAndProjectsLegacyVINs(t *testing.T) { db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) if err != nil { diff --git a/vehicle-data-platform/apps/api/cmd/gzev-daily-report/main.go b/vehicle-data-platform/apps/api/cmd/gzev-daily-report/main.go new file mode 100644 index 00000000..b4d9ff95 --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/gzev-daily-report/main.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "flag" + "fmt" + "os" + "time" + + "github.com/go-sql-driver/mysql" + "lingniu/vehicle-data-platform/apps/api/internal/gzev" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "gzev-daily-report:", err) + os.Exit(1) + } +} +func run() error { + preview := flag.Bool("preview", false, "print summary without sending or recording delivery") + flag.Parse() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cfg, err := mysql.ParseDSN(os.Getenv("MYSQL_DSN")) + if err != nil { + return errors.New("invalid MYSQL_DSN") + } + cfg.ParseTime = true + cfg.Loc = time.UTC + cfg.Timeout = 10 * time.Second + cfg.ReadTimeout = 30 * time.Second + db, err := sql.Open("mysql", cfg.FormatDSN()) + if err != nil { + return errors.New("cannot initialize MySQL") + } + defer db.Close() + account := env("GZEV_ACCOUNT", "guangdonglingniu") + report, err := gzev.LoadDailyReport(ctx, db, account, env("GZEV_START_DATE", "2024-01-01"), time.Now()) + if err != nil { + return err + } + title, body := report.Message() + if *preview { + fmt.Println(title + "\n\n" + body) + return nil + } + sender := gzev.ServerChanFromEnv() + sent, err := gzev.DeliverDailyReport(env("GZEV_DAILY_REPORT_STATE", "/var/lib/lingniu-gzev/daily-report.json"), report.Date, func() error { return sender.SendMessage(ctx, title, body) }) + if err != nil { + return err + } + if sent { + fmt.Printf("ServerChan accepted daily report: date=%s fetched_days=%d expected_days=%d missing_dates=%d\n", report.Date, report.Fetched, report.Expected, len(report.Missing)) + } else { + fmt.Printf("Daily report already accepted; skipped duplicate: date=%s\n", report.Date) + } + return nil +} +func env(k, fallback string) string { + if v := os.Getenv(k); v != "" { + return v + } + return fallback +} diff --git a/vehicle-data-platform/apps/api/cmd/gzev-mileage/main.go b/vehicle-data-platform/apps/api/cmd/gzev-mileage/main.go new file mode 100644 index 00000000..468d14b4 --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/gzev-mileage/main.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/go-sql-driver/mysql" + "lingniu/vehicle-data-platform/apps/api/internal/gzev" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "gzev-mileage:", err) + os.Exit(1) + } +} +func run() error { + mode := flag.String("mode", "sync", "sync (MySQL), fetch (raw daily files), probe (one complete day), status (MySQL coverage), notify-test (one ServerChan test push)") + start := flag.String("from", env("GZEV_START_DATE", "2024-01-01"), "inclusive first day") + end := flag.String("to", "", "inclusive last day; defaults to yesterday") + sessionPath := flag.String("session", env("GZEV_SESSION_FILE", "/var/lib/lingniu-gzev/session.json"), "0600 JSON session file") + out := flag.String("out", "", "fetch output directory") + maxDays := flag.Int("max-days", 30, "maximum days fetched per run; oldest unfinished dates resume next run") + lookback := flag.Int("lookback", 7, "re-fetch recent complete days") + flag.Parse() + if *mode != "sync" && *mode != "fetch" && *mode != "probe" && *mode != "status" && *mode != "notify-test" { + return errors.New("unknown mode") + } + if *maxDays < 1 || *lookback < 0 { + return errors.New("invalid day limits") + } + today := gzev.ShanghaiToday(time.Now()) + if *end == "" { + *end = today.AddDate(0, 0, -1).Format("2006-01-02") + } + first, err := gzev.ParseDate(*start) + if err != nil { + return err + } + last, err := gzev.ParseDate(*end) + if err != nil { + return err + } + if first.After(last) || gzev.ValidateDate(*end, time.Now()) != nil { + return errors.New("invalid range: end must be before today in Asia/Shanghai") + } + var session gzev.Session + var sessionErr error + if *mode != "status" && *mode != "notify-test" { + session, sessionErr = gzev.ReadSession(*sessionPath) + if sessionErr != nil && *mode != "sync" { + return sessionErr + } + } + client := gzev.NewClient(session) + account := env("GZEV_ACCOUNT", "guangdonglingniu") + if len(account) > 64 { + return errors.New("account too long") + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + if *mode == "notify-test" { + if err := gzev.ServerChanFromEnv().SendTest(ctx); err != nil { + return err + } + fmt.Println("ServerChan accepted test notification") + return nil + } + if *mode == "probe" || *mode == "fetch" { + if err := client.VerifyAccount(ctx, account); err != nil { + return err + } + } + if *mode == "probe" { + client.PageSize = 500 + day, err := client.FetchDay(ctx, *end) + if err != nil { + return err + } + fmt.Printf("date=%s rows=%d pages=%d\n", day.Date, len(day.Rows), len(day.Pages)) + return nil + } + if *mode == "fetch" { + if *out == "" { + return errors.New("fetch requires --out") + } + if err := os.MkdirAll(*out, 0700); err != nil { + return err + } + count := 0 + for day := first; !day.After(last) && count < *maxDays; day = day.AddDate(0, 0, 1) { + date := day.Format("2006-01-02") + path := filepath.Join(*out, date+".json") + if _, err := os.Stat(path); err == nil { + continue + } else if !os.IsNotExist(err) { + return err + } + result, err := client.FetchDay(ctx, date) + if err != nil { + return err + } + raw, err := json.Marshal(result) + if err != nil { + return err + } + tmp, err := os.CreateTemp(*out, ".gzev-*") + if err != nil { + return err + } + name := tmp.Name() + _, writeErr := tmp.Write(raw) + syncErr := tmp.Sync() + closeErr := tmp.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + os.Remove(name) + return err + } + if err := os.Rename(name, path); err != nil { + os.Remove(name) + return err + } + fmt.Printf("downloaded date=%s rows=%d pages=%d\n", date, len(result.Rows), len(result.Pages)) + count++ + } + return nil + } + dsn := os.Getenv("MYSQL_DSN") + if dsn == "" { + return errors.New("MYSQL_DSN is required for sync") + } + config, err := mysql.ParseDSN(dsn) + if err != nil { + return errors.New("invalid MYSQL_DSN") + } + config.ParseTime = true + config.Loc = time.UTC + config.Timeout = 10 * time.Second + config.ReadTimeout = 60 * time.Second + config.WriteTimeout = 60 * time.Second + db, err := sql.Open("mysql", config.FormatDSN()) + if err != nil { + return errors.New("cannot initialize MySQL") + } + defer db.Close() + if *mode == "status" { + var days, records, missing int64 + var latest sql.NullString + err := db.QueryRowContext(ctx, "SELECT COUNT(*),COALESCE(SUM(row_count),0),COALESCE(SUM(missing_count),0),CAST(MAX(last_success_at) AS CHAR) FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ?", account, *start, *end).Scan(&days, &records, &missing, &latest) + if err != nil { + return err + } + return json.NewEncoder(os.Stdout).Encode(map[string]any{"account": account, "from": *start, "to": *end, "expected_days": int(last.Sub(first).Hours()/24) + 1, "fetched_days": days, "rows": records, "missing_rows": missing, "last_success_utc": latest.String}) + } + err = gzev.WithLock(ctx, db, account, func(store *gzev.Store) error { + if sessionErr != nil { + return errors.Join(gzev.ErrAuth, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send)) + } + if err := client.VerifyAccount(ctx, account); err != nil { + if errors.Is(err, gzev.ErrAuth) { + return errors.Join(err, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send)) + } + return err + } + if err := store.ResolveAuth(ctx); err != nil { + return err + } + count := 0 + var failures []error + // First collect yesterday so a long historical backfill cannot starve daily updates. + dates := []time.Time{} + recentStart := today.AddDate(0, 0, -*lookback) + for d := last; !d.Before(first) && !d.Before(recentStart); d = d.AddDate(0, 0, -1) { + dates = append(dates, d) + } + for d := first; !d.After(last); d = d.AddDate(0, 0, 1) { + if d.Before(recentStart) { + dates = append(dates, d) + } + } + for _, day := range dates { + if count >= *maxDays { + break + } + date := day.Format("2006-01-02") + recent := !day.Before(today.AddDate(0, 0, -*lookback)) + need, err := store.NeedsDay(ctx, date, recent) + if err != nil { + return err + } + if !need { + continue + } + // Read again for each day: operators can atomically replace the session during backfill. + s, err := gzev.ReadSession(*sessionPath) + if err != nil { + return errors.Join(gzev.ErrAuth, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send)) + } + changed := s.Authorization != client.Session.Authorization + client.Session = s + if changed { + if err := client.VerifyAccount(ctx, account); err != nil { + if errors.Is(err, gzev.ErrAuth) { + return errors.Join(err, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send)) + } + return err + } + } + result, err := client.FetchDay(ctx, date) + count++ + if errors.Is(err, gzev.ErrAuth) { + return errors.Join(err, store.AuthFailure(ctx, gzev.ServerChanFromEnv().Send)) + } + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + if failureErr := store.RecordFailure(ctx, date); failureErr != nil { + return failureErr + } + failures = append(failures, fmt.Errorf("%s: %w", date, err)) + continue + } + if err := store.ResolveAuth(ctx); err != nil { + return err + } + if err := store.SaveDay(ctx, result); err != nil { + if failureErr := store.RecordFailure(ctx, date); failureErr != nil { + return failureErr + } + failures = append(failures, fmt.Errorf("%s: %w", date, err)) + continue + } + fmt.Printf("stored date=%s rows=%d pages=%d\n", date, len(result.Rows), len(result.Pages)) + } + return errors.Join(failures...) + }) + return err +} +func env(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} diff --git a/vehicle-data-platform/apps/api/cmd/gzev-session-upload/main.go b/vehicle-data-platform/apps/api/cmd/gzev-session-upload/main.go new file mode 100644 index 00000000..c7e9526b --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/gzev-session-upload/main.go @@ -0,0 +1,45 @@ +package main + +import ( + "context" + "lingniu/vehicle-data-platform/apps/api/internal/gzev" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +func main() { + path := os.Getenv("GZEV_SESSION_FILE") + if path == "" { + path = "/var/lib/lingniu-gzev/session.json" + } + account := os.Getenv("GZEV_ACCOUNT") + if account == "" { + account = "guangdonglingniu" + } + handler, err := gzev.NewUploadHandler(path, account) + if err != nil { + log.Fatal("cannot initialize upload handler") + } + handler.PublicOrigin = "https://vehicle.d.lnoneos.com" + handler.RecoveryPath = path + ".recovery.json" + addr := os.Getenv("GZEV_UPLOAD_ADDR") + if addr == "" { + addr = "127.0.0.1:20312" + } + server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 40 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 8192} + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + go func() { + <-ctx.Done() + stop, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + server.Shutdown(stop) + }() + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal("upload service stopped unexpectedly") + } +} diff --git a/vehicle-data-platform/apps/api/cmd/oneos-local-fixture/main.go b/vehicle-data-platform/apps/api/cmd/oneos-local-fixture/main.go new file mode 100644 index 00000000..8cbac622 --- /dev/null +++ b/vehicle-data-platform/apps/api/cmd/oneos-local-fixture/main.go @@ -0,0 +1,310 @@ +// Command oneos-local-fixture is a disposable loopback-only OneOS simulator. +// It is NOT a OneOS implementation or an upstream deployment artifact. +package main + +import ( + "crypto/aes" + "crypto/rand" + "crypto/rsa" + "crypto/subtle" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "flag" + "fmt" + "html/template" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +const clientID = "vehicle-platform-local" +const tenantID = "local-test" +const password = "LocalOnly123!" + +type user struct { + ID, Username, Name, VIN string + Disabled, NoAccess bool +} +type session struct { + User string + ExpiresAt time.Time +} +type ticket struct { + Token string + ExpiresAt time.Time +} +type fixture struct { + mu sync.Mutex + key *rsa.PrivateKey + serviceToken, platformOrigin string + users map[string]*user + sessions map[string]session + tickets map[string]ticket +} + +func randomToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} +func response(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{"code": status, "data": data}) +} +func loopbackOrigin(value string) bool { + u, e := url.Parse(value) + if e != nil { + return false + } + ip := net.ParseIP(u.Hostname()) + return u.Scheme == "http" && ip != nil && ip.IsLoopback() && u.User == nil && u.RawQuery == "" && u.Fragment == "" && u.Path == "" +} + +func main() { + listen := flag.String("listen", "127.0.0.1:20410", "literal loopback listener") + origin := flag.String("platform-origin", "http://127.0.0.1:20400", "local platform origin") + configPath := flag.String("config-file", "", "required private JSON output for local launcher") + flag.Parse() + host, _, e := net.SplitHostPort(*listen) + if e != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() || !loopbackOrigin(*origin) || *configPath == "" { + log.Fatal("literal loopback endpoints and config-file required") + } + key, e := rsa.GenerateKey(rand.Reader, 2048) + if e != nil { + log.Fatal(e) + } + f := &fixture{key: key, serviceToken: randomToken(), platformOrigin: *origin, users: map[string]*user{"alpha": {ID: "101", Username: "alpha", Name: "本地甲组用户", VIN: "LB9A32A24R0LS1426"}, "beta": {ID: "102", Username: "beta", Name: "本地乙组用户", VIN: "LNXNEGRR7SR318212"}, "noaccess": {ID: "103", Username: "noaccess", Name: "本地未授权用户", NoAccess: true}}, sessions: map[string]session{}, tickets: map[string]ticket{}} + public, e := x509.MarshalPKIXPublicKey(&key.PublicKey) + if e != nil { + log.Fatal(e) + } + data, _ := json.Marshal(map[string]string{"baseURL": "http://" + *listen, "publicOrigin": *origin, "issuer": "oneos-local-fixture", "tenantId": tenantID, "clientId": clientID, "serviceToken": f.serviceToken, "publicKey": string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: public}))}) + file, e := os.OpenFile(*configPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if e != nil { + log.Fatal("cannot create private local config") + } + _, e = file.Write(data) + file.Close() + if e != nil { + log.Fatal(e) + } + server := &http.Server{Addr: *listen, Handler: f, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second} + log.Printf("Local fixture only: http://%s (accounts alpha/beta/noaccess; no external clients)", *listen) + log.Fatal(server.ListenAndServe()) +} + +func (f *fixture) issue(name string) (string, session) { + s := session{User: name, ExpiresAt: time.Now().Add(20 * time.Minute)} + token := randomToken() + f.sessions[token] = s + return token, s +} +func (f *fixture) current(r *http.Request) (string, *user, session, bool) { + token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + s, ok := f.sessions[token] + u := f.users[s.User] + return token, u, s, ok && u != nil && !u.Disabled && s.ExpiresAt.After(time.Now()) && r.Header.Get("ClientID") == clientID +} + +func (f *fixture) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + if r.URL.Path == "/health" { + response(w, 200, map[string]bool{"localOnly": true}) + return + } + if r.URL.Path == "/auth/code" && r.Method == "GET" { + response(w, 200, map[string]any{"captchaEnabled": false, "uuid": "", "img": ""}) + return + } + if r.URL.Path == "/auth/login" && r.Method == "POST" { + input, err := f.decryptLogin(r) + if err != nil || input["clientId"] != clientID || input["tenantId"] != tenantID || input["grantType"] != "password" || input["password"] != password || f.users[input["username"]] == nil || f.users[input["username"]].Disabled { + response(w, 401, nil) + return + } + token, s := f.issue(input["username"]) + response(w, 200, map[string]any{"access_token": token, "client_id": clientID, "expire_in": int(time.Until(s.ExpiresAt).Seconds())}) + return + } + if r.URL.Path == "/auth/inner/v1/sso/vehicle-platform/token-exchange" && r.Method == "POST" { + if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+f.serviceToken)) != 1 { + response(w, 401, nil) + return + } + var input struct { + Code string `json:"code"` + } + if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil { + response(w, 400, nil) + return + } + t, ok := f.tickets[input.Code] + delete(f.tickets, input.Code) + s, live := f.sessions[t.Token] + u := f.users[s.User] + if !ok || !live || !t.ExpiresAt.After(time.Now()) || !s.ExpiresAt.After(time.Now()) || u == nil || u.Disabled { + response(w, 401, nil) + return + } + response(w, 200, map[string]any{"accessToken": t.Token, "tokenType": "Bearer", "clientId": clientID, "expiresAt": s.ExpiresAt}) + return + } + if r.URL.Path == "/system/user/getInfo" || r.URL.Path == "/asset/vehicle-platform/scope" || r.URL.Path == "/auth/logout" { + token, u, _, ok := f.current(r) + if !ok { + response(w, 401, nil) + return + } + switch r.URL.Path { + case "/auth/logout": + if r.Method != "POST" { + response(w, 405, nil) + return + } + delete(f.sessions, token) + response(w, 200, map[string]bool{"loggedOut": true}) + case "/system/user/getInfo": + permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:monitor:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"} + if u.NoAccess { + permissions = []string{} + } + response(w, 200, map[string]any{"user": map[string]any{"userId": u.ID, "tenantId": tenantID, "userName": u.Username, "nickName": u.Name, "deptId": u.ID, "deptName": "本地测试部门", "status": "0"}, "permissions": permissions, "roles": []string{"local-business-user"}}) + case "/asset/vehicle-platform/scope": + vehicles := []map[string]any{} + if u.VIN != "" && !u.NoAccess { + vehicles = append(vehicles, map[string]any{"vin": u.VIN, "validFrom": "2026-01-01T00:00:00Z"}) + } + response(w, 200, map[string]any{"userId": u.ID, "tenantId": tenantID, "permissionVersion": "fixture-v1", "expiresAt": time.Now().Add(30 * time.Second), "vehicles": vehicles}) + } + return + } + if r.URL.Path == "/__local/control" && r.Method == "POST" { + if r.Header.Get("Authorization") != "Bearer "+f.serviceToken { + response(w, 401, nil) + return + } + var input struct{ Username, Action string } + if json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&input) != nil || f.users[input.Username] == nil { + response(w, 400, nil) + return + } + u := f.users[input.Username] + switch input.Action { + case "disable": + u.Disabled = true + case "enable": + u.Disabled = false + case "revoke-vehicles": + u.VIN = "" + case "restore-vehicles": + if u.Username == "alpha" { + u.VIN = "LB9A32A24R0LS1426" + } else if u.Username == "beta" { + u.VIN = "LNXNEGRR7SR318212" + } + default: + response(w, 400, nil) + return + } + response(w, 200, map[string]bool{"localOnly": true}) + return + } + if r.URL.Path == "/portal/login" && r.Method == "POST" { + if r.ParseForm() != nil || r.FormValue("password") != password || f.users[r.FormValue("username")] == nil { + http.Error(w, "本地测试账号或密码错误", 401) + return + } + token, _ := f.issue(r.FormValue("username")) + http.SetCookie(w, &http.Cookie{Name: "fixture_oneos", Value: token, HttpOnly: true, SameSite: http.SameSiteLaxMode, Path: "/"}) + http.Redirect(w, r, "/", 303) + return + } + if r.URL.Path == "/portal/ticket" && r.Method == "POST" { + cookie, e := r.Cookie("fixture_oneos") + if e != nil { + http.Error(w, "请先登录本地模拟 OneOS", 401) + return + } + s, ok := f.sessions[cookie.Value] + if !ok || !s.ExpiresAt.After(time.Now()) { + http.Error(w, "本地会话已失效", 401) + return + } + code := randomToken() + f.tickets[code] = ticket{Token: cookie.Value, ExpiresAt: time.Now().Add(5 * time.Second)} + http.Redirect(w, r, f.platformOrigin+"/api/v2/auth/oneos/callback?code="+url.QueryEscape(code), 303) + return + } + if r.URL.Path == "/" && r.Method == "GET" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + name := "" + if c, e := r.Cookie("fixture_oneos"); e == nil { + if s, ok := f.sessions[c.Value]; ok && s.ExpiresAt.After(time.Now()) { + name = s.User + } + } + _ = portal.Execute(w, map[string]string{"Name": name}) + return + } + response(w, 404, nil) +} + +var portal = template.Must(template.New("portal").Parse(`本地 OneOS 模拟器

本地 OneOS 模拟器

仅回环地址 · 虚构账号 · 不连接线上

{{if .Name}}

当前 OneOS 用户:{{.Name}}

{{end}}

测试账号 alpha / beta / noaccess,密码 LocalOnly123!。前两个账号分别授权一辆不同车辆;第三个没有中台入口权限。

`)) + +func (f *fixture) decryptLogin(r *http.Request) (map[string]string, error) { + wrapper, e := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key")) + if e != nil { + return nil, e + } + encoded, e := rsa.DecryptPKCS1v15(rand.Reader, f.key, wrapper) + if e != nil { + return nil, e + } + key, e := base64.StdEncoding.DecodeString(string(encoded)) + if e != nil { + return nil, e + } + raw, e := io.ReadAll(io.LimitReader(r.Body, 8193)) + if e != nil || len(raw) > 8192 { + return nil, fmt.Errorf("bad body") + } + cipher, e := base64.StdEncoding.DecodeString(string(raw)) + if e != nil || len(cipher) == 0 || len(cipher)%aes.BlockSize != 0 { + return nil, fmt.Errorf("bad cipher") + } + block, e := aes.NewCipher(key) + if e != nil { + return nil, e + } + plain := make([]byte, len(cipher)) + for i := 0; i < len(cipher); i += aes.BlockSize { + block.Decrypt(plain[i:i+aes.BlockSize], cipher[i:i+aes.BlockSize]) + } + pad := int(plain[len(plain)-1]) + if pad < 1 || pad > aes.BlockSize || pad > len(plain) { + return nil, fmt.Errorf("bad padding") + } + for _, b := range plain[len(plain)-pad:] { + if int(b) != pad { + return nil, fmt.Errorf("bad padding") + } + } + var input map[string]string + e = json.Unmarshal(plain[:len(plain)-pad], &input) + return input, e +} diff --git a/vehicle-data-platform/apps/api/cmd/platform-api/main.go b/vehicle-data-platform/apps/api/cmd/platform-api/main.go index 32b1aa9a..d40411be 100644 --- a/vehicle-data-platform/apps/api/cmd/platform-api/main.go +++ b/vehicle-data-platform/apps/api/cmd/platform-api/main.go @@ -13,6 +13,9 @@ import ( func main() { cfg := config.Load() + if err := app.ValidateLocalIsolation(cfg); err != nil { + log.Fatal(err) + } log.Printf("vehicle data platform listening on %s", cfg.HTTPAddr) if err := http.ListenAndServe(cfg.HTTPAddr, app.NewServer(cfg)); err != nil { log.Fatal(err) diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index 3ca24a99..f55705b8 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -5,10 +5,12 @@ import ( "crypto/subtle" "database/sql" "encoding/json" + "errors" "fmt" "log" "net/http" "strings" + "time" "lingniu/vehicle-data-platform/apps/api/internal/config" "lingniu/vehicle-data-platform/apps/api/internal/httpx" @@ -27,12 +29,14 @@ type tokenPrincipal struct { } type apiAuthenticator struct { - mode string - tokens []tokenPrincipal - local *authStore - oneOS *oneOSIdentityAdapter - demo *demoAuthDirectory - adapters []IdentityAdapter + mode string + tokens []tokenPrincipal + local *authStore + profile *oneOSProfileAuth + oneOS *oneOSIdentityAdapter + demo *demoAuthDirectory + localBackend bool + adapters []IdentityAdapter } func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler { @@ -62,14 +66,32 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro if err != nil { return nil, err } - oneOS, err := newOneOSIdentityAdapter(cfg) + legacyConfig := cfg + if cfg.OneOSAuthEnabled { + legacyConfig.OneOSSSOEnabled = false + } + oneOS, err := newOneOSIdentityAdapter(legacyConfig) if err != nil { return nil, err } if oneOS != nil && local == nil { return nil, fmt.Errorf("ONEOS_SSO_ENABLED requires MYSQL_DSN and the platform identity schema") } - authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS} + authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS, localBackend: cfg.OneOSLocalBackend} + if cfg.OneOSAuthEnabled { + localMock := (cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) && strings.EqualFold(cfg.DataMode, "mock") + if mode != "enforce" || (local == nil && !localMock) { + return nil, fmt.Errorf("OneOS auth requires AUTH_MODE=enforce and identity DB or explicit isolated local mock") + } + if !validOneOSOrigin(cfg.OneOSPublicOrigin, cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) { + return nil, fmt.Errorf("ONEOS_PUBLIC_ORIGIN must be a trusted HTTPS origin or explicit local loopback origin") + } + client, e := newOneOSProfileClient(cfg) + if e != nil { + return nil, e + } + authenticator.profile = &oneOSProfileAuth{localBackend: cfg.OneOSLocalBackend, vehicleQueryScope: cfg.OneOSVehicleQueryScope, devIntegration: cfg.OneOSDevIntegration, devDataProxy: cfg.OneOSDevDataProxyURL != "", client: client, store: local, origin: strings.TrimRight(cfg.OneOSPublicOrigin, "/"), issuer: cfg.OneOSProfileIssuer, tenant: cfg.OneOSProfileAllowedTenant, passwordEnabled: cfg.OneOSPasswordEnabled, scopeEnabled: cfg.OneOSVehicleScopePath != "" || cfg.OneOSVehicleQueryScope, localOnly: localMock, ssoEnabled: localMock && cfg.OneOSLoginTicketExchangePath != ""} + } if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") { authenticator.demo = newDemoAuthDirectory() } @@ -101,7 +123,7 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)} authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal}) } - if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 { + if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && authenticator.profile == nil && len(authenticator.adapters) == 0 { return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON") } return authenticator, nil @@ -109,6 +131,18 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/auth/providers" { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return + } + httpx.WriteOK(w, requestTraceID(r), map[string]any{"oneos": map[string]bool{"enabled": a.profile != nil, "devIntegration": a.profile != nil && a.profile.devIntegration, "realDataEnabled": a.profile != nil && (a.profile.devDataProxy || a.profile.localBackend), "passwordEnabled": a.profile != nil && a.profile.passwordEnabled, "authorizationReady": a.profile != nil && a.profile.scopeEnabled, "ssoEnabled": a.profile != nil && a.profile.ssoEnabled}, "local": map[string]bool{"enabled": a.local != nil}}) + return + } + if a.profile != nil && a.profile.handle(w, r) { + return + } if r.URL.Path == "/api/v2/auth/oneos/exchange" { if r.Method != http.MethodPost { httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r)) @@ -133,13 +167,79 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { a.local.login(w, r) return } + // This route resolves the caller's own provider; request parameters never select it. + if r.URL.Path == "/api/v2/auth/vehicle-scope" { + w.Header().Set("Cache-Control", "no-store") + if r.Method != http.MethodGet { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return + } + if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" && a.profile.vehicleQueryScope { + token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID") + current, err := a.profile.client.CurrentUser(r.Context(), token, clientID) + if err != nil || !a.profile.validProfile(current.Profile) { + oneOSAuthError(w, r, 401, "AUTH_REQUIRED") + return + } + p, _, err := a.profile.resolveVehicleQueryScope(r.Context(), token, clientID, current) + if err != nil { + httpx.WriteError(w, 503, "VEHICLE_SCOPE_UNAVAILABLE", "车辆权限暂时加载失败,请重试", "", requestTraceID(r)) + return + } + httpx.WriteOK(w, requestTraceID(r), struct { + platform.Principal + AuthMode string `json:"authMode"` + }{p, a.mode}) + return + } + p, ok := a.authenticate(r) + if !ok { + oneOSAuthError(w, r, 401, "AUTH_REQUIRED") + return + } + httpx.WriteOK(w, requestTraceID(r), struct { + platform.Principal + AuthMode string `json:"authMode"` + }{p, a.mode}) + return + } principal, ok := a.authenticate(r) if !ok { w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`) httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r)) return } - if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" { + if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" { + limited := principal.AuthorizationStatus == "pending" || (a.profile.devIntegration && principal.AuthorizationStatus == "asset-catalog") + identityRoute := (r.Method == http.MethodGet && (r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me")) || (r.Method == http.MethodPost && r.URL.Path == "/api/v2/auth/logout") + catalogRoute := principal.AuthorizationStatus != "pending" && r.Method == http.MethodGet && r.URL.Path == "/api/v2/auth/oneos/vehicles" && (principal.AuthorizationStatus == "asset-catalog" || (a.profile.vehicleQueryScope || a.profile.devDataProxy && principal.Role == "admin")) + action := oneOSReadAction(r) + admin := oneOSHasAdminPermission(principal.PermissionKeys) && principal.Role == "admin" + if limited && admin && !identityRoute && !catalogRoute { + httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_DATA_UNAVAILABLE", "管理员权限已开通,本地开发环境尚未连接中台业务数据源", "", requestTraceID(r)) + return + } + if (limited && !identityRoute && !catalogRoute) || (!limited && !admin && !identityRoute && !catalogRoute && (action == "" || !oneOSPermissions(principal.PermissionKeys)[action])) { + oneOSAuthError(w, r, 403, "ONEOS_PERMISSION_DENIED") + return + } + if catalogRoute { + page, err := a.profile.client.VehicleAssetPage(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID"), r.URL.Query()) + if err != nil { + w.Header().Set("Cache-Control", "no-store") + if errors.Is(err, errOneOSAssetQuery) { + httpx.WriteError(w, http.StatusBadRequest, "ONEOS_CATALOG_QUERY_INVALID", "车辆目录查询参数无效,请检查筛选条件及分页范围", "", requestTraceID(r)) + } else { + httpx.WriteError(w, http.StatusBadGateway, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 车辆目录暂不可用,请稍后重试", "", requestTraceID(r)) + } + return + } + w.Header().Set("Cache-Control", "no-store") + httpx.WriteOK(w, requestTraceID(r), map[string]any{"records": page.Records, "total": page.Total, "current": page.Current, "size": page.Size, "telemetryAvailable": false, "historyAvailable": false, "source": "oneos-asset-catalog"}) + return + } + } + if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" && r.URL.Path != "/api/v2/auth/oneos/me" { menu := requiredMenu(r) allowed := principal.CanMenu(menu) if menu == "shared" { @@ -159,8 +259,45 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r)) return } + if a.localBackend { + principal.DataSource = "production-local" + } ctx := platform.WithPrincipal(r.Context(), principal) + if strings.HasPrefix(r.URL.Path, "/api/v2/customer-mileage") || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance") { + snapshot := platform.CustomerAssignmentSnapshot{Tenant: principal.TenantRef} + if principal.AuthProvider != "oneos" && a.profile != nil { + snapshot.Tenant = a.profile.tenant + } + if principal.AuthProvider == "oneos" && (r.URL.Path == "/api/v2/customer-mileage" || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance")) && r.Method == http.MethodGet && a.profile != nil && a.profile.vehicleQueryScope { + token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID") + rows, err := a.profile.vehicleQuerySnapshot(ctx, token, clientID) + if err != nil { + httpx.WriteError(w, 503, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 客户车辆归属暂不可用,请重试", "", requestTraceID(r)) + return + } + snapshot.Live, snapshot.ObservedAt = true, time.Now() + key := sha256.Sum256([]byte(clientID + "\x00" + token)) + a.profile.scopeMu.Lock() + if entry := a.profile.queryScopes[key]; entry != nil && !entry.expires.IsZero() { + snapshot.ObservedAt = entry.expires.Add(-oneOSQueryScopeTTL) + } + a.profile.scopeMu.Unlock() + for _, row := range rows { + snapshot.Rows = append(snapshot.Rows, platform.CustomerVehicleAssignment{VIN: row.VIN, Plate: row.PlateNumber, Customer: row.CustomerName, Department: row.BusinessDepartment, ResponsiblePerson: row.BusinessResponsiblePerson}) + } + } + ctx = platform.WithCustomerAssignments(ctx, snapshot) + } r = r.WithContext(ctx) + if r.URL.Path == "/api/v2/auth/oneos/me" { + if a.profile == nil || principal.AuthProvider != "oneos" || r.Method != http.MethodGet { + oneOSAuthError(w, r, 403, "ONEOS_REQUIRED") + return + } + w.Header().Set("Cache-Control", "no-store") + httpx.WriteOK(w, requestTraceID(r), map[string]any{"username": principal.Username, "displayName": principal.Name, "tenantId": principal.TenantRef, "menuKeys": principal.MenuKeys, "authorizationStatus": principal.AuthorizationStatus}) + return + } if r.URL.Path == "/api/v2/session" { principal.VehicleCount = len(principal.VehicleVINs) httpx.WriteOK(w, requestTraceID(r), struct { @@ -174,7 +311,7 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r)) return } - if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") { + if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos" || principal.AuthProvider == "oneos-profile") { a.local.logout(r.Context(), bearerToken(r)) } httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true}) @@ -206,8 +343,14 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo if token == "" { return platform.Principal{}, false } + if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" { + return a.profile.authenticate(r, token) + } if a.local != nil { if principal, ok := a.local.authenticate(r.Context(), token); ok { + if principal.AuthProvider == "oneos-profile" || (a.profile != nil && principal.AuthProvider == "oneos") { + return platform.Principal{}, false + } return principal, true } } @@ -245,13 +388,17 @@ func bearerToken(r *http.Request) string { func requiredMenu(r *http.Request) string { path := r.URL.Path switch { + case strings.HasPrefix(path, "/api/v2/operation-compliance"): + return "operation-compliance" + case strings.HasPrefix(path, "/api/v2/customer-mileage"): + return "customer-mileage" 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", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"): return "shared" case path == "/api/v2/tracks": return "tracks" - case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage": + case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/v2/statistics/vehicles", path == "/api/v2/statistics/vehicle-options", path == "/api/v2/statistics/vehicle-resolve": return "statistics" case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters": return "shared" @@ -263,6 +410,9 @@ func requiredMenu(r *http.Request) string { } func requiredRole(r *http.Request) string { + if r.URL.Path == "/api/v2/customer-mileage/rules" && r.Method != http.MethodGet { + return "admin" + } if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") { return "admin" } @@ -293,7 +443,7 @@ func requiredRole(r *http.Request) string { switch path { case "/api/v2/auth/logout": return "viewer" - case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports": + case "/api/v2/statistics/vehicle-resolve", "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports": return "viewer" case "/api/v2/alerts/notifications/read": return "operator" diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store.go b/vehicle-data-platform/apps/api/internal/app/auth_store.go index 1896c0dd..caea817d 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -35,14 +35,14 @@ const ( var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`) var customerMenuSet = map[string]bool{ - "monitor": true, "vehicles": true, "tracks": true, "statistics": true, + "monitor": true, "vehicles": true, "tracks": true, "statistics": true, "customer-mileage": true, "operation-compliance": true, } -var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"} +var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "customer-mileage", "operation-compliance", "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, + "alerts": true, "access": true, "operations": true, "users": true, "customer-mileage": true, "operation-compliance": true, } type authUser struct { @@ -294,11 +294,9 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) return } - principal, err := s.principalForUser(r.Context(), credential.authUser) - if err != nil { - httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r)) - return - } + // Issue identity first; vehicle grants are loaded by the authenticated scope endpoint. + principal := platform.Principal{SubjectID: strconv.FormatUint(credential.ID, 10), Name: credential.DisplayName, Username: credential.Username, AuthProvider: "local", Role: credential.UserType, UserType: credential.UserType, AuthorizationStatus: "loading", MenuKeys: []string{}, VehicleVINs: []string{}} + response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r)) @@ -432,7 +430,9 @@ func (s *authStore) issueSession(r *http.Request, userID uint64, principal platf return loginResponse{}, err } principal.SessionID = sessionID - s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt) + if principal.AuthorizationStatus != "loading" { + s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt) + } return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil } diff --git a/vehicle-data-platform/apps/api/internal/app/customer_mileage_auth_test.go b/vehicle-data-platform/apps/api/internal/app/customer_mileage_auth_test.go new file mode 100644 index 00000000..b6348186 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/customer_mileage_auth_test.go @@ -0,0 +1,23 @@ +package app + +import ( + "net/http/httptest" + "testing" +) + +func TestCustomerMileageOneOSPermissionMapping(t *testing.T) { + request := httptest.NewRequest("GET", "/api/v2/customer-mileage", nil) + if requiredMenu(request) != "customer-mileage" || oneOSReadAction(request) != "vehiclePlatform:customerMileage:view" { + t.Fatal("OneOS legal permission key not mapped to route") + } + if !oneOSPermissions([]string{"*:*:*"})["vehiclePlatform:customerMileage:view"] { + t.Fatal("administrator lost customer mileage menu") + } + if oneOSPermissions([]string{"vehiclePlatform:statistics:read"})["vehiclePlatform:customerMileage:view"] { + t.Fatal("ordinary mileage implicitly authorized hydrogen") + } + request = httptest.NewRequest("PUT", "/api/v2/customer-mileage/rules", nil) + if requiredRole(request) != "admin" || oneOSReadAction(request) != "" { + t.Fatal("read permission can mutate rules") + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_admin_authority_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_admin_authority_test.go new file mode 100644 index 00000000..c97ae09e --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_admin_authority_test.go @@ -0,0 +1,113 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +func TestOneOSAdminPermissionRequiresAuthoritativeGrant(t *testing.T) { + for _, tc := range []struct { + name string + permissions []string + admin bool + }{ + {"super administrator", []string{"*:*:*"}, true}, + {"platform administrator", []string{"vehicle:platform:access", oneOSAdminPermission}, true}, + {"missing entry", []string{oneOSAdminPermission}, false}, + {"menu only", []string{"vehicle:platform:access", "vehiclePlatform:operations:view", "vehiclePlatform:users:view"}, false}, + {"no permissions", nil, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := oneOSHasAdminPermission(tc.permissions); got != tc.admin { + t.Fatalf("admin=%v want %v", got, tc.admin) + } + }) + } +} + +func TestOneOSAdminFullAccessAndRevocation(t *testing.T) { + var mode atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer admin-test-token" || r.Header.Get("Clientid") != "web" { + t.Error("upstream credentials changed") + } + if r.URL.Path != "/system/user/getInfo" { + t.Errorf("admin should not require a separate scope endpoint: %s", r.URL.Path) + w.WriteHeader(404) + return + } + permissions := []string{"vehicle:platform:access", oneOSAdminPermission} + status := "0" + switch mode.Load() { + case 1: + permissions = []string{"*:*:*"} + case 2: + permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"} + case 3: + status = "1" + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": status}, "permissions": permissions, "roles": []string{"superadmin"}}}) + })) + defer upstream.Close() + auth := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true} + handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p, ok := platform.PrincipalFromContext(r.Context()) + if !ok || p.Role != "admin" || p.UserType != "admin" || !p.CanVIN("ANY_VIN") { + t.Error("admin principal missing") + } + for _, menu := range adminMenus { + if !p.CanMenu(menu) { + t.Errorf("missing menu %s", menu) + } + } + w.WriteHeader(http.StatusNoContent) + })) + request := func(method, path string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, nil) + r.Header.Set("Authorization", "Bearer admin-test-token") + r.Header.Set("X-OneOS-Client-ID", "web") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + for _, modeValue := range []int32{0, 1} { + mode.Store(modeValue) + for _, route := range []struct{ method, path string }{ + {"GET", "/api/v2/operations/tasks"}, {"POST", "/api/v2/operations/tasks"}, + {"GET", "/api/v2/admin/users"}, {"POST", "/api/v2/admin/users"}, + {"PUT", "/api/v2/access/thresholds"}, {"POST", "/api/v2/alerts/rules"}, + {"POST", "/api/history/raw-frames/query"}, {"POST", "/api/v2/exports"}, + } { + if w := request(route.method, route.path); w.Code != 204 { + t.Fatalf("%s %s status=%d body=%s", route.method, route.path, w.Code, w.Body.String()) + } + } + } + // A dev administrator still cannot see mock business records as real data. + auth.devIntegration = true + if w := request("GET", "/api/v2/operations/tasks"); w.Code != 503 || !strings.Contains(w.Body.String(), "ONEOS_DATA_UNAVAILABLE") { + t.Fatalf("dev storage leaked: %d %s", w.Code, w.Body.String()) + } + // Revoking the full grant takes effect on the next request, even though the + // username and upstream role display name still say admin/superadmin. + mode.Store(2) + if w := request("GET", "/api/v2/operations/tasks"); w.Code != 403 { + t.Fatalf("revoked admin retained operations: %d", w.Code) + } + p, _, err := auth.resolve(context.Background(), "admin-test-token", "web") + if err != nil || p.Role != "customer" || p.CanMenu("users") || p.CanVIN("ANY_VIN") { + t.Fatalf("username elevated revoked user: %+v %v", p, err) + } + mode.Store(3) + if w := request("GET", "/api/v2/session"); w.Code != 401 { + t.Fatalf("disabled admin accepted: %d", w.Code) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_asset_page.go b/vehicle-data-platform/apps/api/internal/app/oneos_asset_page.go new file mode 100644 index 00000000..769697b9 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_asset_page.go @@ -0,0 +1,161 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +// oneOSAssetVehicle contains only asset catalog fields from the OneOS vehicle +// page. Authorization only consumes fully enumerated caller-scoped snapshots +// when ONEOS_VEHICLE_QUERY_SCOPE is explicitly enabled. +type oneOSAssetVehicle struct { + ID string `json:"id"` + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + VehicleType string `json:"vehicleType"` + Brand string `json:"brand"` + BrandName string `json:"brandName"` + Model string `json:"model"` + ModelName string `json:"modelName"` + ProvinceName string `json:"provinceName"` + CityName string `json:"cityName"` + CustomerName string `json:"customerName"` + ProjectName string `json:"projectName"` + BusinessDepartment string `json:"businessDepartment"` + BusinessResponsiblePerson string `json:"businessResponsiblePerson"` + OperationStatus string `json:"operationStatus"` + VehicleSource string `json:"vehicleSource"` +} + +func (v *oneOSAssetVehicle) UnmarshalJSON(raw []byte) error { + type fields oneOSAssetVehicle + var payload struct { + fields + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return err + } + *v = oneOSAssetVehicle(payload.fields) + id, err := oneOSIDString(payload.ID) + if err != nil { + return err + } + v.ID = id + return nil +} + +type oneOSAssetPage struct { + Records []oneOSAssetVehicle `json:"records"` + Total int64 `json:"total"` + Current int64 `json:"current"` + Size int64 `json:"size"` +} + +var errOneOSAssetQuery = errors.New("invalid OneOS asset query") + +var oneOSAssetPageFilters = map[string]bool{ + "assetCategory": true, "vin": true, "plateNumber": true, "plateNumberList": true, + "province": true, "city": true, "provinces": true, "vehicleType": true, + "brand": true, "model": true, "customerName": true, "businessDept": true, + "registeredOwnership": true, "contractCode": true, "operationOrgId": true, + "vehicleSource": true, "rentalCompany": true, "operationStatus": true, + "vehicleStatus": true, "onlineStatus": true, "licenseStatus": true, + "packingLotId": true, "provinceName": true, "cityName": true, + "commercialInsuranceStatus": true, "compulsoryInsuranceStatus": true, + "projectName": true, "insuranceStatus": true, +} + +const oneOSVehicleAssetCategory = "allOperating" + +// VehicleAssetPage faithfully reads one bounded page of the OneOS vehicle +// catalog. It neither scans all pages nor turns records into permission grants. +func (c *oneOSProfileClient) VehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values) (oneOSAssetPage, error) { + return c.vehicleAssetPage(ctx, token, clientID, filters, 50) +} + +// Larger pages are reserved for the server-side permission snapshot. +func (c *oneOSProfileClient) vehicleAssetPage(ctx context.Context, token, clientID string, filters url.Values, maxPageSize int64) (oneOSAssetPage, error) { + if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID { + return oneOSAssetPage{}, errors.New("invalid OneOS credential") + } + query := url.Values{} + pageNum, pageSize := int64(1), int64(10) + if filters != nil { + for key, values := range filters { + if key == "pageNum" || key == "pageSize" { + if len(values) != 1 || strings.TrimSpace(values[0]) == "" { + return oneOSAssetPage{}, errOneOSAssetQuery + } + continue + } + if !oneOSAssetPageFilters[key] || len(values) == 0 || len(values) > 20 { + return oneOSAssetPage{}, errOneOSAssetQuery + } + for _, value := range values { + if strings.TrimSpace(value) == "" || len(value) > 128 || strings.ContainsAny(value, "\r\n") { + return oneOSAssetPage{}, errOneOSAssetQuery + } + query.Add(key, value) + } + } + if raw := filters.Get("pageNum"); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed < 1 || parsed > 1000000 || len(filters["pageNum"]) != 1 { + return oneOSAssetPage{}, errOneOSAssetQuery + } + pageNum = parsed + } + if raw := filters.Get("pageSize"); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil || parsed < 1 || parsed > maxPageSize || len(filters["pageSize"]) != 1 { + return oneOSAssetPage{}, errOneOSAssetQuery + } + pageSize = parsed + } + } + if len(query["assetCategory"]) > 1 { + return oneOSAssetPage{}, errOneOSAssetQuery + } + // Fixed business boundary; caller filters cannot select a different category. + query.Set("assetCategory", oneOSVehicleAssetCategory) + query.Set("pageNum", strconv.FormatInt(pageNum, 10)) + query.Set("pageSize", strconv.FormatInt(pageSize, 10)) + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/asset/vehicle-info/page?"+query.Encode(), nil) + if err != nil { + return oneOSAssetPage{}, errors.New("invalid OneOS asset request") + } + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("ClientID", clientID) + request.Header.Set("Accept", "application/json") + response, err := c.client.Do(request) + if err != nil { + return oneOSAssetPage{}, errors.New("OneOS asset page unavailable") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return oneOSAssetPage{}, fmt.Errorf("OneOS asset page rejected: HTTP %d", response.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) + if err != nil || len(raw) > 1<<20 { + return oneOSAssetPage{}, errors.New("OneOS asset page too large") + } + var envelope oneOSProfileEnvelope[oneOSAssetPage] + if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 { + return oneOSAssetPage{}, errors.New("invalid OneOS asset page response") + } + page := envelope.Data + if page.Total < 0 || page.Current != pageNum || page.Size != pageSize || int64(len(page.Records)) > pageSize { + return oneOSAssetPage{}, errors.New("invalid OneOS asset page metadata") + } + return page, nil +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_asset_page_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_asset_page_test.go new file mode 100644 index 00000000..e0a21ee5 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_asset_page_test.go @@ -0,0 +1,46 @@ +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +func TestOneOSVehicleAssetPageForwardsBoundedOriginalQuery(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Method != http.MethodGet || r.URL.Path != "/asset/vehicle-info/page" { + t.Error("wrong asset endpoint") + } + if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" { + t.Error("missing OneOS credential") + } + q := r.URL.Query() + if q.Get("pageNum") != "2" || q.Get("pageSize") != "10" || q.Get("assetCategory") != "allOperating" || q.Get("vin") != "VIN123" || q.Get("businessDept") != "East" || len(q["provinces"]) != 3 || q["provinces"][0] != "410000" || q["provinces"][1] != "420000" || q["provinces"][2] != "430000" { + t.Errorf("wrong query %v", q) + } + _, _ = w.Write([]byte(`{"code":200,"data":{"records":[{"id":9123456789012345678,"vin":"VIN123","plateNumber":"A123","brand":"Brand","model":"Model","businessDepartment":"East"}],"total":11,"current":2,"size":10}}`)) + })) + defer server.Close() + client := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "client-a"} + page, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"assetCategory": {"stock"}, "pageNum": {"2"}, "pageSize": {"10"}, "vin": {"VIN123"}, "businessDept": {"East"}, "provinces": {"410000", "420000", "430000"}}) + if err != nil { + t.Fatal(err) + } + if page.Total != 11 || len(page.Records) != 1 || page.Records[0].PlateNumber != "A123" || page.Records[0].ID != "9123456789012345678" { + t.Fatalf("wrong asset page: %+v", page) + } + if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"pageSize": {"9999"}}); err == nil { + t.Fatal("unbounded page accepted") + } + if _, err := client.VehicleAssetPage(context.Background(), "upstream-token-123456", "client-a", url.Values{"arbitrary": {"1"}}); err == nil { + t.Fatal("arbitrary filter accepted") + } + if calls != 1 { + t.Fatalf("invalid filters reached OneOS: %d", calls) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_authority.go b/vehicle-data-platform/apps/api/internal/app/oneos_authority.go new file mode 100644 index 00000000..bfdde0b0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_authority.go @@ -0,0 +1,115 @@ +package app + +// OneOS permissions are revalidated for each request. Administrator authority +// comes from upstream permissions, never from a username or role display name. +import ( + "errors" + "lingniu/vehicle-data-platform/apps/api/internal/platform" + "net/http" + "strings" + "time" +) + +var errOneOSAuthority = errors.New("OneOS authorization unavailable") + +const oneOSAdminPermission = "vehiclePlatform:admin:all" + +func oneOSHasAdminPermission(values []string) bool { + var access, admin bool + for _, value := range values { + if value == "*:*:*" { + return true + } + access = access || value == "vehicle:platform:access" + admin = admin || value == oneOSAdminPermission + } + return access && admin +} + +func oneOSAdminPrincipal(u authUser, p oneOSProfile, permissions []string) platform.Principal { + return platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "admin", UserType: "admin", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: append([]string(nil), adminMenus...), AuthorizationStatus: "ready"} +} + +func oneOSPermissions(values []string) map[string]bool { + out := map[string]bool{} + for _, v := range values { + out[v] = true + } + if oneOSHasAdminPermission(values) { + out[oneOSAdminPermission] = true + for _, menu := range adminMenus { + out[oneOSMenuPermission(menu)] = true + } + for _, key := range []string{"vehicle:platform:access", "vehiclePlatform:monitor:view", "vehiclePlatform:vehicles:view", "vehiclePlatform:tracks:view", "vehiclePlatform:statistics:view", "vehiclePlatform:vehicle:read", "vehiclePlatform:track:read", "vehiclePlatform:statistics:read"} { + out[key] = true + } + } + return out +} +func oneOSReadAction(r *http.Request) string { + if r.URL.Path == "/api/v2/alerts/events" { + return "" + } + if r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me" { + return "vehicle:platform:access" + } + if requiredRole(r) != "viewer" { + return "" + } + switch requiredMenu(r) { + case "operation-compliance": + return "vehiclePlatform:operationCompliance:view" + case "customer-mileage": + return "vehiclePlatform:customerMileage:view" + case "monitor", "vehicles", "shared": + return "vehiclePlatform:vehicle:read" + case "tracks": + return "vehiclePlatform:track:read" + case "statistics": + return "vehiclePlatform:statistics:read" + } + return "" +} +func oneOSScopedPrincipal(u authUser, p oneOSProfile, permissions []string, scope oneOSVehicleScope, now time.Time) (platform.Principal, error) { + if oneOSHasAdminPermission(permissions) { + return oneOSAdminPrincipal(u, p, permissions), nil + } + grants := oneOSPermissions(permissions) + if !grants["vehicle:platform:access"] || scope.TenantID != p.TenantID || scope.UserID != p.UserID || scope.PermissionVersion == "" || !scope.ExpiresAt.After(now) || scope.ExpiresAt.Sub(now) > 60*time.Second { + return platform.Principal{}, errOneOSAuthority + } + seen := map[string]bool{} + if len(scope.Vehicles) > 100000 { + return platform.Principal{}, errOneOSAuthority + } + for _, v := range scope.Vehicles { + if len(v.VIN) == 0 || len(v.VIN) > 64 || v.VIN != strings.ToUpper(strings.TrimSpace(v.VIN)) || seen[v.VIN] || v.ValidFrom.IsZero() || (v.ValidTo != nil && !v.ValidTo.After(v.ValidFrom)) { + return platform.Principal{}, errOneOSAuthority + } + seen[v.VIN] = true + } + principal := platform.Principal{PermissionKeys: append([]string(nil), permissions...), Name: u.DisplayName, Username: p.Username, Role: "customer", UserType: "customer", TenantRef: p.TenantID, AuthProvider: "oneos", MenuKeys: []string{}} + for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} { + if grants[oneOSMenuPermission(menu)] { + principal.MenuKeys = append(principal.MenuKeys, menu) + } + } + for _, v := range scope.Vehicles { + if !v.ValidFrom.After(now) && (v.ValidTo == nil || v.ValidTo.After(now)) { + principal.VehicleVINs = append(principal.VehicleVINs, v.VIN) + principal.VehicleGrants = append(principal.VehicleGrants, platform.VehicleGrant{VIN: v.VIN, ValidFrom: v.ValidFrom, ValidTo: v.ValidTo}) + } + } + principal.VehicleCount = len(principal.VehicleVINs) + return principal, nil +} + +func oneOSMenuPermission(menu string) string { + if menu == "operation-compliance" { + menu = "operationCompliance" + } + if menu == "customer-mileage" { + menu = "customerMileage" + } + return "vehiclePlatform:" + menu + ":view" +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_dev_data_proxy.go b/vehicle-data-platform/apps/api/internal/app/oneos_dev_data_proxy.go new file mode 100644 index 00000000..181c784c --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_dev_data_proxy.go @@ -0,0 +1,159 @@ +package app + +import ( + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "regexp" + "strings" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/config" + "lingniu/vehicle-data-platform/apps/api/internal/httpx" + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +// This development bridge reads an existing platform through an SSH tunnel. +// It never forwards the browser's OneOS credentials or permits business writes. +type oneOSDevDataProxy struct { + base string + token string + client *http.Client +} + +func validateOneOSDevDataProxy(cfg config.Config) error { + if cfg.OneOSDevDataProxyURL == "" && cfg.OneOSDevDataProxyToken == "" { + return nil + } + u, err := url.Parse(cfg.OneOSDevDataProxyURL) + if err != nil || u.Host == "" || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { + return fmt.Errorf("OneOS dev data proxy requires a literal loopback HTTP origin") + } + ip := net.ParseIP(u.Hostname()) + if ip == nil || !ip.IsLoopback() || u.Port() == "" || u.Host == cfg.HTTPAddr { + return fmt.Errorf("OneOS dev data proxy requires a separate loopback tunnel port") + } + if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || cfg.DataMode != "mock" || cfg.AuthMode != "enforce" { + return fmt.Errorf("OneOS dev data proxy requires authenticated isolated dev mode") + } + if len(cfg.OneOSDevDataProxyToken) < 16 || strings.ContainsAny(cfg.OneOSDevDataProxyToken, " \t\r\n") { + return fmt.Errorf("OneOS dev data proxy requires a server-side upstream credential") + } + return nil +} + +func newOneOSDevDataProxy(cfg config.Config) *oneOSDevDataProxy { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + return &oneOSDevDataProxy{base: cfg.OneOSDevDataProxyURL, token: cfg.OneOSDevDataProxyToken, client: &http.Client{Transport: transport, Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}} +} + +var oneOSDevVehicleRead = regexp.MustCompile(`^/api/v2/vehicles/[A-Za-z0-9_-]+/(profile|telemetry/latest|source-evidence|hydrogen-evidence)$`) +var oneOSDevVehicleDiagnostic = regexp.MustCompile(`^/api/v2/operations/vehicles/[A-Za-z0-9_-]+/sources$`) + +func oneOSDevReadAllowed(r *http.Request) bool { + p := r.URL.Path + if path.Clean(p) != p || strings.Contains(r.URL.EscapedPath(), "%") { + return false + } + if r.Method == http.MethodGet || r.Method == http.MethodHead { + if oneOSDevVehicleRead.MatchString(p) || oneOSDevVehicleDiagnostic.MatchString(p) { + return true + } + switch p { + case "/api/dashboard/summary", "/api/vehicles", "/api/vehicles/resolve", "/api/vehicles/coverage", "/api/vehicles/coverage/summary", "/api/vehicles/business-filters", "/api/vehicles/detail", + "/api/vehicle-service", "/api/vehicle-service/summary", "/api/vehicle-service/overview", "/api/realtime/vehicles", "/api/realtime/locations", + "/api/history/locations", "/api/history/raw-frames", "/api/mileage/summary", "/api/mileage/daily", "/api/v2/statistics/mileage", + "/api/statistics/online-summary", "/api/statistics/online-vehicles", "/api/quality/summary", "/api/quality/issues", "/api/quality/notification-plan", + "/api/alert-events/summary", "/api/alert-events", "/api/alert-events/notification-plan", "/api/ops/health", "/api/ops/source-readiness", + "/api/v2/monitor/summary", "/api/v2/monitor/map", "/api/v2/monitor/workspace", "/api/v2/tracks", "/api/v2/metrics", + "/api/v2/history/metrics", "/api/v2/history/query", "/api/v2/history/series", "/api/v2/history/preferences", + "/api/v2/access/thresholds", "/api/v2/alerts/rules", "/api/v2/alerts/rules/library", "/api/v2/alerts/notification-config", + "/api/v2/reconciliation/summary", "/api/v2/reconciliation/assignees", "/api/map/reverse-geocode": + return true + } + } + if r.Method == http.MethodPost { + switch p { + case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", + "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/access/unresolved-identities", + "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/reconciliation/issues": + return true + } + } + return false +} + +func (p *oneOSDevDataProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + principal, ok := platform.PrincipalFromContext(r.Context()) + if !ok || principal.AuthProvider != "oneos" || principal.Role != "admin" || !oneOSHasAdminPermission(principal.PermissionKeys) { + oneOSAuthError(w, r, http.StatusForbidden, "ONEOS_DEV_ADMIN_REQUIRED") + return + } + if !oneOSDevReadAllowed(r) { + httpx.WriteError(w, http.StatusForbidden, "DEV_READ_ONLY", "当前为真实数据只读联调,此操作未开放;写操作请在测试数据源验收", "", requestTraceID(r)) + return + } + p.forward(w, r, true) +} + +func (p *oneOSDevDataProxy) forward(w http.ResponseWriter, r *http.Request, authenticated bool) { + body := http.MaxBytesReader(w, r.Body, 2<<20) + req, err := http.NewRequestWithContext(r.Context(), r.Method, p.base+r.URL.RequestURI(), body) + if err != nil { + p.unavailable(w, r) + return + } + if authenticated { + req.Header.Set("Authorization", "Bearer "+p.token) + } + for _, key := range []string{"Content-Type", "Accept"} { + if value := r.Header.Get(key); value != "" { + req.Header.Set(key, value) + } + } + response, err := p.client.Do(req) + if err != nil { + p.unavailable(w, r) + return + } + defer response.Body.Close() + if response.StatusCode >= 300 && response.StatusCode < 400 || response.StatusCode == 401 || response.StatusCode == 403 || response.StatusCode >= 500 { + p.unavailable(w, r) + return + } + payload, err := io.ReadAll(io.LimitReader(response.Body, (32<<20)+1)) + if err != nil || len(payload) > 32<<20 { + p.unavailable(w, r) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Type", response.Header.Get("Content-Type")) + w.Header().Set("X-Development-Data-Source", "ecs-readonly") + w.WriteHeader(response.StatusCode) + _, _ = w.Write(payload) +} + +func (p *oneOSDevDataProxy) unavailable(w http.ResponseWriter, r *http.Request) { + httpx.WriteError(w, http.StatusBadGateway, "DEV_DATA_UPSTREAM_UNAVAILABLE", "真实数据连接暂不可用,请检查本地 SSH 隧道和上游中台服务", "", requestTraceID(r)) +} + +// AMap SDK requests cannot carry the browser's Authorization header. Only the +// existing public map proxy's fixed, read-only paths are available here. +func (p *oneOSDevDataProxy) withMapProxy(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/_AMapService/") { + next.ServeHTTP(w, r) + return + } + suffix := strings.TrimPrefix(r.URL.Path, "/_AMapService") + if r.Method != http.MethodGet || path.Clean(r.URL.Path) != r.URL.Path || strings.Contains(r.URL.EscapedPath(), "%") || !(strings.HasPrefix(suffix, "/v3/") || strings.HasPrefix(suffix, "/v4/map/styles")) { + http.NotFound(w, r) + return + } + p.forward(w, r, false) + }) +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_dev_data_proxy_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_dev_data_proxy_test.go new file mode 100644 index 00000000..232c8b81 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_dev_data_proxy_test.go @@ -0,0 +1,129 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/config" + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +func TestOneOSDevDataProxyReadOnlyAndCredentialBoundary(t *testing.T) { + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Header.Get("Authorization") != "Bearer upstream-service-token" { + t.Error("wrong service credential") + } + for _, key := range []string{"Cookie", "X-OneOS-Client-ID", "Origin", "X-Forwarded-Host"} { + if r.Header.Get(key) != "" { + t.Errorf("forwarded untrusted header %s", key) + } + } + if r.URL.Path == "/api/v2/tracks" && r.URL.Query().Get("vin") != "VIN123" { + t.Error("query lost") + } + w.Header().Set("Set-Cookie", "upstream=secret") + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":{"real":true}}`)) + })) + defer upstream.Close() + proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "upstream-service-token"}) + admin := platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}} + request := func(method, path string, p platform.Principal) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(`{"dateFrom":"2026-09-29","dateTo":"2026-09-29"}`)) + r.Header.Set("Authorization", "Bearer browser-oneos-token") + r.Header.Set("Cookie", "private=secret") + r.Header.Set("X-OneOS-Client-ID", "web") + r.Header.Set("Origin", "http://127.0.0.1:20400") + r.Header.Set("X-Forwarded-Host", "evil.invalid") + r = r.WithContext(platform.WithPrincipal(r.Context(), p)) + w := httptest.NewRecorder() + proxy.ServeHTTP(w, r) + return w + } + for _, route := range []struct{ method, path string }{{"GET", "/api/v2/monitor/workspace"}, {"GET", "/api/v2/tracks?vin=VIN123"}, {"POST", "/api/v2/statistics/mileage"}} { + w := request(route.method, route.path, admin) + if w.Code != 200 || !strings.Contains(w.Body.String(), `"real":true`) || w.Header().Get("Set-Cookie") != "" { + t.Fatalf("read failed: %d %s", w.Code, w.Body.String()) + } + } + count := calls.Load() + for _, route := range []struct{ method, path string }{{"POST", "/api/v2/alerts/rules"}, {"PUT", "/api/v2/access/thresholds"}, {"DELETE", "/api/v2/vehicles/VIN123"}, {"POST", "/api/v2/exports"}, {"POST", "/api/v2/vehicle-profiles/sync"}, {"GET", "/api/v2/admin/users"}, {"GET", "/api/v2/monitor/../admin/users"}, {"GET", "/api/v2/vehicles/VIN%2f123/profile"}, {"GET", "/unknown"}} { + if w := request(route.method, route.path, admin); w.Code != 403 { + t.Errorf("unexpected write/unknown access %s: %d", route.path, w.Code) + } + } + for _, p := range []platform.Principal{{Role: "customer", AuthProvider: "oneos"}, {Role: "admin", AuthProvider: "local", PermissionKeys: []string{"*:*:*"}}, {Role: "admin", AuthProvider: "oneos"}} { + if w := request("GET", "/api/v2/monitor/workspace", p); w.Code != 403 { + t.Error("non-authoritative admin accepted") + } + } + if calls.Load() != count { + t.Fatal("denied request reached upstream") + } +} + +func TestOneOSDevDataProxyDoesNotFollowRedirectsOrFallBack(t *testing.T) { + for _, status := range []int{302, 401, 403, 500} { + t.Run(http.StatusText(status), func(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "http://evil.invalid") + w.WriteHeader(status) + w.Write([]byte("private upstream diagnostic")) + })) + defer upstream.Close() + proxy := newOneOSDevDataProxy(config.Config{OneOSDevDataProxyURL: upstream.URL, OneOSDevDataProxyToken: "server-token-123456"}) + r := httptest.NewRequest("GET", "/api/v2/monitor/summary", nil) + r = r.WithContext(platform.WithPrincipal(r.Context(), platform.Principal{Role: "admin", AuthProvider: "oneos", PermissionKeys: []string{"*:*:*"}})) + w := httptest.NewRecorder() + proxy.ServeHTTP(w, r) + if w.Code != 502 || strings.Contains(w.Body.String(), "private upstream") || w.Header().Get("Location") != "" { + t.Fatalf("unsafe error %d %s", w.Code, w.Body.String()) + } + }) + } +} + +func TestOneOSDevDataProxyIsolationConfiguration(t *testing.T) { + base := config.Config{OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce", OneOSDevDataProxyURL: "http://127.0.0.1:20420", OneOSDevDataProxyToken: "server-token-123456", AMapWebJSKey: "public-key", AMapServiceHost: "/_AMapService"} + if err := ValidateLocalIsolation(base); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*config.Config){func(c *config.Config) { c.OneOSDevIntegration = false }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://localhost:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://192.0.2.1:20420" }, func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20400" }, func(c *config.Config) { c.OneOSDevDataProxyURL += "/api" }, func(c *config.Config) { c.OneOSDevDataProxyToken = "" }, func(c *config.Config) { c.MySQLDSN = "external" }, func(c *config.Config) { c.HistoryCleanupAutomation = true }, func(c *config.Config) { c.AMapServiceHost = "https://evil.invalid" }} { + c := base + mutate(&c) + if ValidateLocalIsolation(c) == nil { + t.Fatal("unsafe dev proxy configuration accepted") + } + } +} + +func TestOneOSRealDataAdminSessionKeepsOrdinaryUsersScoped(t *testing.T) { + var admin atomic.Bool + admin.Store(true) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + perms := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"} + if admin.Load() { + perms = []string{"*:*:*"} + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": perms}}) + })) + defer upstream.Close() + a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}, issuer: "i", tenant: "t", localOnly: true, devIntegration: true, devDataProxy: true} + r := httptest.NewRequest("GET", "/api/v2/session", nil) + p, _, err := a.resolve(r.Context(), "original-token-123456789", "web") + if err != nil || p.Role != "admin" || p.AuthorizationStatus != "ready" || !p.ReadOnly || p.DataSource != "ecs-readonly" { + t.Fatalf("real-data admin not ready: %+v %v", p, err) + } + admin.Store(false) + p, _, err = a.resolve(r.Context(), "original-token-123456789", "web") + if err != nil || p.Role != "customer" || p.AuthorizationStatus != "asset-catalog" || p.DataSource != "" { + t.Fatalf("ordinary identity elevated: %+v %v", p, err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_local_backend_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_local_backend_test.go new file mode 100644 index 00000000..af35d3fb --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_local_backend_test.go @@ -0,0 +1,64 @@ +package app + +import ( + "lingniu/vehicle-data-platform/apps/api/internal/config" + "testing" +) + +func TestOneOSLocalBackendRequiresExplicitLoopbackStores(t *testing.T) { + cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20400", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", RedisAddr: "127.0.0.1:20479", TDengineDSN: "test:test@ws(127.0.0.1:20441)/", AlertStreamMode: "disabled"} + if err := ValidateLocalIsolation(cfg); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*config.Config){ + func(c *config.Config) { c.MySQLDSN = "test:test@tcp(prod.example:3306)/vehicle" }, + func(c *config.Config) { c.RedisAddr = "prod.example:6379" }, + func(c *config.Config) { c.TDengineDSN = "test:test@ws(prod.example:6041)/" }, + func(c *config.Config) { c.OneOSVehicleQueryScope = false }, + func(c *config.Config) { c.AuthMode = "disabled" }, + func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" }, + func(c *config.Config) { c.HistoryCleanupAutomation = true }, + func(c *config.Config) { c.DailyGeographyEnabled = true }, + func(c *config.Config) { c.OneOSDevDataProxyURL = "http://127.0.0.1:20420" }, + } { + candidate := cfg + mutate(&candidate) + if ValidateLocalIsolation(candidate) == nil { + t.Fatal("unsafe backend configuration accepted") + } + } +} + +func TestLocalOneOSProductionOriginAllowlist(t *testing.T) { + for _, value := range []string{"https://lnoneos.com/prod-api", "https://lnoneos.com/prod-api/"} { + if !validOneOSDevBaseURL(value) { + t.Fatalf("production origin rejected: %s", value) + } + } + for _, value := range []string{"http://lnoneos.com/prod-api", "https://lnoneos.com.evil.invalid/prod-api", "https://lnoneos.com/prod-api?x=1", "https://lnoneos.com:443/prod-api"} { + if validOneOSDevBaseURL(value) { + t.Fatalf("unsafe origin accepted: %s", value) + } + } +} + +func TestOneOSLocalBackendRESTRequiresLiteralLoopbackTunnel(t *testing.T) { + cfg := config.Config{OneOSLocalBackend: true, OneOSDevIntegration: true, OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSPublicOrigin: "http://127.0.0.1:20301", HTTPAddr: "127.0.0.1:20400", DataMode: "production", AuthMode: "enforce", MySQLDSN: "test:test@tcp(127.0.0.1:20430)/vehicle", TDengineDriver: "taosRestful", TDengineDSN: "test:test@http(127.0.0.1:20441)/", AlertStreamMode: "disabled"} + if err := ValidateLocalIsolation(cfg); err != nil { + t.Fatal(err) + } + for _, dsn := range []string{ + "test:test@http(prod.example:6041)/", + "test:test@http(localhost:20441)/", + "test:test@http(0.0.0.0:20441)/", + "test:test@http(127.0.0.1:0)/", + "test:test@ws(127.0.0.1:20441)/", + "invalid", + } { + candidate := cfg + candidate.TDengineDSN = dsn + if ValidateLocalIsolation(candidate) == nil { + t.Fatalf("unsafe REST tunnel accepted: %s", dsn) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_local_isolation.go b/vehicle-data-platform/apps/api/internal/app/oneos_local_isolation.go new file mode 100644 index 00000000..58549764 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_local_isolation.go @@ -0,0 +1,129 @@ +package app + +import ( + "fmt" + "net" + "net/http" + "strings" + + "github.com/go-sql-driver/mysql" + "github.com/taosdata/driver-go/v3/taosRestful" + + "lingniu/vehicle-data-platform/apps/api/internal/config" +) + +// ValidateLocalIsolation runs before constructing any database, external client, +// scheduler or listener. It is an opt-in development boundary, not an auth bypass. +func validOneOSDevBaseURL(value string) bool { + return value == "https://dev-oneos.lnh2e.com/prod-api" || value == "https://dev-oneos.lnh2e.com/prod-api/" || value == "https://lnoneos.com/prod-api" || value == "https://lnoneos.com/prod-api/" +} + +func ValidateLocalIsolation(cfg config.Config) error { + if err := validateOneOSDevDataProxy(cfg); err != nil { + return err + } + if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration { + return fmt.Errorf("ONEOS_LOCAL_ONLY and ONEOS_DEV_INTEGRATION are mutually exclusive") + } + if cfg.OneOSLocalBackend && !cfg.OneOSDevIntegration { + return fmt.Errorf("local backend requires dev integration") + } + if !cfg.OneOSLocalOnly && !cfg.OneOSDevIntegration { + return nil + } + if cfg.OneOSDevIntegration { + if !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) { + return fmt.Errorf("ONEOS_DEV_INTEGRATION requires an explicitly allowed OneOS HTTPS origin") + } + if !validOneOSOrigin(cfg.OneOSPublicOrigin, true) { + return fmt.Errorf("ONEOS_DEV_INTEGRATION requires a literal loopback public origin") + } + } + host, _, err := net.SplitHostPort(cfg.HTTPAddr) + if err != nil || net.ParseIP(host) == nil || !net.ParseIP(host).IsLoopback() { + return fmt.Errorf("local OneOS testing requires an explicit loopback HTTP_ADDR") + } + if cfg.OneOSLocalBackend { + if !cfg.OneOSDevIntegration || cfg.OneOSLocalOnly || !cfg.OneOSAuthEnabled || !cfg.OneOSVehicleQueryScope || cfg.DataMode != "production" || cfg.AuthMode != "enforce" || cfg.OneOSDevDataProxyURL != "" { + return fmt.Errorf("local backend requires dev OneOS, vehicle-query scope, production store and enforced authentication") + } + db, err := mysql.ParseDSN(cfg.MySQLDSN) + if err != nil || db.Net != "tcp" || !loopbackAddress(db.Addr) { + return fmt.Errorf("local backend requires a loopback MySQL tunnel") + } + if cfg.RedisAddr != "" && !loopbackAddress(cfg.RedisAddr) { + return fmt.Errorf("local backend requires a loopback Redis tunnel") + } + if cfg.TDengineDSN != "" { + if cfg.TDengineDriver == "taosRestful" { + rest, err := taosRestful.ParseDSN(cfg.TDengineDSN) + if err != nil || rest.Net != "http" || rest.Port < 1 || rest.Port > 65535 || !loopbackAddress(net.JoinHostPort(rest.Addr, fmt.Sprint(rest.Port))) { + return fmt.Errorf("local backend requires a loopback TDengine REST tunnel") + } + } else { + if cfg.TDengineDriver != "" && cfg.TDengineDriver != "taosWS" { + return fmt.Errorf("local backend requires a supported TDengine driver") + } + marker := strings.LastIndex(cfg.TDengineDSN, "@ws(") + if marker < 0 { + return fmt.Errorf("local backend requires a TDengine websocket tunnel") + } + address := strings.SplitN(cfg.TDengineDSN[marker+4:], ")", 2) + if len(address) != 2 || !loopbackAddress(address[0]) { + return fmt.Errorf("local backend requires a loopback TDengine tunnel") + } + } + } + if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled || cfg.CapacityCheckBin != "" || cfg.AlertStreamMode != "disabled" || len(cfg.AlertStreamKafkaBrokers) != 0 || cfg.AlertNotificationSMSURL != "" || cfg.AlertNotificationEmailURL != "" || cfg.AlertNotificationWeComURL != "" { + return fmt.Errorf("local backend must not start production workers or notification delivery") + } + return nil + } + if cfg.DataMode != "mock" || cfg.AuthMode != "enforce" { + return fmt.Errorf("local OneOS testing requires DATA_MODE=mock and AUTH_MODE=enforce") + } + values := []string{ + cfg.MySQLDSN, cfg.RedisAddr, cfg.TDengineDSN, cfg.CapacityCheckBin, + cfg.AMapAPIKey, cfg.AMapSecurityCode, + cfg.AuthToken, cfg.AuthTokensJSON, cfg.BootstrapAdminPassword, + cfg.AlertNotificationSMSURL, cfg.AlertNotificationEmailURL, cfg.AlertNotificationWeComURL, + cfg.OneOSIntrospectionURL, + } + if cfg.OneOSDevDataProxyURL == "" && (cfg.AMapWebJSKey != "" || cfg.AMapServiceHost != "") { + return fmt.Errorf("isolated OneOS testing rejects external map configuration") + } + if cfg.OneOSDevDataProxyURL != "" && cfg.AMapServiceHost != "" && cfg.AMapServiceHost != "/_AMapService" { + return fmt.Errorf("real-data development requires the local map proxy path") + } + for _, value := range values { + if strings.TrimSpace(value) != "" { + return fmt.Errorf("local OneOS testing rejects external data, legacy auth and notification configuration") + } + } + if cfg.DailyGeographyEnabled || cfg.HistoryCleanupAutomation || cfg.OneOSSSOEnabled || + (cfg.AlertStreamMode != "" && cfg.AlertStreamMode != "disabled") || len(cfg.AlertStreamKafkaBrokers) != 0 { + return fmt.Errorf("local OneOS testing rejects external workers and legacy SSO") + } + return nil +} + +func loopbackAddress(address string) bool { + host, _, err := net.SplitHostPort(address) + ip := net.ParseIP(host) + return err == nil && ip != nil && ip.IsLoopback() +} + +func withOneOSLocalIsolationHeaders(next http.Handler, realData ...bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The local UI must not load map SDKs, analytics, APIs or images remotely. + // Existing index.html contains an inline boot/error indicator. Inline code + // remains local; every external resource and connection is still excluded. + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'") + if len(realData) > 0 && realData[0] { + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://webapi.amap.com https://jsapi-service.amap.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.amap.com https://*.autonavi.com; font-src 'self' data:; connect-src 'self' https://*.amap.com https://*.autonavi.com; worker-src 'self' blob:; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'") + } + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_local_isolation_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_local_isolation_test.go new file mode 100644 index 00000000..42f9a095 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_local_isolation_test.go @@ -0,0 +1,84 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "lingniu/vehicle-data-platform/apps/api/internal/config" +) + +func TestOneOSLocalIsolationRejectsExternalConfiguration(t *testing.T) { + base := config.Config{OneOSLocalOnly: true, HTTPAddr: "127.0.0.1:20400", DataMode: "mock", AuthMode: "enforce"} + if err := ValidateLocalIsolation(base); err != nil { + t.Fatal(err) + } + cases := map[string]func(*config.Config){ + "wildcard listen": func(c *config.Config) { c.HTTPAddr = ":20400" }, + "hostname listen": func(c *config.Config) { c.HTTPAddr = "localhost:20400" }, + "production mode": func(c *config.Config) { c.DataMode = "production" }, + "auth bypass": func(c *config.Config) { c.AuthMode = "disabled" }, + "database": func(c *config.Config) { c.MySQLDSN = "must-not-be-opened" }, + "redis": func(c *config.Config) { c.RedisAddr = "192.0.2.1:6379" }, + "tdengine": func(c *config.Config) { c.TDengineDSN = "must-not-be-opened" }, + "notification": func(c *config.Config) { c.AlertNotificationSMSURL = "https://example.invalid" }, + "map": func(c *config.Config) { c.AMapWebJSKey = "local-must-not-load-sdk" }, + "legacy credential": func(c *config.Config) { c.AuthToken = "must-not-bypass-oneos" }, + "worker": func(c *config.Config) { c.HistoryCleanupAutomation = true }, + "kafka": func(c *config.Config) { c.AlertStreamKafkaBrokers = []string{"192.0.2.1:9092"} }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + cfg := base + mutate(&cfg) + if ValidateLocalIsolation(cfg) == nil { + t.Fatal("unsafe local configuration accepted") + } + w := httptest.NewRecorder() + NewServer(cfg).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v2/session", nil)) + if w.Code != http.StatusServiceUnavailable || !strings.Contains(w.Body.String(), "LOCAL_ISOLATION_REQUIRED") { + t.Fatalf("unexpected response %d", w.Code) + } + }) + } +} + +func TestOneOSLocalBrowserCannotFetchRemoteResources(t *testing.T) { + w := httptest.NewRecorder() + withOneOSLocalIsolationHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + csp := w.Header().Get("Content-Security-Policy") + for _, requirement := range []string{"connect-src 'self'", "script-src 'self'", "frame-src 'none'", "form-action 'self'"} { + if !strings.Contains(csp, requirement) { + t.Fatalf("missing %s", requirement) + } + } +} + +func TestOneOSDevIntegrationPreservesLocalIsolation(t *testing.T) { + base := config.Config{OneOSDevIntegration: true, HTTPAddr: "127.0.0.1:20400", OneOSPublicOrigin: "http://127.0.0.1:20400", OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", DataMode: "mock", AuthMode: "enforce"} + if err := ValidateLocalIsolation(base); err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*config.Config){ + "fixture conflict": func(c *config.Config) { c.OneOSLocalOnly = true }, + "other host": func(c *config.Config) { c.OneOSProfileBaseURL = "https://evil.invalid/prod-api" }, + "other prefix": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com/other" }, + "insecure": func(c *config.Config) { c.OneOSProfileBaseURL = "http://dev-oneos.lnh2e.com/prod-api" }, + "credentials": func(c *config.Config) { c.OneOSProfileBaseURL = "https://user@dev-oneos.lnh2e.com/prod-api" }, + "port": func(c *config.Config) { c.OneOSProfileBaseURL = "https://dev-oneos.lnh2e.com:443/prod-api" }, + "nonlocal listen": func(c *config.Config) { c.HTTPAddr = "0.0.0.0:20400" }, + "nonlocal public": func(c *config.Config) { c.OneOSPublicOrigin = "https://dev-oneos.lnh2e.com" }, + "external db": func(c *config.Config) { c.MySQLDSN = "blocked" }, + "worker": func(c *config.Config) { c.HistoryCleanupAutomation = true }, + "real business": func(c *config.Config) { c.DataMode = "production" }, + } { + t.Run(name, func(t *testing.T) { + cfg := base + mutate(&cfg) + if ValidateLocalIsolation(cfg) == nil { + t.Fatal("unsafe dev integration accepted") + } + }) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_login_transport.go b/vehicle-data-platform/apps/api/internal/app/oneos_login_transport.go new file mode 100644 index 00000000..3e4da640 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_login_transport.go @@ -0,0 +1,264 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// oneOSCredential carries the real upstream login token. It is never derived +// from, or interchangeable with, the restricted profile-jump token. +type oneOSCredential struct { + AccessToken string + ClientID string + ExpiresAt time.Time +} + +type oneOSCurrentUser struct { + Profile oneOSProfile + Permissions []string + Roles []string + Status string +} + +type oneOSVehicleGrant struct { + VIN string `json:"vin"` + ValidFrom time.Time `json:"validFrom"` + ValidTo *time.Time `json:"validTo,omitempty"` +} + +type oneOSVehicleScope struct { + TenantID string `json:"tenantId"` + UserID string `json:"userId"` + PermissionVersion string `json:"permissionVersion"` + ExpiresAt time.Time `json:"expiresAt"` + Vehicles []oneOSVehicleGrant `json:"vehicles"` +} + +func validateOneOSConfiguredPath(path string) error { + if path == "" { + return nil + } + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.ContainsAny(path, "?#\\\r\n") { + return errors.New("invalid OneOS path") + } + u, err := url.Parse(path) + if err != nil || u.IsAbs() || u.Host != "" || u.RawPath != "" || u.Path != path || strings.Contains(path, "..") || strings.Contains(strings.ToLower(path), "%2f") || strings.Contains(strings.ToLower(path), "%2e") { + return errors.New("invalid OneOS path") + } + if strings.Contains(path, "profile-jump") { + return errors.New("profile-jump is not a login-token exchange") + } + return nil +} + +func (c *oneOSProfileClient) ExchangeLoginTicket(ctx context.Context, code string) (oneOSCredential, error) { + if c == nil || c.loginTicketExchangePath == "" { + return oneOSCredential{}, errors.New("OneOS login-ticket exchange is disabled") + } + if !validOneOSOpaqueToken(code) { + return oneOSCredential{}, errors.New("invalid OneOS login ticket") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + body, _ := json.Marshal(struct { + Code string `json:"code"` + }{code}) + var result struct { + AccessToken string `json:"accessToken"` + TokenType string `json:"tokenType"` + ClientID string `json:"clientId"` + ExpiresAt time.Time `json:"expiresAt"` + } + if err := c.doJSON(ctx, http.MethodPost, c.loginTicketExchangePath, body, c.loginTicketServiceToken, &result); err != nil { + return oneOSCredential{}, err + } + if result.TokenType != "Bearer" || result.ClientID == "" || result.ClientID != c.passwordClientID || !validOneOSLoginToken(result.AccessToken) || !result.ExpiresAt.After(time.Now()) { + return oneOSCredential{}, errors.New("invalid OneOS login-ticket response") + } + expiry := result.ExpiresAt + if ceiling := time.Now().Add(30 * time.Minute); expiry.After(ceiling) { + expiry = ceiling + } + return oneOSCredential{AccessToken: result.AccessToken, ClientID: result.ClientID, ExpiresAt: expiry}, nil +} + +func validOneOSLoginToken(token string) bool { + return len(token) >= 16 && len(token) <= 4096 && !strings.ContainsAny(token, "\r\n\t ") +} + +func (c *oneOSProfileClient) CurrentUser(ctx context.Context, token, clientID string) (oneOSCurrentUser, error) { + if c == nil || !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID { + return oneOSCurrentUser{}, errors.New("invalid OneOS credential") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + var raw struct { + User struct { + UserID json.RawMessage `json:"userId"` + TenantID string `json:"tenantId"` + Username string `json:"userName"` + Nickname string `json:"nickName"` + DeptID json.RawMessage `json:"deptId"` + DeptName string `json:"deptName"` + Status string `json:"status"` + } `json:"user"` + Permissions []string `json:"permissions"` + Roles []string `json:"roles"` + } + if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/getInfo", nil, token, clientID, &raw); err != nil { + return oneOSCurrentUser{}, err + } + userID, err := oneOSIDString(raw.User.UserID) + if err != nil { + return oneOSCurrentUser{}, errors.New("invalid OneOS user identity") + } + deptID, err := oneOSIDString(raw.User.DeptID) + if err != nil { + return oneOSCurrentUser{}, errors.New("invalid OneOS department identity") + } + if userID == "" || raw.User.TenantID != c.allowedTenant || raw.User.Status != "0" { + return oneOSCurrentUser{}, errors.New("OneOS user or tenant inactive") + } + return oneOSCurrentUser{Profile: oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, Permissions: raw.Permissions, Roles: raw.Roles, Status: raw.User.Status}, nil +} + +func oneOSIDString(raw json.RawMessage) (string, error) { + if len(raw) == 0 || string(raw) == "null" { + return "", nil + } + var s string + if raw[0] == '"' { + if err := json.Unmarshal(raw, &s); err != nil { + return "", err + } + return strings.TrimSpace(s), nil + } + var n json.Number + if err := json.Unmarshal(raw, &n); err != nil { + return "", err + } + if _, err := n.Int64(); err != nil { + return "", err + } + return n.String(), nil +} + +func (c *oneOSProfileClient) VehicleScope(ctx context.Context, token, clientID string) (oneOSVehicleScope, error) { + if c == nil || c.vehicleScopePath == "" { + return oneOSVehicleScope{}, errors.New("OneOS vehicle-scope endpoint is disabled") + } + if !validOneOSLoginToken(token) || clientID != c.passwordClientID { + return oneOSVehicleScope{}, errors.New("invalid OneOS credential") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + var scope oneOSVehicleScope + if err := c.doAuthenticatedJSON(ctx, http.MethodGet, c.vehicleScopePath, nil, token, clientID, &scope); err != nil { + return oneOSVehicleScope{}, err + } + if scope.TenantID != c.allowedTenant || strings.TrimSpace(scope.UserID) == "" || strings.TrimSpace(scope.PermissionVersion) == "" || !scope.ExpiresAt.After(time.Now()) || scope.ExpiresAt.After(time.Now().Add(65*time.Second)) { + return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle scope") + } + if len(scope.Vehicles) > 100000 { + return oneOSVehicleScope{}, errors.New("OneOS vehicle scope too large") + } + for _, grant := range scope.Vehicles { + if strings.TrimSpace(grant.VIN) == "" || grant.ValidFrom.IsZero() || grant.ValidFrom.After(time.Now()) || (grant.ValidTo != nil && !grant.ValidTo.After(grant.ValidFrom)) { + return oneOSVehicleScope{}, errors.New("invalid OneOS vehicle grant") + } + } + return scope, nil +} + +func (c *oneOSProfileClient) Logout(ctx context.Context, token, clientID string) error { + if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID { + return errors.New("invalid OneOS credential") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + return c.doAuthenticatedJSON(ctx, http.MethodPost, "/auth/logout", []byte("{}"), token, clientID, nil) +} + +// FetchOwnProfile demonstrates a fixed, trusted OneOS API call with the same +// upstream token. It does not proxy arbitrary paths or return extra PII. +func (c *oneOSProfileClient) FetchOwnProfile(ctx context.Context, token, clientID string) (oneOSProfile, error) { + if c == nil || !validOneOSLoginToken(token) || clientID != c.passwordClientID { + return oneOSProfile{}, errors.New("invalid OneOS credential") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + var raw struct { + User struct { + UserID json.RawMessage `json:"userId"` + TenantID string `json:"tenantId"` + Username string `json:"userName"` + Nickname string `json:"nickName"` + DeptID json.RawMessage `json:"deptId"` + DeptName string `json:"deptName"` + } `json:"user"` + } + if err := c.doAuthenticatedJSON(ctx, http.MethodGet, "/system/user/profile", nil, token, clientID, &raw); err != nil { + return oneOSProfile{}, err + } + userID, err := oneOSIDString(raw.User.UserID) + if err != nil { + return oneOSProfile{}, errors.New("invalid OneOS profile") + } + deptID, err := oneOSIDString(raw.User.DeptID) + if err != nil { + return oneOSProfile{}, errors.New("invalid OneOS profile") + } + if userID == "" || raw.User.TenantID != c.allowedTenant { + return oneOSProfile{}, errors.New("invalid OneOS profile") + } + return oneOSProfile{Issuer: c.issuer, UserID: userID, TenantID: raw.User.TenantID, Username: raw.User.Username, Nickname: raw.User.Nickname, DeptID: deptID, DeptName: raw.User.DeptName}, nil +} + +func (c *oneOSProfileClient) doAuthenticatedJSON(ctx context.Context, method, path string, body []byte, token, clientID string, target any) error { + if !validOneOSLoginToken(token) || clientID == "" || clientID != c.passwordClientID { + return errors.New("invalid OneOS credential") + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return errors.New("invalid OneOS request") + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("ClientID", clientID) + req.Header.Set("Accept", "application/json") + if method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.client.Do(req) + if err != nil { + return errors.New("OneOS API unavailable") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("OneOS API rejected request: HTTP %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, oneOSProfileMaxResponse+1)) + if err != nil || len(raw) > oneOSProfileMaxResponse { + return errors.New("invalid OneOS API response") + } + var envelope oneOSProfileEnvelope[json.RawMessage] + if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 { + return errors.New("OneOS API rejected request") + } + if target != nil { + if len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return errors.New("invalid OneOS API response") + } + if err := json.Unmarshal(envelope.Data, target); err != nil { + return errors.New("invalid OneOS API response") + } + } + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_login_transport_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_login_transport_test.go new file mode 100644 index 00000000..a7b77ad6 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_login_transport_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/config" +) + +func TestOneOSLoginTransportLocalFixture(t *testing.T) { + code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32))) + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.URL.Path != "/auth/inner/v1/sso/vehicle-platform/token-exchange" && (r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a") { + t.Errorf("missing upstream credential on %s", r.URL.Path) + } + switch r.URL.Path { + case "/auth/inner/v1/sso/vehicle-platform/token-exchange": + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) { + t.Error("bad ticket exchange request") + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": "upstream-token-123456", "tokenType": "Bearer", "clientId": "client-a", "expiresAt": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)}}) + case "/system/user/getInfo": + _, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":"42","tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"],"roles":["operator"]}}`)) + case "/asset/vehicle-platform/scope": + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"tenantId": "tenant-a", "userId": "42", "permissionVersion": "v1", "expiresAt": time.Now().Add(50 * time.Second).UTC().Format(time.RFC3339), "vehicles": []map[string]any{{"vin": "VIN123", "validFrom": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)}}}}) + case "/system/user/profile": + _, _ = w.Write([]byte(`{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","deptId":7}}}`)) + case "/auth/logout": + _, _ = w.Write([]byte(`{"code":200,"data":null}`)) + default: + t.Errorf("unexpected endpoint %s", r.URL.Path) + } + })) + defer server.Close() + client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordClientID: "client-a", OneOSLocalOnly: true, OneOSLoginTicketExchangePath: "/auth/inner/v1/sso/vehicle-platform/token-exchange", OneOSLoginTicketServiceToken: strings.Repeat("s", 32), OneOSVehicleScopePath: "/asset/vehicle-platform/scope"}) + if err != nil { + t.Fatal(err) + } + credential, err := client.ExchangeLoginTicket(context.Background(), code) + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" { + t.Fatal("incorrect upstream credential") + } + user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil { + t.Fatal(err) + } + if user.Profile.UserID != "42" || user.Profile.DeptID != "7" || len(user.Permissions) != 1 || user.Status != "0" { + t.Fatalf("wrong user: %+v", user) + } + scope, err := client.VehicleScope(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil { + t.Fatal(err) + } + if scope.UserID != "42" || len(scope.Vehicles) != 1 || scope.Vehicles[0].VIN != "VIN123" { + t.Fatalf("wrong scope: %+v", scope) + } + profile, err := client.FetchOwnProfile(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil || profile.UserID != "42" { + t.Fatalf("wrong own profile: %+v %v", profile, err) + } + if err := client.Logout(context.Background(), credential.AccessToken, credential.ClientID); err != nil { + t.Fatal(err) + } + if len(paths) != 5 { + t.Fatalf("wrong API call count: %v", paths) + } +} + +func TestOneOSLoginTransportRejectsUnsafeOriginsAndPaths(t *testing.T) { + base := config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant", OneOSLocalOnly: true, OneOSPasswordClientID: "client"} + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("remote HTTP accepted") + } + base.OneOSProfileBaseURL = "https://oneos.example" + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("remote HTTPS accepted in local-only mode") + } + base.OneOSProfileBaseURL = "http://127.0.0.1:1234" + base.OneOSLoginTicketExchangePath = "/auth/inner/v1/sso/vehicle-platform/token-exchange" + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("missing service credential accepted") + } + base.OneOSLoginTicketServiceToken = strings.Repeat("s", 31) + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("short service credential accepted") + } + base.OneOSLoginTicketServiceToken = strings.Repeat("s", 32) + base.OneOSLoginTicketExchangePath = "/auth/sso/profile-jump/exchange" + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("profile-jump endpoint accepted as login exchange") + } + base.OneOSLoginTicketExchangePath = "//attacker.example/path" + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("network path accepted") + } +} + +func TestOneOSCurrentUserRejectsDisabledAndOtherTenant(t *testing.T) { + for _, tc := range []struct{ tenant, status string }{{"other", "0"}, {"tenant-a", "1"}} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": 42, "tenantId": tc.tenant, "status": tc.status}}}) + })) + client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSLocalOnly: true, OneOSPasswordClientID: "client-a"}) + if err != nil { + t.Fatal(err) + } + if _, err := client.CurrentUser(context.Background(), "upstream-token-123456", "client-a"); err == nil { + t.Fatalf("accepted tenant=%s status=%s", tc.tenant, tc.status) + } + server.Close() + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_mileage_permissions_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_mileage_permissions_test.go new file mode 100644 index 00000000..85835d70 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_mileage_permissions_test.go @@ -0,0 +1,78 @@ +package app + +import ( + "encoding/json" + "lingniu/vehicle-data-platform/apps/api/internal/platform" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestOneOSStatisticsOnlyUserCanLoadMileageVehiclePickers(t *testing.T) { + permissions := []string{"vehicle:platform:access", "vehiclePlatform:statistics:view", "vehiclePlatform:statistics:read"} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/system/user/getInfo" { + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "18", "tenantId": "000000", "userName": "statistics-only", "status": "0"}, "permissions": permissions}}) + return + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LB9A32A24R0LS1426"}}, Total: 1, Current: 1, Size: 200}}) + })) + defer upstream.Close() + auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}} + handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore()))) + request := func(method, path, body string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer statistics-user-token") + r.Header.Set("X-OneOS-Client-ID", "web") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + for _, tc := range []struct{ method, path, body string }{ + {"GET", "/api/v2/statistics/vehicle-options?limit=20&bindingStatus=bound", ""}, + {"GET", "/api/v2/statistics/vehicles?limit=12", ""}, + {"POST", "/api/v2/statistics/vehicle-resolve", `{"keywords":["LB9A32A24R0LS1426"],"limit":200}`}, + } { + w := request(tc.method, tc.path, tc.body) + if w.Code != 200 { + t.Fatalf("%s status=%d body=%s", tc.path, w.Code, w.Body.String()) + } + var response struct { + Data struct { + Items []map[string]any `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Data.Items) == 0 { + t.Fatalf("missing authorized vehicle: %s", w.Body.String()) + } + for _, row := range response.Data.Items { + if row["vin"] != "LB9A32A24R0LS1426" { + t.Fatalf("unauthorized VIN: %+v", row) + } + for key := range row { + if key != "vin" && key != "plate" && key != "brandName" && key != "modelName" { + t.Fatalf("excess field %s", key) + } + } + } + } + for _, path := range []string{"/api/vehicles/coverage", "/api/vehicles", "/api/v2/monitor/workspace"} { + w := request("GET", path, "") + if w.Code != 403 || !strings.Contains(w.Body.String(), "查询权限") || strings.Contains(w.Body.String(), "授权已失效") { + t.Fatalf("permission error not preserved: %d %s", w.Code, w.Body.String()) + } + } + permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:view"} + if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 { + t.Fatalf("revoked read access: %d %s", w.Code, w.Body.String()) + } + permissions = []string{"vehicle:platform:access", "vehiclePlatform:statistics:read"} + if w := request("GET", "/api/v2/statistics/vehicle-options", ""); w.Code != 403 { + t.Fatalf("revoked menu access: %d %s", w.Code, w.Body.String()) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_password_client.go b/vehicle-data-platform/apps/api/internal/app/oneos_password_client.go new file mode 100644 index 00000000..25fb5fb3 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_password_client.go @@ -0,0 +1,166 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io" + "net/http" + "strings" + "time" +) + +type oneOSCaptcha struct { + Enabled bool `json:"captchaEnabled"` + UUID string `json:"uuid"` + Image string `json:"img"` +} + +type oneOSPasswordLogin struct { + Username string `json:"username"` + Password string `json:"password"` + Code string `json:"code,omitempty"` + UUID string `json:"uuid,omitempty"` +} + +func parseOneOSRSAPublicKey(value string) (*rsa.PublicKey, error) { + return parseOneOSRSAPublicKeyForMode(value, false) +} + +// The dev OneOS web bundle currently publishes a legacy 512-bit RSA key. +// Accepting it requires the separate, fixed-origin dev integration guard in +// the caller. Normal configurations retain the 2048-bit minimum. +func parseOneOSRSAPublicKeyForMode(value string, devIntegration bool) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(value)) + var der []byte + if block != nil { + der = block.Bytes + } else { + var err error + der, err = base64.StdEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil { + return nil, errors.New("invalid RSA public key encoding") + } + } + minBits := 2048 + if devIntegration { + minBits = 512 + } + if key, err := x509.ParsePKIXPublicKey(der); err == nil { + if public, ok := key.(*rsa.PublicKey); ok && public.N.BitLen() >= minBits { + return public, nil + } + } + if public, err := x509.ParsePKCS1PublicKey(der); err == nil && public.N.BitLen() >= minBits { + return public, nil + } + return nil, errors.New("unsupported RSA public key") +} + +func (c *oneOSProfileClient) FetchCaptcha(ctx context.Context) (oneOSCaptcha, error) { + if c == nil || !c.passwordEnabled { + return oneOSCaptcha{}, errors.New("OneOS password login is disabled") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + var captcha oneOSCaptcha + if err := c.doJSON(ctx, http.MethodGet, "/auth/code", nil, "", &captcha); err != nil { + return oneOSCaptcha{}, err + } + if captcha.Enabled && (captcha.UUID == "" || captcha.Image == "" || len(captcha.Image) > 50<<10) { + return oneOSCaptcha{}, errors.New("invalid OneOS captcha response") + } + return captcha, nil +} + +// AuthenticatePassword follows the OneOS browser's RSA/AES request protocol +// and returns the actual OneOS login credential for online validation. +func (c *oneOSProfileClient) AuthenticatePassword(ctx context.Context, login oneOSPasswordLogin) (oneOSCredential, error) { + if c == nil || !c.passwordEnabled { + return oneOSCredential{}, errors.New("OneOS password login is disabled") + } + if strings.TrimSpace(login.Username) == "" || login.Password == "" || len(login.Username) > 256 || len(login.Password) > 1024 { + return oneOSCredential{}, errors.New("invalid OneOS credentials") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + plain, _ := json.Marshal(struct { + ClientID string `json:"clientId"` + GrantType string `json:"grantType"` + TenantID string `json:"tenantId"` + Username string `json:"username"` + Password string `json:"password"` + Code string `json:"code,omitempty"` + UUID string `json:"uuid,omitempty"` + }{c.passwordClientID, "password", c.allowedTenant, login.Username, login.Password, login.Code, login.UUID}) + seed := make([]byte, 24) + if _, err := rand.Read(seed); err != nil { + return oneOSCredential{}, errors.New("OneOS request encryption failed") + } + key := []byte(base64.RawURLEncoding.EncodeToString(seed)) // 32 printable ASCII bytes, as in the OneOS web client. + // JSEncrypt encrypts the base64 form of the ASCII AES key with RSA PKCS#1 v1.5. + wrapped, err := rsa.EncryptPKCS1v15(rand.Reader, c.passwordPublicKey, []byte(base64.StdEncoding.EncodeToString(key))) + if err != nil { + return oneOSCredential{}, errors.New("OneOS request encryption failed") + } + ciphertext, err := oneOSAesECBEncrypt(plain, key) + if err != nil { + return oneOSCredential{}, errors.New("OneOS request encryption failed") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/auth/login", strings.NewReader(base64.StdEncoding.EncodeToString(ciphertext))) + if err != nil { + return oneOSCredential{}, errors.New("invalid OneOS login request") + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + request.Header.Set("ClientID", c.passwordClientID) + request.Header.Set("encrypt-key", base64.StdEncoding.EncodeToString(wrapped)) + response, err := c.client.Do(request) + if err != nil { + return oneOSCredential{}, errors.New("OneOS password login unavailable") + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return oneOSCredential{}, errors.New("OneOS password login rejected") + } + raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1)) + if err != nil || len(raw) > oneOSProfileMaxResponse { + return oneOSCredential{}, errors.New("invalid OneOS login response") + } + var envelope oneOSProfileEnvelope[struct { + AccessToken string `json:"access_token"` + ClientID string `json:"client_id"` + ExpireIn int64 `json:"expire_in"` + }] + if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || !validOneOSLoginToken(envelope.Data.AccessToken) || envelope.Data.ClientID != c.passwordClientID || envelope.Data.ExpireIn == 0 || envelope.Data.ExpireIn < -1 { + return oneOSCredential{}, errors.New("OneOS password login rejected") + } + // OneOS can configure indefinite timeout (-1). This local credential horizon + // stays bounded; online CurrentUser checks remain mandatory on use. + ttl := envelope.Data.ExpireIn + if ttl == -1 || ttl > 1800 { + ttl = 1800 + } + return oneOSCredential{AccessToken: envelope.Data.AccessToken, ClientID: envelope.Data.ClientID, ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second)}, nil +} + +func oneOSAesECBEncrypt(plain, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + padding := aes.BlockSize - len(plain)%aes.BlockSize + plain = append(bytes.Clone(plain), bytes.Repeat([]byte{byte(padding)}, padding)...) + result := make([]byte, len(plain)) + for offset := 0; offset < len(plain); offset += aes.BlockSize { + block.Encrypt(result[offset:offset+aes.BlockSize], plain[offset:offset+aes.BlockSize]) + } + return result, nil +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_password_client_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_password_client_test.go new file mode 100644 index 00000000..572d54d8 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_password_client_test.go @@ -0,0 +1,227 @@ +package app + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/config" +) + +func TestOneOSRSAPublicKeyLegacyDevModeIsExplicit(t *testing.T) { + legacy := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537} + legacy.N.SetBit(legacy.N, 0, 1) + der, err := x509.MarshalPKIXPublicKey(legacy) + if err != nil { + t.Fatal(err) + } + for _, encoded := range []string{ + string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})), + base64.StdEncoding.EncodeToString(der), + } { + if _, err := parseOneOSRSAPublicKey(encoded); err == nil { + t.Fatal("normal parser accepted 512-bit key") + } + parsed, err := parseOneOSRSAPublicKeyForMode(encoded, true) + if err != nil || parsed.N.BitLen() != 512 { + t.Fatalf("dev parser rejected 512-bit key: %v", err) + } + } + tooSmall := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 510), E: 65537} + tooSmall.N.SetBit(tooSmall.N, 0, 1) + smallDER, err := x509.MarshalPKIXPublicKey(tooSmall) + if err != nil { + t.Fatal(err) + } + if _, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(smallDER), true); err == nil { + t.Fatal("dev parser accepted sub-512-bit key") + } +} + +func TestOneOSPasswordLegacy512EncryptsOnlyWithGoDevOverride(t *testing.T) { + t.Setenv("GODEBUG", "") + legacyPublic := &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 511), E: 65537} + legacyPublic.N.SetBit(legacyPublic.N, 0, 1) + if _, err := rsa.EncryptPKCS1v15(rand.Reader, legacyPublic, []byte("probe")); err == nil { + t.Fatal("Go unexpectedly accepted 512-bit RSA without override") + } + t.Setenv("GODEBUG", "rsa1024min=0") + private, err := rsa.GenerateKey(rand.Reader, 512) + if err != nil { + t.Fatal(err) + } + der, err := x509.MarshalPKIXPublicKey(&private.PublicKey) + if err != nil { + t.Fatal(err) + } + public, err := parseOneOSRSAPublicKeyForMode(base64.StdEncoding.EncodeToString(der), true) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth/login" || r.Method != http.MethodPost { + t.Errorf("unexpected request %s", r.URL.Path) + return + } + wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key")) + if err != nil { + t.Error(err) + return + } + base64Key, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped) + if err != nil { + t.Error(err) + return + } + key, err := base64.StdEncoding.DecodeString(string(base64Key)) + if err != nil || len(key) != 32 { + t.Error("invalid AES key") + return + } + cipherBody, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + ciphertext, err := base64.StdEncoding.DecodeString(string(cipherBody)) + if err != nil { + t.Error(err) + return + } + block, err := aes.NewCipher(key) + if err != nil { + t.Error(err) + return + } + plain := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + plain = plain[:len(plain)-int(plain[len(plain)-1])] + var login map[string]string + if err := json.Unmarshal(plain, &login); err != nil { + t.Error(err) + return + } + if login["clientId"] != "dev-client" || login["password"] != "local-fixture-password" { + t.Error("incorrect decrypted login fields") + } + _, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"local-dev-token-123456","client_id":"dev-client","expire_in":120}}`) + })) + defer server.Close() + client := &oneOSProfileClient{baseURL: server.URL, requestTimeout: time.Second, client: &http.Client{Timeout: time.Second}, passwordEnabled: true, passwordClientID: "dev-client", passwordPublicKey: public, allowedTenant: "tenant-a"} + credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "local-fixture-password"}) + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "local-dev-token-123456" || credential.ClientID != "dev-client" { + t.Fatal("incorrect legacy credential") + } +} + +func TestOneOSPasswordUsesEncryptedLoginAndCurrentUser(t *testing.T) { + private, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + publicDER, err := x509.MarshalPKIXPublicKey(&private.PublicKey) + if err != nil { + t.Fatal(err) + } + publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER})) + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/code": + if r.Header.Get("Authorization") != "" { + t.Error("captcha request has bearer") + } + _, _ = io.WriteString(w, `{"code":200,"data":{"captchaEnabled":true,"uuid":"u","img":"image"}}`) + case "/auth/login": + wrapped, err := base64.StdEncoding.DecodeString(r.Header.Get("encrypt-key")) + if err != nil { + t.Error(err) + return + } + keyBase64, err := rsa.DecryptPKCS1v15(rand.Reader, private, wrapped) + if err != nil { + t.Error(err) + return + } + key, err := base64.StdEncoding.DecodeString(string(keyBase64)) + if err != nil { + t.Error(err) + return + } + encoded, _ := io.ReadAll(r.Body) + ciphertext, err := base64.StdEncoding.DecodeString(string(encoded)) + if err != nil { + t.Error(err) + return + } + block, err := aes.NewCipher(key) + if err != nil { + t.Error(err) + return + } + plain := make([]byte, len(ciphertext)) + for i := 0; i < len(ciphertext); i += aes.BlockSize { + block.Decrypt(plain[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) + } + plain = plain[:len(plain)-int(plain[len(plain)-1])] + var login map[string]string + if err := json.Unmarshal(plain, &login); err != nil { + t.Error(err) + return + } + if login["clientId"] != "client-a" || login["grantType"] != "password" || login["tenantId"] != "tenant-a" || login["password"] != "secret" || login["code"] != "1234" || login["uuid"] != "u" { + t.Errorf("bad login request: %v", login) + } + _, _ = io.WriteString(w, `{"code":200,"data":{"access_token":"upstream-token-123456","client_id":"client-a","expire_in":3600}}`) + case "/system/user/getInfo": + if r.Header.Get("Authorization") != "Bearer upstream-token-123456" || r.Header.Get("Clientid") != "client-a" { + t.Error("wrong current-user token") + } + _, _ = io.WriteString(w, `{"code":200,"data":{"user":{"userId":42,"tenantId":"tenant-a","userName":"jane","nickName":"Jane","deptId":7,"deptName":"East","status":"0"},"permissions":["vehicle:platform:access"]}}`) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + client, err := newOneOSProfileClient(config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL, OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant-a", OneOSProfileTimeout: 2 * time.Second, OneOSPasswordEnabled: true, OneOSPasswordClientID: "client-a", OneOSPasswordRSAPublicKey: publicPEM}) + if err != nil { + t.Fatal(err) + } + client.client = server.Client() + captcha, err := client.FetchCaptcha(context.Background()) + if err != nil || !captcha.Enabled || captcha.UUID != "u" { + t.Fatalf("captcha=%+v err=%v", captcha, err) + } + credential, err := client.AuthenticatePassword(context.Background(), oneOSPasswordLogin{Username: "jane", Password: "secret", Code: "1234", UUID: "u"}) + if err != nil { + t.Fatal(err) + } + if credential.AccessToken != "upstream-token-123456" || credential.ClientID != "client-a" || credential.ExpiresAt.After(time.Now().Add(31*time.Minute)) { + t.Fatal("bad credential") + } + user, err := client.CurrentUser(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil { + t.Fatal(err) + } + profile := user.Profile + if !bytes.Equal([]byte(profile.UserID), []byte("42")) || profile.TenantID != "tenant-a" || profile.Issuer != "issuer" || len(user.Permissions) != 1 { + t.Fatalf("bad current user: %+v", user) + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth.go b/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth.go new file mode 100644 index 00000000..22f82a70 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth.go @@ -0,0 +1,453 @@ +package app + +import ( + "context" + "crypto/sha256" + "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "github.com/go-sql-driver/mysql" + "lingniu/vehicle-data-platform/apps/api/internal/httpx" + "lingniu/vehicle-data-platform/apps/api/internal/platform" + "net" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" +) + +func validOneOSOrigin(value string, local bool) bool { + u, err := url.Parse(value) + if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" { + return false + } + if local { + ip := net.ParseIP(u.Hostname()) + return ip != nil && ip.IsLoopback() && (u.Scheme == "http" || u.Scheme == "https") + } + return u.Scheme == "https" +} + +const oneOSHandoffCookie = "oneos_handoff" + +type oneOSPendingLogin struct { + credential oneOSCredential + profile oneOSProfile + csrf string + expires time.Time +} +type oneOSProfileAuth struct { + client *oneOSProfileClient + store *authStore + origin, issuer, tenant string + passwordEnabled, ssoEnabled, scopeEnabled, localOnly bool + devIntegration bool + devDataProxy bool + localBackend, vehicleQueryScope bool + scopeMu sync.Mutex + queryScopes map[[32]byte]*oneOSQueryScopeEntry + mu sync.Mutex + pendingLogins map[[32]byte]oneOSPendingLogin + users map[string]authUser +} +type oneOSLoginResponse struct { + AccessToken string `json:"accessToken"` + ClientID string `json:"clientId"` + ExpiresAt time.Time `json:"expiresAt"` + Session platform.Principal `json:"session"` +} + +func (a *oneOSProfileAuth) cookie(w http.ResponseWriter, value string, maxAge int) { + http.SetCookie(w, &http.Cookie{Name: oneOSHandoffCookie, Value: value, Path: "/api/v2/auth/oneos", HttpOnly: true, Secure: strings.HasPrefix(a.origin, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: maxAge}) +} +func oneOSAuthError(w http.ResponseWriter, r *http.Request, status int, code string) { + w.Header().Set("Cache-Control", "no-store") + message := "OneOS 登录暂不可用或授权已失效" + if code == "ONEOS_PERMISSION_DENIED" { + message = "当前 OneOS 账号缺少此功能的查询权限,请联系管理员开通后刷新校验" + } + httpx.WriteError(w, status, code, message, "", requestTraceID(r)) +} +func (a *oneOSProfileAuth) handle(w http.ResponseWriter, r *http.Request) bool { + if r.URL.Path == "/api/v2/auth/logout" && r.Header.Get("X-OneOS-Client-ID") != "" { + if r.Method != http.MethodPost { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return true + } + if r.Header.Get("Origin") != a.origin { + oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED") + return true + } + if err := a.client.Logout(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID")); err != nil { + oneOSAuthError(w, r, 503, "ONEOS_LOGOUT_FAILED") + return true + } + httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true}) + return true + } + if r.URL.Path == "/api/v2/auth/oneos/me" || r.URL.Path == "/api/v2/auth/oneos/vehicles" { + return false + } + if !strings.HasPrefix(r.URL.Path, "/api/v2/auth/oneos/") { + return false + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Referrer-Policy", "no-referrer") + switch r.URL.Path { + case "/api/v2/auth/oneos/callback": + if r.Method != http.MethodGet { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return true + } + // Never exchange profile-jump credentials here: they are not login tokens. + var err error + if !a.ssoEnabled { + err = errOneOSAuthority + } else { + var credential oneOSCredential + credential, err = a.client.ExchangeLoginTicket(r.Context(), r.URL.Query().Get("code")) + if err == nil { + var user oneOSCurrentUser + user, err = a.client.CurrentUser(r.Context(), credential.AccessToken, credential.ClientID) + if err == nil { + err = a.stage(w, credential, user.Profile) + } + } + } + target := "/auth/oneos/complete" + if err != nil { + a.cookie(w, "", -1) + target += "?error=ONEOS_LOGIN_FAILED" + } + http.Redirect(w, r, target, http.StatusSeeOther) + case "/api/v2/auth/oneos/pending": + if r.Method != http.MethodGet { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return true + } + item, err := a.pending(r, false) + if err != nil { + oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED") + return true + } + p := item.profile + httpx.WriteOK(w, requestTraceID(r), map[string]any{"identity": map[string]string{"username": p.Username, "displayName": firstNonEmpty(p.Nickname, p.Username), "tenantId": p.TenantID, "departmentName": p.DeptName}, "csrfToken": item.csrf, "expiresAt": item.expires}) + case "/api/v2/auth/oneos/confirm", "/api/v2/auth/oneos/cancel": + if r.Method != http.MethodPost { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return true + } + if r.Header.Get("Origin") != a.origin || a.origin == "" { + oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED") + return true + } + var input struct { + CSRFToken string `json:"csrfToken"` + } + if !decodeAuthJSON(w, r, &input) { + return true + } + item, err := a.pending(r, false) + if err != nil || subtle.ConstantTimeCompare([]byte(item.csrf), []byte(input.CSRFToken)) != 1 { + oneOSAuthError(w, r, 403, "ONEOS_HANDOFF_REJECTED") + return true + } + item, err = a.pending(r, true) + if err != nil { + oneOSAuthError(w, r, 401, "ONEOS_HANDOFF_EXPIRED") + return true + } + a.cookie(w, "", -1) + if strings.HasSuffix(r.URL.Path, "/cancel") { + httpx.WriteOK(w, requestTraceID(r), map[string]bool{"cancelled": true}) + return true + } + response, err := a.login(r, item.credential) + if err != nil { + oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE") + return true + } + httpx.WriteOK(w, requestTraceID(r), response) + case "/api/v2/auth/oneos/captcha": + if r.Method != http.MethodGet { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return true + } + if !a.passwordEnabled { + oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE") + return true + } + captcha, err := a.client.FetchCaptcha(r.Context()) + if err != nil { + oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE") + return true + } + httpx.WriteOK(w, requestTraceID(r), captcha) + case "/api/v2/auth/oneos/login": + if r.Method != http.MethodPost { + oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED") + return true + } + if r.Header.Get("Origin") != a.origin || a.origin == "" { + oneOSAuthError(w, r, 403, "ONEOS_ORIGIN_REJECTED") + return true + } + if !a.passwordEnabled { + oneOSAuthError(w, r, 503, "ONEOS_PASSWORD_UNAVAILABLE") + return true + } + if !a.scopeEnabled && !a.devIntegration { + oneOSAuthError(w, r, 503, "ONEOS_AUTHORIZATION_UNAVAILABLE") + return true + } + var input oneOSPasswordLogin + if !decodeAuthJSON(w, r, &input) { + return true + } + credential, err := a.client.AuthenticatePassword(r.Context(), input) + if err != nil { + oneOSAuthError(w, r, 401, "ONEOS_LOGIN_FAILED") + return true + } + response, err := a.login(r, credential) + if err != nil { + oneOSAuthError(w, r, 403, "ONEOS_AUTHORIZATION_UNAVAILABLE") + return true + } + httpx.WriteOK(w, requestTraceID(r), response) + default: + oneOSAuthError(w, r, 404, "NOT_FOUND") + } + return true +} +func (a *oneOSProfileAuth) validProfile(p oneOSProfile) bool { + return p.Issuer == a.issuer && p.TenantID == a.tenant && p.UserID != "" && len(p.UserID) <= 96 +} +func (a *oneOSProfileAuth) stage(w http.ResponseWriter, c oneOSCredential, p oneOSProfile) error { + if !a.localOnly || !a.validProfile(p) || !c.ExpiresAt.After(time.Now()) { + return errOneOSAuthority + } + token, hash, err := randomSessionToken() + if err != nil { + return err + } + csrf, _, err := randomSessionToken() + if err != nil { + return err + } + expiry := time.Now().Add(2 * time.Minute) + if c.ExpiresAt.Before(expiry) { + expiry = c.ExpiresAt + } + a.mu.Lock() + defer a.mu.Unlock() + if a.pendingLogins == nil { + a.pendingLogins = map[[32]byte]oneOSPendingLogin{} + } + for key, item := range a.pendingLogins { + if !item.expires.After(time.Now()) { + delete(a.pendingLogins, key) + } + } + if len(a.pendingLogins) >= 1000 { + return errOneOSAuthority + } + a.pendingLogins[hash] = oneOSPendingLogin{credential: c, profile: p, csrf: csrf, expires: expiry} + a.cookie(w, token, 120) + return nil +} +func (a *oneOSProfileAuth) pending(r *http.Request, consume bool) (oneOSPendingLogin, error) { + c, err := r.Cookie(oneOSHandoffCookie) + if err != nil || len(c.Value) != 43 { + return oneOSPendingLogin{}, errOneOSAuthority + } + hash := sha256.Sum256([]byte(c.Value)) + a.mu.Lock() + defer a.mu.Unlock() + item, ok := a.pendingLogins[hash] + if !ok { + return item, errOneOSAuthority + } + if consume || !item.expires.After(time.Now()) { + delete(a.pendingLogins, hash) + } + if !item.expires.After(time.Now()) { + return item, errOneOSAuthority + } + return item, nil +} +func (a *oneOSProfileAuth) bind(ctx context.Context, p oneOSProfile) (authUser, uint64, error) { + u, id, err := a.bindAttempt(ctx, p) + var mysqlErr *mysql.MySQLError + if err != nil && errors.As(err, &mysqlErr) && (mysqlErr.Number == 1062 || mysqlErr.Number == 1213 || mysqlErr.Number == 1205) { + return a.bindAttempt(ctx, p) + } + return u, id, err +} +func (a *oneOSProfileAuth) bindAttempt(ctx context.Context, p oneOSProfile) (authUser, uint64, error) { + if !a.validProfile(p) { + return authUser{}, 0, errOneOSAuthority + } + if a.store == nil { + if !a.localOnly { + return authUser{}, 0, errOneOSAuthority + } + encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID}) + key := string(encoded) + a.mu.Lock() + defer a.mu.Unlock() + if a.users == nil { + a.users = map[string]authUser{} + } + if u, ok := a.users[key]; ok { + if u.Status != "enabled" { + return u, 0, errOneOSAuthority + } + return u, u.ID, nil + } + if len(a.users) >= 10000 { + return authUser{}, 0, errOneOSAuthority + } + hash := sha256.Sum256(encoded) + u := authUser{ID: uint64(len(a.users) + 1), Username: "oneos-" + hex.EncodeToString(hash[:24]), DisplayName: firstNonEmpty(p.Nickname, p.Username, p.UserID), Status: "enabled", AuthProvider: "oneos-profile"} + a.users[key] = u + return u, u.ID, nil + } + tx, err := a.store.db.BeginTx(ctx, nil) + if err != nil { + return authUser{}, 0, err + } + defer tx.Rollback() + var u authUser + var binding uint64 + err = tx.QueryRowContext(ctx, `SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider FROM platform_external_identity i JOIN platform_user u ON u.id=i.user_id WHERE i.issuer=? AND i.tenant_id=? AND i.external_user_id=? FOR UPDATE`, p.Issuer, p.TenantID, p.UserID).Scan(&binding, &u.ID, &u.Username, &u.DisplayName, &u.Status, &u.AuthProvider) + if errors.Is(err, sql.ErrNoRows) { + encoded, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID}) + hash := sha256.Sum256(encoded) + u.Username = "oneos-" + hex.EncodeToString(hash[:24]) + u.DisplayName = truncateUTF8(firstNonEmpty(p.Nickname, p.Username, p.UserID), 96) + u.Status = "enabled" + u.AuthProvider = "oneos-profile" + result, e := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,'','customer','enabled',?,'oneos-profile','oneos-profile','oneos-profile')`, u.Username, u.DisplayName, p.TenantID) + if e != nil { + return u, 0, e + } + id, e := result.LastInsertId() + if e != nil { + return u, 0, e + } + u.ID = uint64(id) + result, e = tx.ExecContext(ctx, `INSERT INTO platform_external_identity(issuer,tenant_id,external_user_id,user_id) VALUES(?,?,?,?)`, p.Issuer, p.TenantID, p.UserID, u.ID) + if e != nil { + return u, 0, e + } + id, e = result.LastInsertId() + if e != nil { + return u, 0, e + } + binding = uint64(id) + } else if err != nil { + return u, 0, err + } + if u.Status != "enabled" || u.AuthProvider != "oneos-profile" { + return u, 0, errOneOSAuthority + } + if err = tx.Commit(); err != nil { + return u, 0, err + } + return u, binding, nil +} +func (a *oneOSProfileAuth) resolve(ctx context.Context, token, clientID string) (platform.Principal, oneOSCurrentUser, error) { + current, err := a.client.CurrentUser(ctx, token, clientID) + if err != nil || !a.validProfile(current.Profile) { + return platform.Principal{}, current, errOneOSAuthority + } + if a.vehicleQueryScope { + return a.resolveVehicleQueryScope(ctx, token, clientID, current) + } + if oneOSHasAdminPermission(current.Permissions) { + u, _, err := a.bind(ctx, current.Profile) + if err != nil { + return platform.Principal{}, current, err + } + p := oneOSAdminPrincipal(u, current.Profile, current.Permissions) + p.SubjectID = strconv.FormatUint(u.ID, 10) + // Full authority does not make mock storage into real business data. + if a.devIntegration && !a.devDataProxy { + p.AuthorizationStatus = "asset-catalog" + } + if a.devDataProxy { + p.ReadOnly = true + p.DataSource = "ecs-readonly" + } + return p, current, nil + } + if a.devIntegration && !a.scopeEnabled { + u, _, err := a.bind(ctx, current.Profile) + if err != nil { + return platform.Principal{}, current, err + } + p := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, Role: "customer", UserType: "customer", AuthProvider: "oneos", AuthorizationStatus: "pending", MenuKeys: []string{}, VehicleVINs: []string{}} + grants := oneOSPermissions(current.Permissions) + if grants["vehicle:platform:access"] && grants["vehiclePlatform:vehicles:view"] && grants["vehiclePlatform:vehicle:read"] { + p.AuthorizationStatus = "asset-catalog" + p.PermissionKeys = append([]string(nil), current.Permissions...) + for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics"} { + if grants[oneOSMenuPermission(menu)] { + p.MenuKeys = append(p.MenuKeys, menu) + } + } + } + return p, current, nil + } + scope, err := a.client.VehicleScope(ctx, token, clientID) + if err != nil { + return platform.Principal{}, current, err + } + // Validate authorization before persisting an identity. + if _, err = oneOSScopedPrincipal(authUser{}, current.Profile, current.Permissions, scope, time.Now()); err != nil { + return platform.Principal{}, current, err + } + u, _, err := a.bind(ctx, current.Profile) + if err != nil { + return platform.Principal{}, current, err + } + p, err := oneOSScopedPrincipal(u, current.Profile, current.Permissions, scope, time.Now()) + p.SubjectID = strconv.FormatUint(u.ID, 10) + return p, current, err +} +func (a *oneOSProfileAuth) login(r *http.Request, c oneOSCredential) (oneOSLoginResponse, error) { + if c.AccessToken == "" || c.ClientID == "" || !c.ExpiresAt.After(time.Now()) { + return oneOSLoginResponse{}, errOneOSAuthority + } + if a.vehicleQueryScope { + current, err := a.client.CurrentUser(r.Context(), c.AccessToken, c.ClientID) + if err != nil || !a.validProfile(current.Profile) { + return oneOSLoginResponse{}, errOneOSAuthority + } + u, _, err := a.bind(r.Context(), current.Profile) + if err != nil { + return oneOSLoginResponse{}, err + } + a.invalidateVehicleQueryScope(c.AccessToken, c.ClientID) + principal := platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "loading", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}} + if !oneOSPermissions(current.Permissions)["vehicle:platform:access"] { + principal.AuthorizationStatus = "pending" + } + return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil + } + principal, _, err := a.resolve(r.Context(), c.AccessToken, c.ClientID) + if err != nil { + return oneOSLoginResponse{}, err + } + // Return the exact upstream token. No platform session or replacement token. + return oneOSLoginResponse{AccessToken: c.AccessToken, ClientID: c.ClientID, ExpiresAt: c.ExpiresAt, Session: principal}, nil +} +func (a *oneOSProfileAuth) authenticate(r *http.Request, token string) (platform.Principal, bool) { + p, _, err := a.resolve(r.Context(), token, r.Header.Get("X-OneOS-Client-ID")) + return p, err == nil +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth_test.go new file mode 100644 index 00000000..1cf12a97 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_profile_auth_test.go @@ -0,0 +1,374 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/json" + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestOneOSSharedTokenRevalidatesAndNeverFallsBack(t *testing.T) { + const token = "exact-upstream-token-123456789" + var revoked atomic.Bool + var count atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" { + t.Errorf("wrong credential headers") + w.WriteHeader(401) + return + } + count.Add(1) + if revoked.Load() { + w.WriteHeader(401) + return + } + var data any + switch r.URL.Path { + case "/system/user/getInfo": + data = map[string]any{"user": map[string]any{"userId": "7", "tenantId": "t", "userName": "same-name", "nickName": "Seven", "status": "0"}, "permissions": []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}} + case "/scope": + data = oneOSVehicleScope{TenantID: "t", UserID: "7", PermissionVersion: "v1", ExpiresAt: time.Now().Add(30 * time.Second), Vehicles: []oneOSVehicleGrant{{VIN: "VIN7", ValidFrom: time.Now().Add(-time.Hour)}}} + default: + w.WriteHeader(404) + return + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data}) + })) + defer server.Close() + client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", vehicleScopePath: "/scope", client: server.Client()} + auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, scopeEnabled: true} + req := httptest.NewRequest("GET", "/api/vehicles", nil) + req.Header.Set("X-OneOS-Client-ID", "web") + req.Header.Set("Authorization", "Bearer "+token) + response, err := auth.login(req, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + if response.AccessToken != token || response.ClientID != "web" || response.Session.Role != "customer" || !response.Session.CanVIN("VIN7") || response.Session.CanVIN("VIN8") { + t.Fatalf("unsafe login: %#v", response) + } + if _, ok := auth.authenticate(req, token); !ok { + t.Fatal("valid token denied") + } + if count.Load() != 4 { + t.Fatal("did not revalidate identity and scope") + } + forbidden := httptest.NewRequest("GET", "/api/v2/alerts/events", nil) + forbidden.Header = req.Header.Clone() + forbiddenRec := httptest.NewRecorder() + (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.NotFoundHandler()).ServeHTTP(forbiddenRec, forbidden) + if forbiddenRec.Code != http.StatusForbidden { + t.Fatalf("valid token permission denial must be 403: %d", forbiddenRec.Code) + } + // An upstream-denied token must not fall through to a coincidentally equal legacy token. + revoked.Store(true) + hash := sha256.Sum256([]byte(token)) + api := &apiAuthenticator{mode: "enforce", profile: auth, tokens: []tokenPrincipal{{hash: hash, principal: response.Session}}} + if _, ok := api.authenticate(req); ok { + t.Fatal("revoked token accepted") + } +} + +func TestOneOSStableBindingDoesNotMergeNamesOrReenable(t *testing.T) { + a := &oneOSProfileAuth{issuer: "i", tenant: "t", localOnly: true} + p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1", Username: "same"} + u, _, err := a.bind(context.Background(), p) + if err != nil { + t.Fatal(err) + } + p.UserID = "2" + v, _, err := a.bind(context.Background(), p) + if err != nil || u.ID == v.ID { + t.Fatal("merged names") + } + p.UserID = "1" + raw, _ := json.Marshal([]string{p.Issuer, p.TenantID, p.UserID}) + u.Status = "disabled" + a.users[string(raw)] = u + if _, _, err := a.bind(context.Background(), p); err == nil { + t.Fatal("disabled identity reenabled") + } +} +func TestOneOSPendingConsumeOriginAndExpiry(t *testing.T) { + a := &oneOSProfileAuth{issuer: "i", tenant: "t", origin: "http://127.0.0.1:20400", localOnly: true} + p := oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"} + w := httptest.NewRecorder() + if err := a.stage(w, oneOSCredential{AccessToken: "real-upstream-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}, p); err != nil { + t.Fatal(err) + } + cookie := w.Result().Cookies()[0] + if !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode { + t.Fatal("unsafe cookie") + } + req := httptest.NewRequest("GET", "/api/v2/auth/oneos/pending", nil) + req.AddCookie(cookie) + item, err := a.pending(req, false) + if err != nil { + t.Fatal(err) + } + forged := httptest.NewRequest("POST", "/api/v2/auth/oneos/confirm", strings.NewReader(`{"csrfToken":"`+item.csrf+`"}`)) + forged.AddCookie(cookie) + forged.Header.Set("Origin", "https://attacker.invalid") + out := httptest.NewRecorder() + a.handle(out, forged) + if out.Code != 403 { + t.Fatal("cross-origin confirmation allowed") + } + if _, err := a.pending(req, true); err != nil { + t.Fatal(err) + } + if _, err := a.pending(req, true); err == nil { + t.Fatal("handoff replay") + } + if strings.Contains(out.Body.String(), "real-upstream-token") { + t.Fatal("token leaked in error") + } +} +func TestOneOSPermissionAndScopeFailClosed(t *testing.T) { + now := time.Now() + p := oneOSProfile{TenantID: "t", UserID: "1"} + scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)} + wildcard, err := oneOSScopedPrincipal(authUser{}, p, []string{"*:*:*"}, scope, now) + if err != nil || wildcard.Role != "admin" || !wildcard.CanVIN("ANY_VEHICLE") || !wildcard.CanMenu("users") { + t.Fatal("OneOS super administrator did not receive platform administration") + } + for _, mutate := range []func(*oneOSVehicleScope){func(s *oneOSVehicleScope) { s.UserID = "2" }, func(s *oneOSVehicleScope) { s.TenantID = "other" }, func(s *oneOSVehicleScope) { s.ExpiresAt = now.Add(61 * time.Second) }, func(s *oneOSVehicleScope) { s.ExpiresAt = now }, func(s *oneOSVehicleScope) { s.PermissionVersion = "" }} { + s := scope + mutate(&s) + if _, err := oneOSScopedPrincipal(authUser{}, p, []string{"vehicle:platform:access"}, s, now); err == nil { + t.Fatal("invalid scope accepted") + } + } + for _, path := range []string{"/api/v2/admin/users", "/api/v2/operations/tasks", "/api/v2/exports", "/api/v2/auth/password", "/api/v2/alerts/events"} { + if oneOSReadAction(httptest.NewRequest("GET", path, nil)) != "" { + t.Fatal("unsupported route authorized", path) + } + } + if oneOSReadAction(httptest.NewRequest("DELETE", "/api/v2/vehicles/123", nil)) != "" { + t.Fatal("write authorized") + } +} + +func TestOneOSDatabaseBindingPreservesDisable(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectBegin() + mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WithArgs("i", "t", "1").WillReturnRows(sqlmock.NewRows([]string{"id", "uid", "username", "name", "status", "provider"}).AddRow(8, 9, "internal", "Name", "disabled", "oneos-profile")) + mock.ExpectRollback() + a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}} + if _, _, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}); err == nil { + t.Fatal("disabled persisted identity allowed") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} +func TestOneOSOriginPolicy(t *testing.T) { + for _, origin := range []string{"http://example.com", "https://u:p@example.com", "https://example.com/path", "https://example.com?x=1", "https://example.com#x"} { + if validOneOSOrigin(origin, false) { + t.Fatal(origin) + } + } + if !validOneOSOrigin("https://example.com", false) || !validOneOSOrigin("http://127.0.0.1:20400", true) || validOneOSOrigin("https://example.com", true) { + t.Fatal("origin policy failed") + } +} + +func TestOneOSDatabaseBindingRetriesConcurrentWinnerOnce(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + cols := []string{"id", "uid", "username", "name", "status", "provider"} + mock.ExpectBegin() + mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols)) + mock.ExpectExec("INSERT INTO platform_user").WillReturnError(&mysql.MySQLError{Number: 1062, Message: "duplicate"}) + mock.ExpectRollback() + mock.ExpectBegin() + mock.ExpectQuery("SELECT i.id,u.id,u.username,u.display_name,u.status,u.auth_provider").WillReturnRows(sqlmock.NewRows(cols).AddRow(8, 9, "internal", "Name", "enabled", "oneos-profile")) + mock.ExpectCommit() + a := &oneOSProfileAuth{issuer: "i", tenant: "t", store: &authStore{db: db}} + u, id, err := a.bind(context.Background(), oneOSProfile{Issuer: "i", TenantID: "t", UserID: "1"}) + if err != nil || u.ID != 9 || id != 8 { + t.Fatalf("did not reuse concurrent winner: %+v %d %v", u, id, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestOneOSDevPendingIdentityIsLiveAndHasNoBusinessAccess(t *testing.T) { + const token = "real-oneos-token-for-test" + var disabled atomic.Bool + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/system/user/getInfo" { + t.Errorf("pending must not call scope %s", r.URL.Path) + w.WriteHeader(404) + return + } + calls.Add(1) + if r.Header.Get("Authorization") != "Bearer "+token { + t.Error("upstream token changed") + } + status := "0" + if disabled.Load() { + status = "1" + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "nickName": "Administrator", "status": status}, "permissions": []string{}, "roles": []string{"superadmin"}}}) + })) + defer server.Close() + client := &oneOSProfileClient{baseURL: server.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: server.Client()} + auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", localOnly: true, devIntegration: true} + request := httptest.NewRequest("GET", "/api/v2/session", nil) + request.Header.Set("Authorization", "Bearer "+token) + request.Header.Set("X-OneOS-Client-ID", "web") + login, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}) + if err != nil { + t.Fatal(err) + } + if login.AccessToken != token || login.Session.AuthorizationStatus != "pending" || login.Session.Role != "customer" || login.Session.UserType != "customer" || len(login.Session.MenuKeys) != 0 || len(login.Session.VehicleVINs) != 0 || len(login.Session.PermissionKeys) != 0 { + t.Fatalf("unsafe pending identity: %+v", login.Session) + } + handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending reached business handler") })) + for _, item := range []struct { + method, path string + status int + }{{"GET", "/api/v2/session", 200}, {"GET", "/api/v2/auth/oneos/me", 200}, {"GET", "/api/vehicles", 403}, {"GET", "/api/v2/monitor/workspace", 403}, {"GET", "/api/v2/admin/users", 403}, {"POST", "/api/v2/session", 403}} { + req := httptest.NewRequest(item.method, item.path, nil) + req.Header = request.Header.Clone() + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != item.status { + t.Fatalf("%s %s status %d want %d", item.method, item.path, rec.Code, item.status) + } + } + if calls.Load() != 7 { + t.Fatal("identity not revalidated on every request", calls.Load()) + } + disabled.Store(true) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, request) + if rec.Code != 401 { + t.Fatal("disabled upstream user retained pending identity") + } + disabled.Store(false) + auth.devIntegration = false + if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil { + t.Fatal("fixture/normal mode accepted missing scope") + } + auth.devIntegration = true + auth.scopeEnabled = true + if _, err := auth.login(request, oneOSCredential{AccessToken: token, ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)}); err == nil { + t.Fatal("configured scope failure downgraded to pending") + } +} + +func TestOneOSDevAssetCatalogDoesNotGrantTelemetryOrHistory(t *testing.T) { + const token = "catalog-upstream-token" + var permitted atomic.Bool + permitted.Store(true) + var assetCalls atomic.Int32 + var catalogOffline atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("Clientid") != "web" { + t.Error("token changed") + } + var data any + switch r.URL.Path { + case "/system/user/getInfo": + permissions := []string{} + if permitted.Load() { + permissions = []string{"*:*:*"} + } + data = map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions} + case "/asset/vehicle-info/page": + assetCalls.Add(1) + if catalogOffline.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + if r.URL.Query().Get("pageNum") != "2" || r.URL.Query().Get("pageSize") != "10" || r.URL.Query().Get("assetCategory") != "allOperating" { + t.Error("catalog query changed", r.URL.RawQuery) + } + data = map[string]any{"records": []map[string]any{{"vin": "LREALASSET00000001", "plateNumber": "沪A12345", "brand": "真实品牌"}}, "total": 11, "current": 2, "size": 10} + default: + t.Errorf("unexpected upstream route %s", r.URL.Path) + w.WriteHeader(404) + return + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": data}) + })) + defer upstream.Close() + client := &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", requestTimeout: time.Second, passwordClientID: "web", client: upstream.Client()} + auth := &oneOSProfileAuth{client: client, issuer: "i", tenant: "t", devIntegration: true, localOnly: true} + api := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("catalog user reached mock business") })) + request := func(path string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("X-OneOS-Client-ID", "web") + w := httptest.NewRecorder() + api.ServeHTTP(w, r) + return w + } + session := request("/api/v2/session") + if session.Code != 200 || !strings.Contains(session.Body.String(), `"authorizationStatus":"asset-catalog"`) || !strings.Contains(session.Body.String(), `"vehicleCount":0`) { + t.Fatal("wrong catalog session", session.Body.String()) + } + list := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10") + if list.Code != 200 || !strings.Contains(list.Body.String(), "LREALASSET00000001") || !strings.Contains(list.Body.String(), `"telemetryAvailable":false`) { + t.Fatal("catalog unavailable", list.Code, list.Body.String()) + } + for _, path := range []string{"/api/vehicles", "/api/v2/monitor/workspace", "/api/v2/tracks?vin=LREALASSET00000001", "/api/v2/vehicles/LREALASSET00000001/telemetry/latest"} { + if rec := request(path); rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "ONEOS_DATA_UNAVAILABLE") { + t.Fatal("catalog leaked platform data", path, rec.Code) + } + } + for _, query := range []string{"unknown=1", "pageSize=100", "pageNum=0", "pageSize=", "pageSize=10&pageSize=20"} { + rec := request("/api/v2/auth/oneos/vehicles?" + query) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_QUERY_INVALID") || strings.Contains(rec.Body.String(), "登录") { + t.Fatal("invalid catalog query misclassified", query, rec.Code, rec.Body.String()) + } + } + catalogOffline.Store(true) + if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 502 || !strings.Contains(rec.Body.String(), "ONEOS_CATALOG_UNAVAILABLE") || strings.Contains(rec.Body.String(), "登录") { + t.Fatal("upstream outage misclassified", rec.Code, rec.Body.String()) + } + permitted.Store(false) + if rec := request("/api/v2/auth/oneos/vehicles?pageNum=2&pageSize=10"); rec.Code != 403 { + t.Fatal("revoked catalog permission remained") + } + if assetCalls.Load() != 2 { + t.Fatal("asset fetch happened without permission or for noncatalog routes") + } +} + +func TestOneOSChildPermissionsUseThreeSegmentMenuContract(t *testing.T) { + now := time.Now() + scope := oneOSVehicleScope{TenantID: "t", UserID: "1", PermissionVersion: "v", ExpiresAt: now.Add(time.Second)} + profile := oneOSProfile{TenantID: "t", UserID: "1"} + p, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"}, scope, now) + if err != nil || !p.CanMenu("vehicles") { + t.Fatal("valid three-segment permission denied", err) + } + old, err := oneOSScopedPrincipal(authUser{}, profile, []string{"vehicle:platform:access", "vehicle:platform:vehicles:view", "vehicle:platform:vehicle:read"}, scope, now) + if err != nil || old.CanMenu("vehicles") { + t.Fatal("legacy invalid child permission treated as configured menu") + } + for key := range oneOSPermissions([]string{"*:*:*"}) { + if strings.Count(key, ":") != 2 { + t.Fatal("permission incompatible with OneOS menu contract", key) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_profile_client.go b/vehicle-data-platform/apps/api/internal/app/oneos_profile_client.go new file mode 100644 index 00000000..7d37e4ac --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_profile_client.go @@ -0,0 +1,212 @@ +package app + +import ( + "bytes" + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/config" +) + +// oneOSProfile is identity data only. The profile-jump API conveys no roles, +// permissions, data scope, or evidence that the upstream session remains active. +type oneOSProfile struct { + Issuer string `json:"-"` + UserID string `json:"userId"` + TenantID string `json:"tenantId"` + Username string `json:"username"` + Nickname string `json:"nickname"` + DeptID string `json:"deptId"` + DeptName string `json:"deptName"` +} + +type oneOSProfileClient struct { + baseURL string + serviceToken string + issuer string + allowedTenant string + requestTimeout time.Duration + client *http.Client + passwordEnabled bool + passwordClientID string + passwordPublicKey *rsa.PublicKey + loginTicketExchangePath string + loginTicketServiceToken string + vehicleScopePath string +} + +type oneOSProfileEnvelope[T any] struct { + Code int `json:"code"` + Data T `json:"data"` +} + +type oneOSProfileToken struct { + AccessToken string `json:"accessToken"` + TokenType string `json:"tokenType"` + ExpiresAt time.Time `json:"expiresAt"` +} + +const oneOSProfileMaxResponse = 64 << 10 + +var errOneOSProfileRejected = errors.New("OneOS profile request rejected") + +func newOneOSProfileClient(cfg config.Config) (*oneOSProfileClient, error) { + if !cfg.OneOSProfileEnabled && !cfg.OneOSAuthEnabled && !cfg.OneOSPasswordEnabled && cfg.OneOSLoginTicketExchangePath == "" { + return nil, nil + } + if cfg.OneOSLocalOnly && cfg.OneOSDevIntegration { + return nil, errors.New("OneOS local-only and dev-integration modes are mutually exclusive") + } + if cfg.OneOSDevIntegration && !validOneOSDevBaseURL(cfg.OneOSProfileBaseURL) { + return nil, errors.New("local OneOS integration permits only the configured dev or production HTTPS origin") + } + productionOneOS := strings.TrimRight(cfg.OneOSProfileBaseURL, "/") == "https://lnoneos.com/prod-api" + base, err := url.Parse(strings.TrimSpace(cfg.OneOSProfileBaseURL)) + if err != nil || base.Host == "" || base.User != nil || base.RawQuery != "" || base.Fragment != "" || (!cfg.OneOSDevIntegration && !productionOneOS && base.Path != "" && base.Path != "/") { + return nil, errors.New("ONEOS_PROFILE_BASE_URL must be an HTTPS origin") + } + loopback := net.ParseIP(base.Hostname()) != nil && net.ParseIP(base.Hostname()).IsLoopback() + if cfg.OneOSLocalOnly && !loopback { + return nil, errors.New("ONEOS_LOCAL_ONLY requires a literal loopback OneOS origin") + } + if base.Scheme != "https" && !(cfg.OneOSLocalOnly && loopback && base.Scheme == "http") { + return nil, errors.New("ONEOS_PROFILE_BASE_URL must use HTTPS outside explicit local-only mode") + } + if cfg.OneOSProfileServiceToken != "" && (len(cfg.OneOSProfileServiceToken) < 32 || strings.TrimSpace(cfg.OneOSProfileServiceToken) != cfg.OneOSProfileServiceToken || strings.ContainsAny(cfg.OneOSProfileServiceToken, "\r\n ")) { + return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN must contain at least 32 non-whitespace characters") + } + if cfg.OneOSProfileEnabled && len(cfg.OneOSProfileServiceToken) < 32 { + return nil, errors.New("ONEOS_PROFILE_SERVICE_TOKEN is required for profile-jump") + } + if err := validateOneOSConfiguredPath(cfg.OneOSLoginTicketExchangePath); err != nil { + return nil, errors.New("ONEOS_LOGIN_TICKET_EXCHANGE_PATH is invalid") + } + if cfg.OneOSLoginTicketExchangePath != "" && (len(cfg.OneOSLoginTicketServiceToken) < 32 || strings.TrimSpace(cfg.OneOSLoginTicketServiceToken) != cfg.OneOSLoginTicketServiceToken || strings.ContainsAny(cfg.OneOSLoginTicketServiceToken, " \t\r\n")) { + return nil, errors.New("ONEOS_LOGIN_TICKET_SERVICE_TOKEN must contain at least 32 non-whitespace characters") + } + if err := validateOneOSConfiguredPath(cfg.OneOSVehicleScopePath); err != nil { + return nil, errors.New("ONEOS_VEHICLE_SCOPE_PATH is invalid") + } + issuer := strings.TrimSpace(cfg.OneOSProfileIssuer) + tenant := strings.TrimSpace(cfg.OneOSProfileAllowedTenant) + if issuer == "" || tenant == "" { + return nil, errors.New("ONEOS_PROFILE_ISSUER and ONEOS_PROFILE_ALLOWED_TENANT are required") + } + timeout := cfg.OneOSProfileTimeout + if timeout <= 0 { + timeout = 3500 * time.Millisecond + } + if timeout >= 5*time.Second { + return nil, errors.New("ONEOS_PROFILE_TIMEOUT_MS must be less than 5000") + } + if cfg.OneOSDevIntegration || productionOneOS { + base.Path = "/prod-api" + } else { + base.Path = "" + } + var passwordPublicKey *rsa.PublicKey + if cfg.OneOSPasswordEnabled { + if strings.TrimSpace(cfg.OneOSPasswordClientID) == "" { + return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required") + } + passwordPublicKey, err = parseOneOSRSAPublicKeyForMode(cfg.OneOSPasswordRSAPublicKey, cfg.OneOSDevIntegration || productionOneOS) + if err != nil { + return nil, errors.New("ONEOS_PASSWORD_RSA_PUBLIC_KEY is invalid") + } + } + if cfg.OneOSLoginTicketExchangePath != "" && strings.TrimSpace(cfg.OneOSPasswordClientID) == "" { + return nil, errors.New("ONEOS_PASSWORD_CLIENT_ID is required for login-ticket exchange") + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.ResponseHeaderTimeout = timeout + return &oneOSProfileClient{ + baseURL: base.String(), serviceToken: cfg.OneOSProfileServiceToken, + issuer: issuer, allowedTenant: tenant, requestTimeout: timeout, + passwordEnabled: cfg.OneOSPasswordEnabled, passwordClientID: cfg.OneOSPasswordClientID, passwordPublicKey: passwordPublicKey, + loginTicketExchangePath: cfg.OneOSLoginTicketExchangePath, loginTicketServiceToken: cfg.OneOSLoginTicketServiceToken, vehicleScopePath: cfg.OneOSVehicleScopePath, + client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, + }, nil +} + +// ExchangeCode consumes a code exactly once, then reads its bound profile. +// The whole exchange shares one deadline; a failed call is never retried. +func (c *oneOSProfileClient) ExchangeCode(ctx context.Context, code string) (oneOSProfile, error) { + if c == nil || c.serviceToken == "" { + return oneOSProfile{}, errors.New("OneOS profile client is disabled") + } + decoded, err := base64.RawURLEncoding.DecodeString(code) + if len(code) != 43 || err != nil || len(decoded) != 32 || base64.RawURLEncoding.EncodeToString(decoded) != code { + return oneOSProfile{}, errors.New("invalid OneOS profile code") + } + ctx, cancel := context.WithTimeout(ctx, c.requestTimeout) + defer cancel() + body, _ := json.Marshal(struct { + Code string `json:"code"` + }{code}) + var token oneOSProfileToken + if err := c.doJSON(ctx, http.MethodPost, "/auth/inner/v1/sso/profile-jump/exchange", body, c.serviceToken, &token); err != nil { + return oneOSProfile{}, err + } + if !validOneOSOpaqueToken(token.AccessToken) || token.TokenType != "Bearer" || !token.ExpiresAt.After(time.Now().Add(time.Second)) { + return oneOSProfile{}, errors.New("invalid OneOS profile token response") + } + var profile oneOSProfile + if err := c.doJSON(ctx, http.MethodGet, "/auth/sso/profile-jump/me", nil, token.AccessToken, &profile); err != nil { + return oneOSProfile{}, err + } + if strings.TrimSpace(profile.UserID) == "" || strings.TrimSpace(profile.TenantID) == "" || profile.TenantID != c.allowedTenant { + return oneOSProfile{}, errors.New("OneOS profile identity or tenant rejected") + } + profile.Issuer = c.issuer + return profile, nil +} + +func validOneOSOpaqueToken(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return len(value) == 43 && err == nil && len(decoded) == 32 && base64.RawURLEncoding.EncodeToString(decoded) == value +} + +func (c *oneOSProfileClient) doJSON(ctx context.Context, method, path string, body []byte, bearer string, target any) error { + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return errors.New("invalid OneOS request") + } + if bearer != "" { + request.Header.Set("Authorization", "Bearer "+bearer) + } + request.Header.Set("Accept", "application/json") + if method == http.MethodPost { + request.Header.Set("Content-Type", "application/json") + } + response, err := c.client.Do(request) + if err != nil { + return fmt.Errorf("%w: transport failure", errOneOSProfileRejected) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("%w: HTTP %d", errOneOSProfileRejected, response.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(response.Body, oneOSProfileMaxResponse+1)) + if err != nil || len(raw) > oneOSProfileMaxResponse { + return errors.New("invalid OneOS profile response") + } + var envelope oneOSProfileEnvelope[json.RawMessage] + if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Code != 200 || len(envelope.Data) == 0 || string(envelope.Data) == "null" { + return errors.New("invalid OneOS profile response") + } + if err := json.Unmarshal(envelope.Data, target); err != nil { + return errors.New("invalid OneOS profile response") + } + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_profile_client_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_profile_client_test.go new file mode 100644 index 00000000..a34c309d --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_profile_client_test.go @@ -0,0 +1,170 @@ +package app + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/config" +) + +func testOneOSProfileClient(t *testing.T, server *httptest.Server) *oneOSProfileClient { + t.Helper() + client, err := newOneOSProfileClient(config.Config{ + OneOSProfileEnabled: true, OneOSProfileBaseURL: server.URL, + OneOSProfileServiceToken: strings.Repeat("s", 32), + OneOSProfileIssuer: "oneos-dev", OneOSProfileAllowedTenant: "tenant-a", + OneOSProfileTimeout: 2 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + client.client = server.Client() + client.client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return client +} + +func TestOneOSProfileExchangeUsesTwoTokensAndMinimalIdentity(t *testing.T) { + code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32))) + profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32))) + var calls atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/auth/inner/v1/sso/profile-jump/exchange": + calls.Add(1) + if r.Method != http.MethodPost || r.Header.Get("Authorization") != "Bearer "+strings.Repeat("s", 32) { + t.Errorf("bad exchange request") + } + body, _ := io.ReadAll(r.Body) + if string(body) != `{"code":"`+code+`"}` { + t.Errorf("bad code body") + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}}) + case "/auth/sso/profile-jump/me": + calls.Add(1) + if r.Method != http.MethodGet || r.Header.Get("Authorization") != "Bearer "+profileToken { + t.Errorf("bad profile request") + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "tenant-a", "username": "jane", "nickname": "Jane", "deptId": "7", "deptName": "East", "permissions": []string{"*"}}}) + default: + t.Errorf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + client := testOneOSProfileClient(t, server) + profile, err := client.ExchangeCode(context.Background(), code) + if err != nil { + t.Fatal(err) + } + if calls.Load() != 2 || profile.UserID != "42" || profile.Issuer != "oneos-dev" || profile.TenantID != "tenant-a" { + t.Fatalf("unexpected profile: %+v calls=%d", profile, calls.Load()) + } +} + +func TestOneOSProfileExchangeRejectsTenantAndDoesNotRetry(t *testing.T) { + code := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("c", 32))) + profileToken := base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("p", 32))) + var calls atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Path == "/auth/inner/v1/sso/profile-jump/exchange" { + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"accessToken": profileToken, "tokenType": "Bearer", "expiresAt": time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"userId": "42", "tenantId": "other"}}) + })) + defer server.Close() + client := testOneOSProfileClient(t, server) + if _, err := client.ExchangeCode(context.Background(), code); err == nil { + t.Fatal("cross-tenant identity accepted") + } + if calls.Load() != 2 { + t.Fatalf("unexpected retries: %d", calls.Load()) + } + if _, err := client.ExchangeCode(context.Background(), "invalid"); err == nil || calls.Load() != 2 { + t.Fatal("invalid code reached OneOS") + } +} + +func TestOneOSProfileClientRequiresHTTPSAndTenant(t *testing.T) { + base := config.Config{OneOSProfileEnabled: true, OneOSProfileBaseURL: "http://oneos.example", OneOSProfileServiceToken: strings.Repeat("s", 32), OneOSProfileIssuer: "issuer", OneOSProfileAllowedTenant: "tenant"} + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("HTTP origin accepted") + } + base.OneOSProfileBaseURL = "https://oneos.example" + base.OneOSProfileAllowedTenant = "" + if _, err := newOneOSProfileClient(base); err == nil { + t.Fatal("missing tenant accepted") + } +} + +func TestOneOSDevTransportFixedOriginPrefixWithoutNetwork(t *testing.T) { + cfg := config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"} + client, err := newOneOSProfileClient(cfg) + if err != nil { + t.Fatal(err) + } + if client.baseURL != "https://dev-oneos.lnh2e.com/prod-api" { + t.Fatal("API prefix lost", client.baseURL) + } + tr, ok := client.client.Transport.(*http.Transport) + if !ok || tr.Proxy != nil { + t.Fatal("environment proxy allowed") + } + if client.client.CheckRedirect(&http.Request{}, nil) != http.ErrUseLastResponse { + t.Fatal("redirect allowed") + } + for _, value := range []string{"https://dev-oneos.lnh2e.com", "https://dev-oneos.lnh2e.com/prod-api/../evil", "https://dev-oneos.lnh2e.com/prod-api?x=1", "https://dev-oneos.lnh2e.com.evil.invalid/prod-api", "https://dev-oneos.lnh2e.com:444/prod-api", "http://127.0.0.1/prod-api"} { + c := cfg + c.OneOSProfileBaseURL = value + if _, err := newOneOSProfileClient(c); err == nil { + t.Fatal("invalid dev origin", value) + } + } + cfg.OneOSLocalOnly = true + if _, err := newOneOSProfileClient(cfg); err == nil { + t.Fatal("fixture/dev modes conflict accepted") + } +} + +type oneOSDevURLCaptureTransport struct{ t *testing.T } + +func (c oneOSDevURLCaptureTransport) RoundTrip(r *http.Request) (*http.Response, error) { + if got := r.URL.String(); got != "https://dev-oneos.lnh2e.com/prod-api/auth/code" { + c.t.Fatal("wrong fixed request URL", got) + } + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"code":200,"data":{"captchaEnabled":false}}`)), Request: r}, nil +} +func TestOneOSDevRequestKeepsVerifiedAPIPrefixWithoutNetwork(t *testing.T) { + client, err := newOneOSProfileClient(config.Config{OneOSAuthEnabled: true, OneOSDevIntegration: true, OneOSProfileBaseURL: "https://dev-oneos.lnh2e.com/prod-api/", OneOSProfileIssuer: "dev", OneOSProfileAllowedTenant: "t"}) + if err != nil { + t.Fatal(err) + } + client.passwordEnabled = true + client.client.Transport = oneOSDevURLCaptureTransport{t: t} + if _, err := client.FetchCaptcha(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestProductionOneOSGatewayPrefix(t *testing.T) { + cfg := config.Config{OneOSAuthEnabled: true, OneOSVehicleQueryScope: true, OneOSProfileBaseURL: "https://lnoneos.com/prod-api", OneOSProfileIssuer: "https://lnoneos.com", OneOSProfileAllowedTenant: "000000"} + client, err := newOneOSProfileClient(cfg) + if err != nil { + t.Fatal(err) + } + if client.baseURL != "https://lnoneos.com/prod-api" { + t.Fatal(client.baseURL) + } + cfg.OneOSProfileBaseURL = "https://other.example/prod-api" + if _, err := newOneOSProfileClient(cfg); err == nil { + t.Fatal("unexpected gateway prefix accepted") + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_vehicle_query_scope.go b/vehicle-data-platform/apps/api/internal/app/oneos_vehicle_query_scope.go new file mode 100644 index 00000000..9aca565d --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_vehicle_query_scope.go @@ -0,0 +1,247 @@ +package app + +import ( + "context" + "crypto/sha256" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +const oneOSQueryScopeTTL = 2 * time.Minute +const oneOSQueryScopePageSize int64 = 200 + +type oneOSQueryScopeEntry struct { + ready chan struct{} + vehicles []oneOSAssetVehicle + expires time.Time + err error +} + +// A new login must establish a fresh snapshot even if OneOS reuses its token. +func (a *oneOSProfileAuth) invalidateVehicleQueryScope(token, clientID string) { + key := sha256.Sum256([]byte(clientID + "\x00" + token)) + a.scopeMu.Lock() + delete(a.queryScopes, key) + a.scopeMu.Unlock() +} + +// Each snapshot uses the caller's own upstream credentials. Browser filters, +// service credentials and cached snapshots from other sessions cannot widen it. +func (a *oneOSProfileAuth) vehicleQueryVINs(ctx context.Context, token, clientID string) ([]string, error) { + rows, err := a.vehicleQuerySnapshot(ctx, token, clientID) + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, row := range rows { + seen[row.VIN] = true + } + vins := make([]string, 0, len(seen)) + for vin := range seen { + vins = append(vins, vin) + } + sort.Strings(vins) + return vins, nil +} + +func (a *oneOSProfileAuth) vehicleQuerySnapshot(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) { + key := sha256.Sum256([]byte(clientID + "\x00" + token)) + a.scopeMu.Lock() + if a.queryScopes == nil { + a.queryScopes = map[[32]byte]*oneOSQueryScopeEntry{} + } + for k, e := range a.queryScopes { + select { + case <-e.ready: + if !e.expires.After(time.Now()) { + delete(a.queryScopes, k) + } + default: + } + } + if e, ok := a.queryScopes[key]; ok { + a.scopeMu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-e.ready: + return append([]oneOSAssetVehicle{}, e.vehicles...), e.err + } + } + if len(a.queryScopes) >= 1000 { + a.scopeMu.Unlock() + return nil, errOneOSAuthority + } + e := &oneOSQueryScopeEntry{ready: make(chan struct{})} + a.queryScopes[key] = e + a.scopeMu.Unlock() + fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + vehicles, err := a.client.queryOperatingVehicles(fetchCtx, token, clientID) + a.scopeMu.Lock() + e.vehicles, e.err, e.expires = vehicles, err, time.Now().Add(oneOSQueryScopeTTL) + // An older in-flight request must not remove a newer login's snapshot. + if err != nil && a.queryScopes[key] == e { + delete(a.queryScopes, key) + } + close(e.ready) + a.scopeMu.Unlock() + return append([]oneOSAssetVehicle{}, vehicles...), err +} + +func (c *oneOSProfileClient) queryVisibleVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + rows, err := c.queryOperatingVehicleVINs(ctx, token, clientID) + if err != nil { + return nil, err + } + seen := map[string]bool{} + for _, vin := range rows { + seen[vin] = true + } + vins := make([]string, 0, len(seen)) + for vin := range seen { + vins = append(vins, vin) + } + sort.Strings(vins) + return vins, nil +} + +func (c *oneOSProfileClient) queryOperatingVehicleVINs(ctx context.Context, token, clientID string) ([]string, error) { + rows, err := c.queryOperatingVehicles(ctx, token, clientID) + if err != nil { + return nil, err + } + vins := make([]string, 0, len(rows)) + for _, row := range rows { + vins = append(vins, row.VIN) + } + return vins, nil +} + +func (c *oneOSProfileClient) queryOperatingVehicles(ctx context.Context, token, clientID string) ([]oneOSAssetVehicle, error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + fetch := func(pageNum int64) (oneOSAssetPage, error) { + return c.vehicleAssetPage(ctx, token, clientID, url.Values{"assetCategory": {oneOSVehicleAssetCategory}, "pageNum": {strconv.FormatInt(pageNum, 10)}, "pageSize": {strconv.FormatInt(oneOSQueryScopePageSize, 10)}}, oneOSQueryScopePageSize) + } + first, err := fetch(1) + if err != nil || first.Total > 100000 { + return nil, errOneOSAuthority + } + total := first.Total + rowIDs := map[string]bool{} + vehicles := []oneOSAssetVehicle{} + consume := func(pageNum int64, page oneOSAssetPage) error { + want := total - (pageNum-1)*oneOSQueryScopePageSize + if want > oneOSQueryScopePageSize { + want = oneOSQueryScopePageSize + } + if page.Total != total || want < 0 || int64(len(page.Records)) != want { + return errOneOSAuthority + } + for _, row := range page.Records { + if row.ID == "" || rowIDs[row.ID] { + return errOneOSAuthority + } + rowIDs[row.ID] = true + vin := strings.ToUpper(strings.TrimSpace(row.VIN)) + if validScopeVIN(vin) { + row.VIN = vin + vehicles = append(vehicles, row) + } + } + return nil + } + if err = consume(1, first); err != nil { + return nil, err + } + // Fetch at most four pages concurrently; consume in page order so duplicate + // and completeness checks remain identical to sequential enumeration. + for start := int64(2); start <= (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize; start += 4 { + count := (total+oneOSQueryScopePageSize-1)/oneOSQueryScopePageSize - start + 1 + if count > 4 { + count = 4 + } + pages := make([]oneOSAssetPage, int(count)) + errs := make([]error, int(count)) + var wg sync.WaitGroup + for i := range pages { + wg.Add(1) + go func(i int) { + defer wg.Done() + pages[i], errs[i] = fetch(start + int64(i)) + if errs[i] != nil { + cancel() + } + }(i) + } + wg.Wait() + for i, page := range pages { + if errs[i] != nil { + return nil, errOneOSAuthority + } + if err = consume(start+int64(i), page); err != nil { + return nil, err + } + } + } + + return vehicles, nil +} + +func validScopeVIN(vin string) bool { + if len(vin) != 17 { + return false + } + for _, r := range vin { + if !(r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return false + } + } + return true +} + +func (a *oneOSProfileAuth) resolveVehicleQueryScope(ctx context.Context, token, clientID string, current oneOSCurrentUser) (platform.Principal, oneOSCurrentUser, error) { + grants := oneOSPermissions(current.Permissions) + if !grants["vehicle:platform:access"] { + u, _, err := a.bind(ctx, current.Profile) + if err != nil { + return platform.Principal{}, current, err + } + return platform.Principal{SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", AuthorizationStatus: "pending", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{}, VehicleVINs: []string{}}, current, nil + } + vins, err := a.vehicleQueryVINs(ctx, token, clientID) + if err != nil { + return platform.Principal{}, current, err + } + u, _, err := a.bind(ctx, current.Profile) + if err != nil { + return platform.Principal{}, current, err + } + p := platform.Principal{PermissionKeys: append([]string(nil), current.Permissions...), SubjectID: strconv.FormatUint(u.ID, 10), Name: u.DisplayName, Username: current.Profile.Username, TenantRef: current.Profile.TenantID, AuthProvider: "oneos", Role: "customer", UserType: "customer", MenuKeys: []string{}} + if oneOSHasAdminPermission(current.Permissions) { + p = oneOSAdminPrincipal(u, current.Profile, current.Permissions) + p.SubjectID = strconv.FormatUint(u.ID, 10) + } else { + for _, menu := range []string{"monitor", "vehicles", "tracks", "statistics", "customer-mileage", "operation-compliance"} { + if grants[oneOSMenuPermission(menu)] { + p.MenuKeys = append(p.MenuKeys, menu) + } + } + } + p.VehicleVINs, p.VehicleCount = vins, len(vins) + p.VehicleScopeSource = "oneos-vehicle-query" + p.AuthorizationStatus = "ready" + if a.localBackend { + p.DataSource = "production-local" + } + return p, current, nil +} diff --git a/vehicle-data-platform/apps/api/internal/app/oneos_vehicle_query_scope_test.go b/vehicle-data-platform/apps/api/internal/app/oneos_vehicle_query_scope_test.go new file mode 100644 index 00000000..b850f031 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/oneos_vehicle_query_scope_test.go @@ -0,0 +1,381 @@ +package app + +import ( + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "sync/atomic" + "testing" + "time" +) + +func TestVehicleQueryScopeFullPaginationAndCredentialIsolation(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.URL.Query().Get("assetCategory") != "allOperating" { + t.Error("vehicle scope queried a non-operating category") + } + if r.URL.Path != "/asset/vehicle-info/page" || r.Header.Get("Clientid") != "web" { + t.Error("wrong upstream request") + } + total := int64(0) + records := []oneOSAssetVehicle{} + if r.URL.Query().Get("assetCategory") == "allOperating" { + total = 201 + page, _ := strconv.Atoi(r.URL.Query().Get("pageNum")) + for i := (page - 1) * 200; i < page*200 && i < 201; i++ { + records = append(records, oneOSAssetVehicle{ID: strconv.Itoa(i + 1), VIN: fmt.Sprintf("LNX%014d", i), CustomerName: "客户甲", BusinessDepartment: "业务一部", BusinessResponsiblePerson: "负责人甲"}) + } + if r.Header.Get("Authorization") == "Bearer second-user-token" { + total = 0 + records = []oneOSAssetVehicle{} + } + } + page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64) + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}}) + })) + defer server.Close() + a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}} + first, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web") + if err != nil || len(first) != 201 { + t.Fatalf("incomplete scope: %d %v", len(first), err) + } + n := calls.Load() + snapshot, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web") + if err != nil || len(snapshot) != 201 || snapshot[200].CustomerName != "客户甲" || snapshot[200].BusinessDepartment != "业务一部" || snapshot[200].BusinessResponsiblePerson != "负责人甲" || calls.Load() != n { + t.Fatalf("metadata snapshot lost paginated assignment fields or repeated requests: %v", err) + } + snapshot[0].CustomerName = "tampered" + again, err := a.vehicleQuerySnapshot(context.Background(), "first-user-token-1234", "web") + if err != nil || again[0].CustomerName != "客户甲" { + t.Fatal("metadata cache is mutable by caller") + } + first[0] = "tampered" + cached, err := a.vehicleQueryVINs(context.Background(), "first-user-token-1234", "web") + if err != nil || cached[0] == "tampered" || calls.Load() != n { + t.Fatal("unsafe cache") + } + second, err := a.vehicleQueryVINs(context.Background(), "second-user-token", "web") + if err != nil || len(second) != 0 { + t.Fatal("another user's scope leaked") + } +} + +func TestVehicleQueryScopeFailsClosedOnIncompleteOrFailedPage(t *testing.T) { + for _, scenario := range []string{"truncated", "changed-total", "repeated-row", "upstream-failed"} { + t.Run(scenario, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if scenario == "upstream-failed" { + http.Error(w, "unavailable", 503) + return + } + page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64) + records := []oneOSAssetVehicle{} + count := 200 + total := int64(201) + if page == 2 { + count = 1 + } + if scenario == "truncated" { + count = 0 + } + if page == 2 && scenario == "changed-total" { + total = 202 + } + for i := 0; i < count; i++ { + id := fmt.Sprint((page-1)*200 + int64(i)) + if scenario == "repeated-row" { + id = "same" + } + records = append(records, oneOSAssetVehicle{ID: id, VIN: fmt.Sprintf("LNX%014d", i)}) + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: total, Current: page, Size: 200}}) + })) + defer server.Close() + c := &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"} + if _, err := c.queryVisibleVehicleVINs(context.Background(), "user-token-123456", "web"); err == nil { + t.Fatal("partial scope accepted") + } + }) + } +} + +func TestVehicleQueryScopeDoesNotReuseExpiredSnapshotOnFailure(t *testing.T) { + var fail atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail.Load() { + http.Error(w, "unavailable", 503) + return + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}}) + })) + defer server.Close() + a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: server.URL, client: server.Client(), requestTimeout: time.Second, passwordClientID: "web"}} + if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err != nil { + t.Fatal(err) + } + for _, entry := range a.queryScopes { + entry.expires = time.Now().Add(-time.Second) + } + fail.Store(true) + if _, err := a.vehicleQueryVINs(context.Background(), "user-token-123456", "web"); err == nil { + t.Fatal("stale authorization reused") + } +} + +func TestVehicleQueryScopeRechecksRolesAndNeverGrantsUnlistedVINs(t *testing.T) { + var admin atomic.Bool + admin.Store(true) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/system/user/getInfo" { + permissions := []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"} + if admin.Load() { + permissions = []string{"*:*:*"} + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": permissions}}) + return + } + records := []oneOSAssetVehicle{} + if r.URL.Query().Get("assetCategory") == "allOperating" { + records = []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}} + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: int64(len(records)), Current: 1, Size: 200}}) + })) + defer upstream.Close() + a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, scopeEnabled: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}} + p, _, err := a.resolve(context.Background(), "original-user-token", "web") + if err != nil || p.Role != "admin" || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("L0000000000000000") { + t.Fatalf("bad admin scope: %v %+v", err, p) + } + admin.Store(false) + p, _, err = a.resolve(context.Background(), "original-user-token", "web") + if err != nil || p.Role != "customer" || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") { + t.Fatalf("stale administrator grant: %v %+v", err, p) + } +} + +func TestVehicleQueryScopeLoginRefreshAndFixedExpiry(t *testing.T) { + var calls atomic.Int32 + var vin atomic.Value + vin.Store("LNXNEGRR3SR321379") + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/system/user/getInfo" { + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}}) + return + } + calls.Add(1) + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: vin.Load().(string)}}, Total: 1, Current: 1, Size: 200}}) + })) + defer upstream.Close() + a := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}} + credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)} + request := httptest.NewRequest("POST", "/login", nil) + before := time.Now() + login, err := a.login(request, credential) + if err != nil || login.Session.AuthorizationStatus != "loading" || login.Session.CanVIN("LNXNEGRR3SR321379") || calls.Load() != 0 { + t.Fatalf("login must return identity without querying vehicles: %v", err) + } + if _, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID); err != nil { + t.Fatal(err) + } + key := sha256.Sum256([]byte("web\x00" + credential.AccessToken)) + entry := a.queryScopes[key] + if entry.expires.Before(before.Add(2*time.Minute)) || entry.expires.After(time.Now().Add(2*time.Minute)) { + t.Fatal("scope TTL is not two minutes") + } + // A hit must retain the existing deadline instead of extending it. + entry.expires = time.Now().Add(time.Minute) + deadline := entry.expires + vin.Store("LNXNEGRR3SR321380") + p, _, err := a.resolve(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil || !p.CanVIN("LNXNEGRR3SR321379") || p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 1 || !entry.expires.Equal(deadline) { + t.Fatal("cache hit changed scope or extended deadline") + } + login, err = a.login(request, credential) + if err != nil || login.Session.AuthorizationStatus != "loading" || calls.Load() != 1 { + t.Fatal("repeat login must defer vehicle lookup") + } + p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil || p.CanVIN("LNXNEGRR3SR321379") || !p.CanVIN("LNXNEGRR3SR321380") || calls.Load() != 2 { + t.Fatal("repeat login reused old scope") + } + a.queryScopes[key].expires = time.Now().Add(-time.Second) + vin.Store("LNXNEGRR3SR321381") + p, _, err = a.resolve(context.Background(), credential.AccessToken, credential.ClientID) + if err != nil || p.CanVIN("LNXNEGRR3SR321380") || !p.CanVIN("LNXNEGRR3SR321381") || calls.Load() != 3 { + t.Fatal("expired scope was not refreshed") + } +} + +func TestVehicleQueryScopeOldFailureCannotEvictNewSnapshot(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + var calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if calls.Add(1) == 1 { + close(started) + <-release + http.Error(w, "unavailable", 503) + return + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{}, Current: 1, Size: 200}}) + })) + defer upstream.Close() + a := &oneOSProfileAuth{client: &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: 5 * time.Second, passwordClientID: "web"}} + done := make(chan error, 1) + go func() { _, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); done <- err }() + <-started + a.invalidateVehicleQueryScope("original-user-token", "web") + _, err := a.vehicleQueryVINs(context.Background(), "original-user-token", "web") + close(release) + oldErr := <-done + if err != nil || oldErr == nil { + t.Fatalf("unexpected query results: new=%v old=%v", err, oldErr) + } + if _, err = a.vehicleQueryVINs(context.Background(), "original-user-token", "web"); err != nil || calls.Load() != 2 { + t.Fatal("old failure evicted new snapshot") + } +} + +func TestVehicleQueryScopeBoundedParallelPagination(t *testing.T) { + var active, peak, calls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + n := active.Add(1) + defer active.Add(-1) + for old := peak.Load(); n > old; old = peak.Load() { + if peak.CompareAndSwap(old, n) { + break + } + } + // Model network latency across the 6 pages used by a 1,026-vehicle scope. + time.Sleep(50 * time.Millisecond) + page, _ := strconv.ParseInt(r.URL.Query().Get("pageNum"), 10, 64) + records := []oneOSAssetVehicle{} + for i := (page - 1) * 200; i < page*200 && i < 1026; i++ { + records = append(records, oneOSAssetVehicle{ID: strconv.FormatInt(i+1, 10), VIN: fmt.Sprintf("LNX%014d", i)}) + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: records, Total: 1026, Current: page, Size: 200}}) + })) + defer upstream.Close() + c := &oneOSProfileClient{baseURL: upstream.URL, client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"} + started := time.Now() + vins, err := c.queryVisibleVehicleVINs(context.Background(), "original-user-token", "web") + elapsed := time.Since(started) + if err != nil || len(vins) != 1026 || calls.Load() != 6 { + t.Fatalf("incomplete scope: count=%d calls=%d err=%v", len(vins), calls.Load(), err) + } + if peak.Load() < 2 || peak.Load() > 4 { + t.Fatalf("unexpected concurrency: %d", peak.Load()) + } + t.Logf("6 pages with 50ms simulated latency: %s, sequential minimum 0.30s, peak concurrency %d", elapsed, peak.Load()) +} + +func TestVehicleScopeEndpointSeparatesIdentityAndRetries(t *testing.T) { + var fail atomic.Bool + fail.Store(true) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/system/user/getInfo" { + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "1", "tenantId": "t", "userName": "admin", "status": "0"}, "permissions": []string{"*:*:*"}}}) + return + } + if fail.Load() { + http.Error(w, "unavailable", 503) + return + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}}) + })) + defer upstream.Close() + profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}} + credential := oneOSCredential{AccessToken: "original-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)} + login, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential) + if err != nil || login.Session.AuthorizationStatus != "loading" { + t.Fatal("unavailable vehicle service blocked identity login") + } + auth := &apiAuthenticator{mode: "enforce", profile: profile} + handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("scope request reached business handler") })) + request := func(token, client string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/api/v2/auth/vehicle-scope", nil) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + if client != "" { + r.Header.Set("X-OneOS-Client-ID", client) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + if w := request("", ""); w.Code != 401 { + t.Fatalf("anonymous scope: %d", w.Code) + } + if w := request(credential.AccessToken, credential.ClientID); w.Code != 503 { + t.Fatalf("scope outage should be retryable: %d", w.Code) + } + fail.Store(false) + w := request(credential.AccessToken, credential.ClientID) + var body struct { + Data struct { + AuthorizationStatus string `json:"authorizationStatus"` + VehicleCount int `json:"vehicleCount"` + } `json:"data"` + } + if err = json.Unmarshal(w.Body.Bytes(), &body); err != nil || w.Code != 200 || body.Data.AuthorizationStatus != "ready" || body.Data.VehicleCount != 1 { + t.Fatalf("scope retry failed: status=%d err=%v", w.Code, err) + } + if w := request(credential.AccessToken, ""); w.Code != 401 { + t.Fatal("OneOS token crossed into local authentication") + } +} + +func TestOrdinaryOneOSLoginWithoutEntryPermissionIsPending(t *testing.T) { + var granted atomic.Bool + var vehicleCalls atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/system/user/getInfo" { + permissions := []string{} + if granted.Load() { + permissions = []string{"vehicle:platform:access", "vehiclePlatform:vehicles:view", "vehiclePlatform:vehicle:read"} + } + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": "2", "tenantId": "t", "userName": "ordinary", "status": "0"}, "permissions": permissions}}) + return + } + vehicleCalls.Add(1) + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: "1", VIN: "LNXNEGRR3SR321379"}}, Total: 1, Current: 1, Size: 200}}) + })) + defer upstream.Close() + profile := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "t", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "t", client: upstream.Client(), requestTimeout: time.Second, passwordClientID: "web"}} + credential := oneOSCredential{AccessToken: "ordinary-user-token", ClientID: "web", ExpiresAt: time.Now().Add(time.Hour)} + result, err := profile.login(httptest.NewRequest("POST", "/login", nil), credential) + if err != nil || result.Session.AuthorizationStatus != "pending" || result.Session.CanVIN("LNXNEGRR3SR321379") || result.Session.CanMenu("vehicles") || vehicleCalls.Load() != 0 { + t.Fatal("ordinary identity should log in without receiving business access") + } + auth := &apiAuthenticator{mode: "enforce", profile: profile} + handler := auth.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("pending user reached business handler") })) + for _, path := range []string{"/api/v2/session", "/api/v2/auth/vehicle-scope", "/api/v2/auth/oneos/vehicles", "/api/v2/vehicles"} { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("Authorization", "Bearer "+credential.AccessToken) + r.Header.Set("X-OneOS-Client-ID", "web") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + want := 403 + if path == "/api/v2/session" || path == "/api/v2/auth/vehicle-scope" { + want = 200 + } + if w.Code != want { + t.Fatalf("%s status=%d want=%d", path, w.Code, want) + } + } + if vehicleCalls.Load() != 0 { + t.Fatal("pending user queried vehicle catalog") + } + granted.Store(true) + p, _, err := profile.resolve(context.Background(), credential.AccessToken, "web") + if err != nil || p.AuthorizationStatus != "ready" || p.Role != "customer" || !p.CanMenu("vehicles") || p.CanMenu("users") || !p.CanVIN("LNXNEGRR3SR321379") { + t.Fatal("newly granted ordinary user could not refresh scoped access") + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/operation_compliance_auth_test.go b/vehicle-data-platform/apps/api/internal/app/operation_compliance_auth_test.go new file mode 100644 index 00000000..134f3371 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/operation_compliance_auth_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +func TestOperationComplianceOnlyPermissionAndCallerScopedAssignments(t *testing.T) { + permissions := []string{"vehicle:platform:access", "vehiclePlatform:operationCompliance:view"} + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + second := strings.Contains(r.Header.Get("Authorization"), "second") + user, vin, customer := "21", "LB9A32A24R0LS1426", "客户甲" + if second { + user, vin, customer = "22", "LNXNEGRR7SR318212", "客户乙" + } + if r.URL.Path == "/system/user/getInfo" { + json.NewEncoder(w).Encode(map[string]any{"code": 200, "data": map[string]any{"user": map[string]any{"userId": user, "tenantId": "000000", "userName": "operation-only", "status": "0"}, "permissions": permissions}}) + return + } + if r.URL.Path != "/asset/vehicle-info/page" || r.URL.Query().Get("assetCategory") != "allOperating" { + t.Errorf("unexpected upstream request: %s", r.URL.Path) + } + json.NewEncoder(w).Encode(oneOSProfileEnvelope[oneOSAssetPage]{Code: 200, Data: oneOSAssetPage{Records: []oneOSAssetVehicle{{ID: user, VIN: vin, CustomerName: customer, BusinessDepartment: "业务一部", BusinessResponsiblePerson: "张业务"}}, Total: 1, Current: 1, Size: 200}}) + })) + defer upstream.Close() + auth := &oneOSProfileAuth{localOnly: true, vehicleQueryScope: true, issuer: "i", tenant: "000000", client: &oneOSProfileClient{baseURL: upstream.URL, issuer: "i", allowedTenant: "000000", passwordClientID: "web", requestTimeout: time.Second, client: upstream.Client()}} + handler := (&apiAuthenticator{mode: "enforce", profile: auth}).middleware(platform.NewHandler(platform.NewService(platform.NewMockStore()))) + request := func(token, path string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", path, nil) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("X-OneOS-Client-ID", "web") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + for _, tc := range []struct{ token, vin, customer, forbidden string }{ + {"first-operation-token", "LB9A32A24R0LS1426", "客户甲", "客户乙"}, + {"second-operation-token", "LNXNEGRR7SR318212", "客户乙", "客户甲"}, + } { + w := request(tc.token, "/api/v2/operation-compliance/options") + if w.Code != 200 || !strings.Contains(w.Body.String(), tc.customer) || strings.Contains(w.Body.String(), tc.forbidden) { + t.Fatalf("scoped options failed: %d %s", w.Code, w.Body.String()) + } + w = request(tc.token, "/api/v2/operation-compliance?dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource=hydrogen&scopeVins=FORGED&vins=FORGED&protocol=GB32960") + if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || strings.Contains(w.Body.String(), tc.forbidden) || strings.Contains(w.Body.String(), "FORGED") { + t.Fatalf("roster scope failed: %d %s", w.Code, w.Body.String()) + } + for _, source := range []string{"gps", "odometer", "hydrogen"} { + w = request(tc.token, "/api/v2/operation-compliance/detail?vin="+tc.vin+"&dateFrom=2026-09-01&dateTo=2026-09-03&mileageSource="+source+"&scopeVins=FORGED") + if w.Code != 200 || !strings.Contains(w.Body.String(), tc.vin) || !strings.Contains(w.Body.String(), `"mileageSource":"`+source+`"`) || strings.Contains(w.Body.String(), tc.forbidden) { + t.Fatalf("daily scope failed: %d %s", w.Code, w.Body.String()) + } + } + if denied := request(tc.token, "/api/v2/operation-compliance/detail?vin=LNX00000000000002&dateFrom=2026-09-01&dateTo=2026-09-03"); denied.Code != 403 { + t.Fatalf("daily VIN escape: %d", denied.Code) + } + for _, path := range []string{"/api/vehicles", "/api/v2/statistics/vehicle-options", "/api/v2/customer-mileage"} { + if denied := request(tc.token, path); denied.Code != 403 { + t.Fatalf("new menu escalated access to %s: %d", path, denied.Code) + } + } + } + permissions = []string{"vehicle:platform:access"} + for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} { + if w := request("first-operation-token", path); w.Code != 403 { + t.Fatalf("revocation not enforced: %d %s", w.Code, w.Body.String()) + } + } +} + +func TestOperationComplianceMenuMappingAndNativeConfiguration(t *testing.T) { + if oneOSMenuPermission("operation-compliance") != "vehiclePlatform:operationCompliance:view" || !customerMenuSet["operation-compliance"] || !adminMenuSet["operation-compliance"] { + t.Fatal("menu mapping missing") + } + for _, path := range []string{"/api/v2/operation-compliance", "/api/v2/operation-compliance/options", "/api/v2/operation-compliance/detail"} { + r := httptest.NewRequest("GET", path, nil) + if requiredMenu(r) != "operation-compliance" || requiredRole(r) != "viewer" || oneOSReadAction(r) != "vehiclePlatform:operationCompliance:view" { + t.Fatalf("permission dependency mismatch: %s", path) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index 0f6c6f10..d984c630 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -27,6 +27,11 @@ import ( ) func NewServer(cfg config.Config) http.Handler { + if err := ValidateLocalIsolation(cfg); err != nil { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_ISOLATION_REQUIRED", err.Error(), "", requestTraceID(r)) + }) + } dataMode := strings.ToLower(strings.TrimSpace(cfg.DataMode)) if dataMode == "" { dataMode = "mock" @@ -124,10 +129,22 @@ func NewServer(cfg config.Config) http.Handler { // Reverse geocoding consumes the server-side AMap credential, so it must // stay behind the same authentication boundary as the platform API. api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient) + var devData *oneOSDevDataProxy + if cfg.OneOSDevDataProxyURL != "" { + devData = newOneOSDevDataProxy(cfg) + api = devData + } handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB)) handler = withAppConfig(handler, cfg) handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient) - return withRequestTimeout(handler, cfg.RequestTimeout) + if devData != nil { + handler = devData.withMapProxy(handler) + } + handler = withRequestTimeout(handler, cfg.RequestTimeout) + if cfg.OneOSLocalOnly || cfg.OneOSDevIntegration { + handler = withOneOSLocalIsolationHeaders(handler, devData != nil || cfg.OneOSLocalBackend) + } + return handler } func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig { @@ -610,7 +627,9 @@ func withRequestTimeout(next http.Handler, timeout time.Duration) http.Handler { } buffered.writeTo(w) case <-ctx.Done(): - httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", requestTraceID(r)) + traceID := requestTraceID(r) + log.Printf("request timed out trace_id=%q method=%s path=%s timeout_ms=%d", traceID, r.Method, r.URL.Path, timeout.Milliseconds()) + httpx.WriteError(w, http.StatusServiceUnavailable, "REQUEST_TIMEOUT", "请求处理超时", "", traceID) } }) } diff --git a/vehicle-data-platform/apps/api/internal/config/config.go b/vehicle-data-platform/apps/api/internal/config/config.go index 6f5c123e..8431eee7 100644 --- a/vehicle-data-platform/apps/api/internal/config/config.go +++ b/vehicle-data-platform/apps/api/internal/config/config.go @@ -34,6 +34,26 @@ type Config struct { OneOSDefaultMenus []string OneOSRequestTimeout time.Duration OneOSSessionTTL time.Duration + OneOSProfileEnabled bool + OneOSAuthEnabled bool + OneOSProfileBaseURL string + OneOSProfileServiceToken string + OneOSProfileIssuer string + OneOSProfileAllowedTenant string + OneOSProfileTimeout time.Duration + OneOSPublicOrigin string + OneOSPasswordEnabled bool + OneOSPasswordClientID string + OneOSPasswordRSAPublicKey string + OneOSLoginTicketExchangePath string + OneOSLoginTicketServiceToken string + OneOSDevIntegration bool + OneOSLocalBackend bool + OneOSVehicleQueryScope bool + OneOSDevDataProxyURL string + OneOSDevDataProxyToken string + OneOSLocalOnly bool + OneOSVehicleScopePath string DataMode string ExportDir string RequestTimeout time.Duration @@ -93,6 +113,26 @@ func Load() Config { OneOSDefaultMenus: splitCSV(env("ONEOS_SSO_DEFAULT_MENUS", "vehicles")), OneOSRequestTimeout: time.Duration(envInt("ONEOS_SSO_REQUEST_TIMEOUT_MS", 3000)) * time.Millisecond, OneOSSessionTTL: time.Duration(envInt("ONEOS_SSO_SESSION_TTL_MINUTES", 30)) * time.Minute, + OneOSProfileEnabled: envBool("ONEOS_PROFILE_ENABLED", false), + OneOSAuthEnabled: envBool("ONEOS_AUTH_ENABLED", false), + OneOSProfileBaseURL: os.Getenv("ONEOS_PROFILE_BASE_URL"), + OneOSProfileServiceToken: os.Getenv("ONEOS_PROFILE_SERVICE_TOKEN"), + OneOSProfileIssuer: os.Getenv("ONEOS_PROFILE_ISSUER"), + OneOSProfileAllowedTenant: os.Getenv("ONEOS_PROFILE_ALLOWED_TENANT"), + OneOSProfileTimeout: time.Duration(envInt("ONEOS_PROFILE_TIMEOUT_MS", 3500)) * time.Millisecond, + OneOSPublicOrigin: os.Getenv("ONEOS_PUBLIC_ORIGIN"), + OneOSPasswordEnabled: envBool("ONEOS_PASSWORD_ENABLED", false), + OneOSPasswordClientID: os.Getenv("ONEOS_PASSWORD_CLIENT_ID"), + OneOSPasswordRSAPublicKey: os.Getenv("ONEOS_PASSWORD_RSA_PUBLIC_KEY"), + OneOSLoginTicketExchangePath: os.Getenv("ONEOS_LOGIN_TICKET_EXCHANGE_PATH"), + OneOSLoginTicketServiceToken: os.Getenv("ONEOS_LOGIN_TICKET_SERVICE_TOKEN"), + OneOSDevIntegration: envBool("ONEOS_DEV_INTEGRATION", false), + OneOSLocalBackend: envBool("ONEOS_LOCAL_BACKEND", false), + OneOSVehicleQueryScope: envBool("ONEOS_VEHICLE_QUERY_SCOPE", false), + OneOSDevDataProxyURL: os.Getenv("ONEOS_DEV_DATA_PROXY_URL"), + OneOSDevDataProxyToken: os.Getenv("ONEOS_DEV_DATA_PROXY_TOKEN"), + OneOSLocalOnly: envBool("ONEOS_LOCAL_ONLY", false), + OneOSVehicleScopePath: os.Getenv("ONEOS_VEHICLE_SCOPE_PATH"), DataMode: dataMode(), ExportDir: os.Getenv("EXPORT_DIR"), RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond, diff --git a/vehicle-data-platform/apps/api/internal/config/config_test.go b/vehicle-data-platform/apps/api/internal/config/config_test.go index d71cab2c..ee85c049 100644 --- a/vehicle-data-platform/apps/api/internal/config/config_test.go +++ b/vehicle-data-platform/apps/api/internal/config/config_test.go @@ -79,3 +79,12 @@ func TestLoadReadsHistoryCleanupAutomationSettings(t *testing.T) { t.Fatalf("history cleanup automation settings were not loaded: %+v", cfg) } } + +func TestLoadReadsOneOSLoginTicketServiceToken(t *testing.T) { + t.Setenv("ONEOS_LOGIN_TICKET_EXCHANGE_PATH", "/auth/inner/v1/sso/vehicle-platform/token-exchange") + t.Setenv("ONEOS_LOGIN_TICKET_SERVICE_TOKEN", "local-fixture-service-credential") + cfg := Load() + if cfg.OneOSLoginTicketExchangePath != "/auth/inner/v1/sso/vehicle-platform/token-exchange" || cfg.OneOSLoginTicketServiceToken != "local-fixture-service-credential" { + t.Fatal("OneOS login-ticket service configuration was not loaded") + } +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/client.go b/vehicle-data-platform/apps/api/internal/gzev/client.go new file mode 100644 index 00000000..37a48536 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/client.go @@ -0,0 +1,337 @@ +// Package gzev collects Guangzhou platform daily mileage without browser automation. +package gzev + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +const Origin = "https://gzev.d.lnoneos.com" +const MileagePath = "/evdata-web/api/operation/queryHyVehicleMileageStatistics" + +var ErrAuth = errors.New("GZEV session expired; replace session file after interactive login") +var decimalPattern = regexp.MustCompile(`^[0-9]{1,11}(\.[0-9]{1,3})?$`) + +// Decimal preserves source decimal precision and distinguishes missing values from zero. +type Decimal struct { + Value string + Valid bool +} + +func (d *Decimal) UnmarshalJSON(b []byte) error { + *d = Decimal{} + if string(b) == "null" { + return nil + } + s := string(b) + if len(s) > 0 && s[0] == '"' { + if err := json.Unmarshal(b, &s); err != nil { + return err + } + } + if !decimalPattern.MatchString(s) { + return errors.New("invalid nonnegative mileage decimal") + } + d.Value = s + d.Valid = true + return nil +} +func (d Decimal) MarshalJSON() ([]byte, error) { + if !d.Valid { + return []byte("null"), nil + } + return json.Marshal(d.Value) +} +func (d Decimal) SQLValue() any { + if !d.Valid { + return nil + } + return d.Value +} + +type Row struct { + VIN string `json:"vin"` + Plate string `json:"plateNumber"` + VehicleSeq string `json:"vehicleSeq"` + ProductionCompany string `json:"productionCompany"` + OperationCompany string `json:"operationCompany"` + Brand string `json:"brandName"` + Model string `json:"brandModel"` + DateRange string `json:"dateRange"` + Total Decimal `json:"runMileage"` + Hydrogen Decimal `json:"hyRunMileage"` + Charge Decimal `json:"chargeRunMileage"` + Raw json.RawMessage `json:"-"` +} +type Day struct { + Date string `json:"date"` + Rows []Row `json:"rows"` + Pages []json.RawMessage `json:"pages"` + FetchedAt time.Time `json:"fetched_at"` +} +type SessionCookie struct { + Name string `json:"name"` + Value string `json:"value"` +} +type Session struct { + Authorization string `json:"authorization"` + Cookies []SessionCookie `json:"cookies"` +} + +func ReadSession(path string) (Session, error) { + var s Session + info, err := os.Stat(path) + if err != nil { + return s, errors.New("session file unavailable") + } + if info.Mode().Perm()&0077 != 0 { + return s, errors.New("session file must have permissions 0600") + } + b, err := os.ReadFile(path) + if err != nil { + return s, errors.New("cannot read session file") + } + if json.Unmarshal(b, &s) != nil { + return s, errors.New("invalid session JSON") + } + return s, ValidateSession(s) +} + +func ValidateSession(s Session) error { + if !strings.HasPrefix(s.Authorization, "Bearer ") || len(s.Authorization) <= 7 || strings.ContainsAny(s.Authorization, "\r\n") { + return errors.New("invalid session authorization") + } + seenCookies := map[string]bool{} + for _, cookie := range s.Cookies { + if seenCookies[cookie.Name] { + return errors.New("duplicate session cookie") + } + seenCookies[cookie.Name] = true + if cookie.Name == "token" && "Bearer "+cookie.Value != s.Authorization { + return errors.New("token cookie and bearer mismatch") + } + if cookie.Name != "token" && cookie.Name != "bigs" { + return errors.New("session contains unsupported cookie") + } + if strings.ContainsAny(cookie.Value, "\r\n;") { + return errors.New("invalid session cookie") + } + } + if !seenCookies["token"] { + return errors.New("token cookie is required") + } + return nil +} +func ShanghaiToday(now time.Time) time.Time { + loc := time.FixedZone("Asia/Shanghai", 8*3600) + n := now.In(loc) + return time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc) +} +func ParseDate(s string) (time.Time, error) { + return time.ParseInLocation("2006-01-02", s, time.FixedZone("Asia/Shanghai", 8*3600)) +} +func ValidateDate(s string, now time.Time) error { + d, e := ParseDate(s) + if e != nil || !d.Before(ShanghaiToday(now)) { + return errors.New("date must be a completed Asia/Shanghai calendar day") + } + return nil +} + +type Client struct { + HTTP *http.Client + Session Session + baseURL string // Only tests override this; production credentials stay on the fixed HTTPS origin. + PageSize int + Delay time.Duration +} + +func NewClient(s Session) *Client { + return &Client{HTTP: &http.Client{Timeout: 45 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}, Session: s, baseURL: Origin, PageSize: 500, Delay: 500 * time.Millisecond} +} +func wait(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} +func (c *Client) page(ctx context.Context, date string, page int) (json.RawMessage, error) { + q := url.Values{"productionCompany": {""}, "operationCompany": {""}, "vin": {""}, "plateNumber": {""}, "brandModel": {""}, "queryBeginDate": {date}, "queryEndDate": {date}, "dateRange[0]": {date}, "dateRange[1]": {date}, "queryType": {"2"}, "showMonthlyColumn": {"false"}, "pageNum": {fmt.Sprint(page)}, "pageSize": {fmt.Sprint(c.PageSize)}} + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + if err := wait(ctx, time.Duration(attempt*attempt)*time.Second); err != nil { + return nil, err + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+MileagePath+"?"+q.Encode(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", c.Session.Authorization) + req.Header.Set("Accept", "application/json") + for _, cookie := range c.Session.Cookies { + req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value}) + } + resp, err := c.HTTP.Do(req) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + if attempt < 2 { + continue + } + return nil, errors.New("GZEV network request failed") + } + b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + resp.Body.Close() + if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 { + return nil, ErrAuth + } + if resp.StatusCode == 429 || resp.StatusCode >= 500 { + if attempt < 2 { + continue + } + return nil, fmt.Errorf("GZEV temporary HTTP failure: %d", resp.StatusCode) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("GZEV HTTP failure: %d", resp.StatusCode) + } + if readErr != nil { + return nil, errors.New("GZEV response read failed") + } + var envelope struct { + Code json.RawMessage `json:"code"` + } + if json.Unmarshal(b, &envelope) != nil { + return nil, errors.New("GZEV non-JSON response; login or upstream failure") + } + code := strings.Trim(string(envelope.Code), `"`) + if code == "401" { + return nil, ErrAuth + } + if code != "0" { + if code == "00000004" || code == "403" { + return nil, errors.New("GZEV account lacks endpoint permission") + } + return nil, errors.New("GZEV non-success business response") + } + return b, nil + } + return nil, errors.New("GZEV retries exhausted") +} +func (c *Client) FetchDay(ctx context.Context, date string) (Day, error) { + day := Day{Date: date, FetchedAt: time.Now().UTC()} + if err := wait(ctx, c.Delay); err != nil { + return day, err + } + if err := ValidateDate(date, time.Now()); err != nil { + return day, err + } + if c.PageSize < 1 || c.PageSize > 500 { + return day, errors.New("page size must be 1..500") + } + seen := map[string]bool{} + expected := -1 + for page := 1; page <= 10000; page++ { + if page > 1 { + if err := wait(ctx, c.Delay); err != nil { + return day, err + } + } + b, err := c.page(ctx, date, page) + if err != nil { + return day, err + } + var env struct { + Data *struct { + PageNum int `json:"pageNum"` + Total *int `json:"total"` + List []json.RawMessage `json:"list"` + } `json:"data"` + } + if json.Unmarshal(b, &env) != nil || env.Data == nil || env.Data.Total == nil || env.Data.PageNum != page { + return day, errors.New("invalid pagination schema") + } + p := env.Data + if expected < 0 { + expected = *p.Total + } + if expected < 0 || expected > 1000000 || *p.Total != expected { + return day, errors.New("source total changed or is invalid; retry day") + } + for _, raw := range p.List { + var row Row + if err := json.Unmarshal(raw, &row); err != nil { + return day, fmt.Errorf("invalid mileage row: %w", err) + } + if len(row.VIN) != 17 || seen[row.VIN] || row.DateRange != date+" - "+date { + return day, errors.New("invalid VIN, duplicate VIN, or non-daily date range") + } + row.Raw = raw + seen[row.VIN] = true + day.Rows = append(day.Rows, row) + } + day.Pages = append(day.Pages, b) + if len(day.Rows) == expected { + return day, nil + } + if len(day.Rows) > expected || len(p.List) == 0 { + return day, errors.New("incomplete or inconsistent pagination") + } + } + return day, errors.New("pagination safety limit exceeded") +} + +// VerifyAccount prevents an operator importing another account's valid session into this dataset. +func (c *Client) VerifyAccount(ctx context.Context, account string) error { + q := url.Values{"token": {strings.TrimPrefix(c.Session.Authorization, "Bearer ")}} + req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/evdata-sso/api/base/getUserInfo?"+q.Encode(), nil) + if err != nil { + return errors.New("cannot create identity request") + } + req.Header.Set("Authorization", c.Session.Authorization) + for _, cookie := range c.Session.Cookies { + req.AddCookie(&http.Cookie{Name: cookie.Name, Value: cookie.Value}) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return errors.New("GZEV identity request failed") + } + defer resp.Body.Close() + if resp.StatusCode == 401 || resp.StatusCode >= 300 && resp.StatusCode < 400 { + return ErrAuth + } + if resp.StatusCode != 200 { + return errors.New("GZEV identity HTTP failure") + } + var body struct { + Code json.RawMessage `json:"code"` + Data struct { + LoginName string `json:"loginname"` + } `json:"data"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body) != nil { + return errors.New("invalid identity response") + } + code := strings.Trim(string(body.Code), `"`) + if code == "401" { + return ErrAuth + } + if code != "0" || body.Data.LoginName != account { + return errors.New("GZEV session account does not match configured account, or identity check denied") + } + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/client_test.go b/vehicle-data-platform/apps/api/internal/gzev/client_test.go new file mode 100644 index 00000000..a0061b35 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/client_test.go @@ -0,0 +1,147 @@ +package gzev + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestDecimalPreservesNullAndZero(t *testing.T) { + for _, tc := range []struct { + in string + valid bool + value string + }{{"null", false, ""}, {`"0.0"`, true, "0.0"}, {"203.125", true, "203.125"}} { + var d Decimal + if err := json.Unmarshal([]byte(tc.in), &d); err != nil || d.Valid != tc.valid || d.Value != tc.value { + t.Fatalf("%s: %+v %v", tc.in, d, err) + } + } + for _, bad := range []string{`""`, `"NaN"`, `-1`, `"1.2345"`, `"100000000000"`} { + var d Decimal + if json.Unmarshal([]byte(bad), &d) == nil { + t.Fatalf("accepted %s", bad) + } + } +} +func TestDateShanghaiBoundary(t *testing.T) { + now := time.Date(2026, 9, 27, 16, 0, 0, 0, time.UTC) + if ValidateDate("2026-09-27", now) != nil || ValidateDate("2026-09-28", now) == nil { + t.Fatal("Shanghai day cutoff incorrect") + } +} +func TestFetchPaginationAndCookie(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + if r.Header.Get("Authorization") != "Bearer fake" { + t.Error("missing bearer") + } + cookie, e := r.Cookie("token") + if e != nil || cookie.Value != "fake" { + t.Error("missing token cookie") + } + if r.URL.Query().Get("queryBeginDate") != "2024-01-01" || r.URL.Query().Get("queryEndDate") != "2024-01-01" || r.URL.Query().Get("queryType") != "2" { + t.Error("wrong daily query") + } + fmt.Fprintf(w, `{"code":"0","data":{"pageNum":%d,"total":2,"list":[{"vin":"LNXNEGRR7SR31945%d","dateRange":"2024-01-01 - 2024-01-01","runMileage":"12.1","hyRunMileage":null,"chargeRunMileage":"0"}]}}`, calls, calls) + })) + defer srv.Close() + c := NewClient(Session{Authorization: "Bearer fake", Cookies: []SessionCookie{{"token", "fake"}}}) + c.baseURL = srv.URL + c.PageSize = 1 + c.Delay = 0 + day, err := c.FetchDay(context.Background(), "2024-01-01") + if err != nil || len(day.Rows) != 2 || len(day.Pages) != 2 || calls != 2 { + t.Fatalf("pagination: %+v %v", day, err) + } + if day.Rows[0].Hydrogen.Valid || !day.Rows[0].Charge.Valid { + t.Fatal("null lost") + } +} +func TestRejectsIncompleteSource(t *testing.T) { + for name, body := range map[string]string{ + "missing code": `{"data":{}}`, + "missing total": `{"code":"0","data":{"pageNum":1,"list":[]}}`, + "empty page": `{"code":"0","data":{"pageNum":1,"total":2,"list":[]}}`, + "wrong date": `{"code":"0","data":{"pageNum":1,"total":1,"list":[{"vin":"LNXNEGRR7SR319456","dateRange":"2024-01-01 - 2024-01-02"}]}}`, + } { + t.Run(name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, body) })) + defer srv.Close() + c := NewClient(Session{}) + c.baseURL = srv.URL + if _, err := c.FetchDay(context.Background(), "2024-01-01"); err == nil { + t.Fatal("accepted incomplete day") + } + }) + } +} +func TestAuthAndPermissionAreDifferent(t *testing.T) { + for _, tc := range []struct { + code string + auth bool + }{{"401", true}, {"00000004", false}, {"403", false}} { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; fmt.Fprintf(w, `{"code":%q}`, tc.code) })) + c := NewClient(Session{}) + c.baseURL = srv.URL + _, err := c.FetchDay(context.Background(), "2024-01-01") + srv.Close() + if err == nil || errors.Is(err, ErrAuth) != tc.auth || calls != 1 { + t.Fatalf("code %s: %v calls=%d", tc.code, err, calls) + } + } +} +func TestNoCredentialRedirect(t *testing.T) { + hit := false + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, 302) })) + defer source.Close() + c := NewClient(Session{Authorization: "Bearer fake"}) + c.baseURL = source.URL + _, err := c.FetchDay(context.Background(), "2024-01-01") + if hit || !errors.Is(err, ErrAuth) { + t.Fatalf("redirect leaked credentials: %v", err) + } +} +func TestReadSessionPermissionsAndCookieAllowlist(t *testing.T) { + p := filepath.Join(t.TempDir(), "session.json") + body := `{"authorization":"Bearer fake","cookies":[{"name":"token","value":"fake"}]}` + os.WriteFile(p, []byte(body), 0644) + if _, err := ReadSession(p); err == nil { + t.Fatal("accepted world-readable session") + } + os.Chmod(p, 0600) + if _, err := ReadSession(p); err != nil { + t.Fatal(err) + } + os.WriteFile(p, []byte(strings.ReplaceAll(body, `"name":"token"`, `"name":"password"`)), 0600) + if _, err := ReadSession(p); err == nil { + t.Fatal("accepted password cookie") + } +} + +func TestVerifyAccountRejectsOtherLogin(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/evdata-sso/api/base/getUserInfo" || r.URL.Query().Get("token") != "fake" { + t.Error("incorrect identity contract") + } + fmt.Fprint(w, `{"code":"0","data":{"loginname":"different-account"}}`) + })) + defer srv.Close() + c := NewClient(Session{Authorization: "Bearer fake"}) + c.baseURL = srv.URL + if c.VerifyAccount(context.Background(), "guangdonglingniu") == nil { + t.Fatal("mixed account data") + } +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/daily_report.go b/vehicle-data-platform/apps/api/internal/gzev/daily_report.go new file mode 100644 index 00000000..e8488761 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/daily_report.go @@ -0,0 +1,220 @@ +package gzev + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +type ReportDay struct { + Date string + Rows, Known int64 +} + +type DailyReport struct { + Date, From, Through, Latest, LatestKnown, LastSuccess string + Expected, Fetched, RetryDates int + AuthExpired bool + Recent []ReportDay + Missing []string +} + +// LoadDailyReport reads one consistent database snapshot. NULL mileage stays missing; +// a reported zero counts as known data. +func LoadDailyReport(ctx context.Context, db *sql.DB, account, from string, now time.Time) (DailyReport, error) { + today := ShanghaiToday(now) + first, err := ParseDate(from) + if err != nil || !first.Before(today) { + return DailyReport{}, errors.New("report start must be before today") + } + r := DailyReport{Date: today.Format("2006-01-02"), From: from, Through: today.AddDate(0, 0, -1).Format("2006-01-02")} + r.Expected = int(today.Sub(first).Hours() / 24) + tx, err := db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead}) + if err != nil { + return r, err + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),last_success_at FROM gzev_mileage_day WHERE account=? AND stat_date BETWEEN ? AND ? ORDER BY stat_date`, account, from, r.Through) + if err != nil { + return r, err + } + seen := map[string]bool{} + var latestSuccess time.Time + for rows.Next() { + var date string + var success time.Time + if err = rows.Scan(&date, &success); err != nil { + rows.Close() + return r, err + } + seen[date] = true + r.Fetched++ + r.Latest = date + if success.After(latestSuccess) { + latestSuccess = success + } + } + err = rows.Err() + rows.Close() + if err != nil { + return r, err + } + if !latestSuccess.IsZero() { + r.LastSuccess = latestSuccess.In(today.Location()).Format("2006-01-02 15:04:05") + } + for d := first; d.Before(today); d = d.AddDate(0, 0, 1) { + date := d.Format("2006-01-02") + if !seen[date] { + r.Missing = append(r.Missing, date) + } + } + var known sql.NullString + err = tx.QueryRowContext(ctx, `SELECT DATE_FORMAT(MAX(stat_date),'%Y-%m-%d') FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? AND hydrogen_km IS NOT NULL`, account, from, r.Through).Scan(&known) + if err != nil { + return r, err + } + r.LatestKnown = known.String + recentFrom := today.AddDate(0, 0, -7).Format("2006-01-02") + if recentFrom < from { + recentFrom = from + } + rows, err = tx.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),COUNT(*),COUNT(hydrogen_km) FROM gzev_daily_mileage WHERE account=? AND stat_date BETWEEN ? AND ? GROUP BY stat_date ORDER BY stat_date`, account, recentFrom, r.Through) + if err != nil { + return r, err + } + for rows.Next() { + var d ReportDay + if err = rows.Scan(&d.Date, &d.Rows, &d.Known); err != nil { + rows.Close() + return r, err + } + r.Recent = append(r.Recent, d) + } + err = rows.Err() + rows.Close() + if err != nil { + return r, err + } + err = tx.QueryRowContext(ctx, `SELECT active FROM gzev_auth_alert WHERE account=?`, account).Scan(&r.AuthExpired) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return r, err + } + err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gzev_mileage_attempt WHERE account=? AND stat_date BETWEEN ? AND ?`, account, from, r.Through).Scan(&r.RetryDates) + if err != nil { + return r, err + } + return r, tx.Commit() +} + +func (r DailyReport) Message() (string, string) { + conclusion := "同步已覆盖全部目标日期。" + switch { + case r.AuthExpired: + conclusion = "登录已失效,需要更新登录状态;已入库数据仍保留。" + case len(r.Missing) > 0: + conclusion = fmt.Sprintf("同步尚未补齐,仍有 %d 个日期待采集。", len(r.Missing)) + default: + for _, d := range r.Recent { + if d.Date == r.Through { + if d.Known == 0 { + conclusion = "日期已补齐,但昨天上游纯氢里程全部为空,仍需后续重采。" + } else if d.Known < d.Rows { + conclusion = "日期已补齐,但昨天仍有部分车辆的上游纯氢里程缺失。" + } + } + } + } + show := func(v string) string { + if v == "" { + return "暂无" + } + return v + } + var b strings.Builder + fmt.Fprintf(&b, "**结论:%s**\n\n统计截至:%s(北京时间,昨天及更早完整自然日)\n\n日期采集:%d / %d 天;最新采集日期:%s\n\n最新有纯氢里程值的日期:%s\n\n最后成功采集:%s(北京时间)\n\n待补日期:%d 天;失败待重试:%d 天\n", conclusion, r.Through, r.Fetched, r.Expected, show(r.Latest), show(r.LatestKnown), show(r.LastSuccess), len(r.Missing), r.RetryDates) + if len(r.Missing) > 0 { + n := len(r.Missing) + if n > 14 { + n = 14 + } + fmt.Fprintf(&b, "\n待补日期(最多列出14天):%s\n", strings.Join(r.Missing[:n], "、")) + } + if r.AuthExpired { + fmt.Fprintf(&b, "\n[更新广州平台登录状态](https://vehicle.d.lnoneos.com/gzev/session/)\n") + } + b.WriteString("\n最近7天:\n\n| 日期 | 车辆日记录 | 纯氢有值 | 纯氢缺失 |\n|---|---:|---:|---:|\n") + for _, d := range r.Recent { + fmt.Fprintf(&b, "| %s | %d | %d | %d |\n", d.Date, d.Rows, d.Known, d.Rows-d.Known) + } + b.WriteString("\n有值包含平台明确上报的 0;缺失不计为 0。日期采集完成不代表上游字段齐全。每小时 :15 持续同步及重采近期数据。") + return "纯氢里程每日汇报 · " + r.Date, b.String() +} + +type reportReceipt struct { + Date string `json:"date"` + AttemptedAt time.Time `json:"attempted_at"` + AcceptedAt *time.Time `json:"accepted_at,omitempty"` +} + +// Persist the attempt before sending: an ambiguous timeout must not cause a +// duplicate push when the same day's service is started again. +func DeliverDailyReport(path, date string, send func() error) (bool, error) { + sent := false + err := recoveryLock(path, func() error { + var receipt reportReceipt + b, err := os.ReadFile(path) + if err == nil { + if err = json.Unmarshal(b, &receipt); err != nil { + return errors.New("invalid daily report receipt") + } + if receipt.Date == date { + if receipt.AcceptedAt != nil { + return nil + } + return errors.New("daily report already attempted; delivery acceptance unknown, inspect before retrying") + } + } else if !os.IsNotExist(err) { + return err + } + receipt = reportReceipt{Date: date, AttemptedAt: time.Now().UTC()} + write := func() error { + f, err := os.CreateTemp(filepath.Dir(path), ".daily-report-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + err = json.NewEncoder(f).Encode(receipt) + if err == nil { + err = f.Sync() + } + closeErr := f.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return os.Rename(f.Name(), path) + } + if err = write(); err != nil { + return err + } + if err = send(); err != nil { + return err + } + now := time.Now().UTC() + receipt.AcceptedAt = &now + if err = write(); err != nil { + return err + } + sent = true + return nil + }) + return sent, err +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/daily_report_test.go b/vehicle-data-platform/apps/api/internal/gzev/daily_report_test.go new file mode 100644 index 00000000..951164c0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/daily_report_test.go @@ -0,0 +1,112 @@ +package gzev + +import ( + "context" + "errors" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestDailyReportSnapshotAndMissingDates(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectBegin() + mock.ExpectQuery("SELECT DATE_FORMAT.*FROM gzev_mileage_day").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "success"}).AddRow("2026-10-21", time.Date(2026, 10, 23, 2, 0, 0, 0, time.UTC)).AddRow("2026-10-22", time.Date(2026, 10, 23, 2, 1, 0, 0, time.UTC))) + mock.ExpectQuery("SELECT DATE_FORMAT.*MAX.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date"}).AddRow("2026-10-21")) + mock.ExpectQuery("SELECT DATE_FORMAT.*COUNT.*FROM gzev_daily_mileage").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"date", "rows", "known"}).AddRow("2026-10-21", 426, 1).AddRow("2026-10-22", 426, 0)) + mock.ExpectQuery("SELECT active FROM gzev_auth_alert").WithArgs("account").WillReturnRows(sqlmock.NewRows([]string{"active"}).AddRow(false)) + mock.ExpectQuery("SELECT COUNT.*FROM gzev_mileage_attempt").WithArgs("account", "2026-10-20", "2026-10-22").WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectCommit() + r, err := LoadDailyReport(context.Background(), db, "account", "2026-10-20", time.Date(2026, 10, 23, 2, 30, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if r.Date != "2026-10-23" || r.Expected != 3 || r.Fetched != 2 || len(r.Missing) != 1 || r.Missing[0] != "2026-10-20" || r.LastSuccess != "2026-10-23 10:01:00" || r.RetryDates != 1 { + t.Fatalf("incorrect snapshot: %+v", r) + } + _, body := r.Message() + if !strings.Contains(body, "仍有 1 个日期待采集") || !strings.Contains(body, "| 2026-10-22 | 426 | 0 | 426 |") { + t.Fatal(body) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestDailyReportConclusionDistinguishesCoverageFromNull(t *testing.T) { + r := DailyReport{Date: "2026-10-23", Through: "2026-10-22", Recent: []ReportDay{{Date: "2026-10-22", Rows: 426, Known: 0}}} + _, body := r.Message() + if !strings.Contains(body, "昨天上游纯氢里程全部为空") { + t.Fatal(body) + } + r.Recent[0].Known = 426 + _, body = r.Message() + if !strings.Contains(body, "同步已覆盖全部目标日期") || strings.Contains(body, "昨天上游纯氢里程全部为空") { + t.Fatal(body) + } + r.AuthExpired = true + _, body = r.Message() + if !strings.Contains(body, "登录已失效") { + t.Fatal(body) + } +} + +func TestDailyReportDeliveryDeduplicatesAndPreservesAmbiguousAttempt(t *testing.T) { + for _, fail := range []bool{false, true} { + t.Run(map[bool]string{false: "accepted", true: "timeout"}[fail], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "report.json") + calls := 0 + send := func() error { + calls++ + if fail { + return errors.New("timeout") + } + return nil + } + sent, err := DeliverDailyReport(path, "2026-10-23", send) + if sent == fail || (err != nil) != fail { + t.Fatalf("sent=%v err=%v", sent, err) + } + sent, err = DeliverDailyReport(path, "2026-10-23", send) + if sent || calls != 1 || (err != nil) != fail { + t.Fatalf("duplicate was sent: calls=%d err=%v", calls, err) + } + info, _ := os.Stat(path) + if info.Mode().Perm() != 0600 { + t.Fatal("receipt is not private") + } + if sent, err = DeliverDailyReport(path, "2026-10-24", func() error { calls++; return nil }); !sent || err != nil || calls != 2 { + t.Fatal("next day was blocked") + } + }) + } +} + +func TestDailyReportPushDoesNotCreateRecoveryTicket(t *testing.T) { + s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json")} + s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + f, _ := url.ParseQuery(string(b)) + if f.Get("title") != "daily report" || f.Get("desp") != "summary" { + t.Fatal("incorrect report") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0,"data":{"errno":0}}`))}, nil + })} + if err := s.SendMessage(context.Background(), "daily report", "summary"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(s.SessionPath + ".recovery.json"); !os.IsNotExist(err) { + t.Fatal("daily summary created a recovery ticket") + } +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/recovery.go b/vehicle-data-platform/apps/api/internal/gzev/recovery.go new file mode 100644 index 00000000..7f7a73fc --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/recovery.go @@ -0,0 +1,80 @@ +package gzev + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "syscall" + "time" +) + +type recoveryTicket struct { + Code string `json:"code"` + Expires time.Time `json:"expires"` +} + +func recoveryLock(path string, fn func() error) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + f, err := os.OpenFile(path+".lock", os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return err + } + defer f.Close() + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + return fn() +} +func EnsureRecoveryCode(path string) (string, error) { + var code string + err := recoveryLock(path, func() error { + b, err := os.ReadFile(path) + var ticket recoveryTicket + if err == nil && json.Unmarshal(b, &ticket) == nil && ticket.Expires.After(time.Now()) && len(ticket.Code) == 48 { + code = ticket.Code + return nil + } + nonce := make([]byte, 24) + if _, err := rand.Read(nonce); err != nil { + return err + } + code = hex.EncodeToString(nonce) + ticket = recoveryTicket{Code: code, Expires: time.Now().Add(48 * time.Hour)} + f, err := os.CreateTemp(filepath.Dir(path), ".recovery-*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + if err := json.NewEncoder(f).Encode(ticket); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), path) + }) + return code, err +} + +// ConsumeRecovery serializes all writers and only consumes a ticket after a successful update. +func ConsumeRecovery(path, code string, action func() error) error { + return recoveryLock(path, func() error { + b, err := os.ReadFile(path) + var ticket recoveryTicket + if err != nil || json.Unmarshal(b, &ticket) != nil || !ticket.Expires.After(time.Now()) || len(code) != 48 || subtle.ConstantTimeCompare([]byte(code), []byte(ticket.Code)) != 1 { + return errors.New("invalid recovery code") + } + if err := action(); err != nil { + return err + } + return os.Remove(path) + }) +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/serverchan.go b/vehicle-data-platform/apps/api/internal/gzev/serverchan.go new file mode 100644 index 00000000..e4c1c1b0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/serverchan.go @@ -0,0 +1,84 @@ +package gzev + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "time" +) + +type ServerChan struct { + SendKey, SessionPath, UploadURL string + HTTP *http.Client +} + +func ServerChanFromEnv() ServerChan { + path := os.Getenv("GZEV_SESSION_FILE") + if path == "" { + path = "/var/lib/lingniu-gzev/session.json" + } + return ServerChan{SendKey: strings.TrimSpace(os.Getenv("SERVERCHAN_SENDKEY")), SessionPath: path, UploadURL: os.Getenv("GZEV_SESSION_UPLOAD_URL"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}} +} +func (s ServerChan) Send(ctx context.Context) error { return s.send(ctx, false) } +func (s ServerChan) SendTest(ctx context.Context) error { return s.send(ctx, true) } +func (s ServerChan) send(ctx context.Context, test bool) error { + if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) { + return errors.New("ServerChan SendKey missing or invalid") + } + if s.UploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" { + return errors.New("unsupported recovery URL") + } + code, err := EnsureRecoveryCode(s.SessionPath + ".recovery.json") + if err != nil { + return errors.New("cannot create recovery code") + } + title := "广州里程采集登录失效" + intro := "定时采集前验证未通过,当前采集已暂停。请登录广州平台并提交新的登录状态。" + if test { + title = "广州里程采集推送测试" + intro = "这是 Server酱推送渠道连通性测试,不表示登录状态已失效。以后采集前发现登录失效,将通过此渠道通知。" + } + body := fmt.Sprintf("%s\n\n[打开登录状态上传页面](%s)\n\n通知恢复码:\n\n`%s`\n\n恢复码有效期 48 小时,成功上传后失效。上传页面支持粘贴 token 或上传会话 JSON;验证通过后,下次定时采集自动继续。请勿转发恢复码。", intro, s.UploadURL, code) + return s.SendMessage(ctx, title, body) +} + +// SendMessage uses the existing configured channel without creating a recovery ticket. +func (s ServerChan) SendMessage(ctx context.Context, title, body string) error { + if !regexp.MustCompile(`^SCT[A-Za-z0-9]{8,253}$`).MatchString(s.SendKey) { + return errors.New("ServerChan SendKey missing or invalid") + } + form := url.Values{"title": {title}, "desp": {body}} + req, err := http.NewRequestWithContext(ctx, "POST", "https://sctapi.ftqq.com/"+s.SendKey+".send", strings.NewReader(form.Encode())) + if err != nil { + return errors.New("cannot create push request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := s.HTTP.Do(req) + if err != nil { + return errors.New("ServerChan transport failure; delivery status may be unknown") + } + defer resp.Body.Close() + var result struct { + Code *int `json:"code"` + Data struct { + Errno *int `json:"errno"` + } `json:"data"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code == nil { + return errors.New("ServerChan did not confirm acceptance") + } + if *result.Code != 0 { + return fmt.Errorf("ServerChan rejected push (code=%d)", *result.Code) + } + if result.Data.Errno != nil && *result.Data.Errno != 0 { + return fmt.Errorf("ServerChan channel error (errno=%d)", *result.Data.Errno) + } + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/serverchan_test.go b/vehicle-data-platform/apps/api/internal/gzev/serverchan_test.go new file mode 100644 index 00000000..a8a9e903 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/serverchan_test.go @@ -0,0 +1,59 @@ +package gzev + +import ( + "context" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "testing" +) + +func TestServerChanPushAndAcceptance(t *testing.T) { + for _, response := range []string{`{"code":0,"data":{"errno":0}}`, `{"code":40001}`, `{}`, `{"code":0,"data":{"errno":1}}`} { + t.Run(response, func(t *testing.T) { + calls := 0 + s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"} + s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + if r.Method != "POST" || r.URL.Host != "sctapi.ftqq.com" || r.URL.Path != "/SCTtest12345678.send" { + t.Fatal("wrong endpoint") + } + b, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(b)) + if form.Get("title") != "广州里程采集登录失效" || !strings.Contains(form.Get("desp"), s.UploadURL) || !strings.Contains(form.Get("desp"), "通知恢复码") { + t.Fatal("missing actionable content") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(response)), Header: http.Header{}}, nil + })} + err := s.Send(context.Background()) + if (err == nil) != (response == `{"code":0,"data":{"errno":0}}`) || calls != 1 { + t.Fatalf("unexpected acceptance: %v", err) + } + if err != nil && strings.Contains(err.Error(), s.SendKey) { + t.Fatal("key leaked") + } + }) + } +} +func TestServerChanMissingKeyDoesNotCreateTicket(t *testing.T) { + s := ServerChan{SessionPath: filepath.Join(t.TempDir(), "session.json")} + if s.Send(context.Background()) == nil { + t.Fatal("unconfigured notification accepted") + } +} +func TestServerChanTestIsClearlyLabeled(t *testing.T) { + s := ServerChan{SendKey: "SCTtest12345678", SessionPath: filepath.Join(t.TempDir(), "session.json"), UploadURL: "https://vehicle.d.lnoneos.com/gzev/session/"} + s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + f, _ := url.ParseQuery(string(b)) + if !strings.Contains(f.Get("title"), "测试") || !strings.Contains(f.Get("desp"), "不表示登录状态已失效") { + t.Fatal("false expiry alert") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"code":0}`)), Header: http.Header{}}, nil + })} + if err := s.SendTest(context.Background()); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/sms.go b/vehicle-data-platform/apps/api/internal/gzev/sms.go new file mode 100644 index 00000000..99ac1a19 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/sms.go @@ -0,0 +1,122 @@ +package gzev + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" +) + +type SMS struct { + AccessKeyID, AccessKeySecret, SecurityToken, SignName, TemplateCode, TemplateParam string + HTTP *http.Client +} + +func SMSFromEnv() SMS { + return SMS{AccessKeyID: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_ID"), AccessKeySecret: os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET"), SecurityToken: os.Getenv("ALIBABA_CLOUD_SECURITY_TOKEN"), SignName: os.Getenv("GZEV_SMS_SIGN_NAME"), TemplateCode: os.Getenv("GZEV_SMS_TEMPLATE_CODE"), TemplateParam: os.Getenv("GZEV_SMS_TEMPLATE_PARAM"), HTTP: &http.Client{Timeout: 20 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}} +} +func hashHex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) } + +// ACS3 native HTTP signing follows Alibaba Cloud's published V3 specification. +func signACS3(req *http.Request, payload, accessKey, secret string) { + req.Header.Set("x-acs-content-sha256", hashHex([]byte(payload))) + headers := map[string]string{"host": req.URL.Host} + for key, values := range req.Header { + k := strings.ToLower(key) + if k == "content-type" || strings.HasPrefix(k, "x-acs-") { + headers[k] = strings.TrimSpace(strings.Join(values, ",")) + } + } + names := make([]string, 0, len(headers)) + for k := range headers { + names = append(names, k) + } + sort.Strings(names) + var canonical strings.Builder + for _, k := range names { + fmt.Fprintf(&canonical, "%s:%s\n", k, headers[k]) + } + signed := strings.Join(names, ";") + canonicalRequest := req.Method + "\n/\n" + req.URL.RawQuery + "\n" + canonical.String() + "\n" + signed + "\n" + hashHex([]byte(payload)) + stringToSign := "ACS3-HMAC-SHA256\n" + hashHex([]byte(canonicalRequest)) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(stringToSign)) + req.Header.Set("Authorization", "ACS3-HMAC-SHA256 Credential="+accessKey+",SignedHeaders="+signed+",Signature="+hex.EncodeToString(mac.Sum(nil))) +} +func (s SMS) Send(ctx context.Context) error { + if s.AccessKeyID == "" || s.AccessKeySecret == "" || s.SignName == "" || s.TemplateCode == "" { + return errors.New("Aliyun SMS not configured") + } + param := s.TemplateParam + if param == "" { + param = "{}" + } + var params map[string]string + if json.Unmarshal([]byte(param), ¶ms) != nil || params == nil { + return errors.New("SMS TemplateParam must be a JSON string map") + } + // Template must contain these two variables for a user-actionable recovery message. + uploadURL := os.Getenv("GZEV_SESSION_UPLOAD_URL") + if uploadURL != "" { + if uploadURL != "https://vehicle.d.lnoneos.com/gzev/session/" { + return errors.New("unsupported recovery URL") + } + path := os.Getenv("GZEV_SESSION_FILE") + if path == "" { + path = "/var/lib/lingniu-gzev/session.json" + } + code, err := EnsureRecoveryCode(path + ".recovery.json") + if err != nil { + return errors.New("cannot create recovery code") + } + params["upload_url"] = uploadURL + params["recovery_code"] = code + data, err := json.Marshal(params) + if err != nil { + return err + } + param = string(data) + } + form := url.Values{"PhoneNumbers": {"16605225837"}, "SignName": {s.SignName}, "TemplateCode": {s.TemplateCode}, "TemplateParam": {param}} + payload := form.Encode() + req, err := http.NewRequestWithContext(ctx, "POST", "https://dysmsapi.aliyuncs.com/", strings.NewReader(payload)) + if err != nil { + return err + } + nonce := make([]byte, 16) + if _, err = rand.Read(nonce); err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("x-acs-action", "SendSms") + req.Header.Set("x-acs-version", "2017-05-25") + req.Header.Set("x-acs-date", time.Now().UTC().Format("2006-01-02T15:04:05Z")) + req.Header.Set("x-acs-signature-nonce", hex.EncodeToString(nonce)) + if s.SecurityToken != "" { + req.Header.Set("x-acs-security-token", s.SecurityToken) + } + signACS3(req, payload, s.AccessKeyID, s.AccessKeySecret) + resp, err := s.HTTP.Do(req) + if err != nil { + return errors.New("SMS transport failure; delivery status may be unknown") + } + defer resp.Body.Close() + var result struct { + Code string `json:"Code"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result) != nil || resp.StatusCode != 200 || result.Code != "OK" { + return errors.New("SMS provider did not confirm acceptance") + } + return nil +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/sms_test.go b/vehicle-data-platform/apps/api/internal/gzev/sms_test.go new file mode 100644 index 00000000..1d5bc817 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/sms_test.go @@ -0,0 +1,73 @@ +package gzev + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestSMSRequestAndBusinessFailure(t *testing.T) { + for _, code := range []string{"OK", "isv.BUSINESS_LIMIT_CONTROL"} { + calls := 0 + s := SMS{AccessKeyID: "test-id", AccessKeySecret: "test-secret", SecurityToken: "sts-token", SignName: "测试签名", TemplateCode: "SMS_TEST", TemplateParam: `{"system":"广州里程"}`} + s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + b, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(b)) + if r.URL.String() != "https://dysmsapi.aliyuncs.com/" || form.Get("PhoneNumbers") != "16605225837" || form.Get("TemplateCode") != "SMS_TEST" { + t.Fatal("wrong SMS destination") + } + if r.Header.Get("x-acs-content-sha256") != hashHex(b) || r.Header.Get("x-acs-action") != "SendSms" || !strings.Contains(r.Header.Get("Authorization"), "x-acs-security-token") { + t.Fatal("incorrect signed request") + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"Code":"` + code + `"}`)), Header: http.Header{}}, nil + })} + err := s.Send(context.Background()) + if (err == nil) != (code == "OK") || calls != 1 { + t.Fatalf("%s: %v", code, err) + } + } +} +func TestSMSMissingConfigDoesNotSend(t *testing.T) { + if (SMS{}).Send(context.Background()) == nil { + t.Fatal("missing config reported success") + } +} + +func TestSMSIncludesUploadURLAndSingleUseRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.json") + t.Setenv("GZEV_SESSION_FILE", path) + t.Setenv("GZEV_SESSION_UPLOAD_URL", "https://vehicle.d.lnoneos.com/gzev/session/") + s := SMS{AccessKeyID: "id", AccessKeySecret: "secret", SignName: "sign", TemplateCode: "SMS_TEST"} + var code string + s.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + form, _ := url.ParseQuery(string(b)) + var params map[string]string + if json.Unmarshal([]byte(form.Get("TemplateParam")), ¶ms) != nil { + t.Fatal("invalid template params") + } + if params["upload_url"] != "https://vehicle.d.lnoneos.com/gzev/session/" || len(params["recovery_code"]) != 48 { + t.Fatal("missing actionable recovery information") + } + code = params["recovery_code"] + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"Code":"OK"}`))}, nil + })} + if err := s.Send(context.Background()); err != nil { + t.Fatal(err) + } + if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err != nil { + t.Fatal("SMS code not usable") + } + if err := ConsumeRecovery(path+".recovery.json", code, func() error { return nil }); err == nil { + t.Fatal("SMS code reused") + } +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/store.go b/vehicle-data-platform/apps/api/internal/gzev/store.go new file mode 100644 index 00000000..8c171546 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/store.go @@ -0,0 +1,168 @@ +package gzev + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" +) + +var ErrBusy = errors.New("another GZEV worker holds the account lock") + +type Store struct { + Conn *sql.Conn + Account string +} + +// WithLock uses a dedicated connection: MySQL advisory locks are connection scoped. +func WithLock(ctx context.Context, db *sql.DB, account string, fn func(*Store) error) error { + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + sum := sha256.Sum256([]byte(account)) + key := "gzev:" + hex.EncodeToString(sum[:24]) + var ok int + if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,0)", key).Scan(&ok); err != nil { + return err + } + if ok != 1 { + return ErrBusy + } + defer func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", key) + }() + return fn(&Store{Conn: conn, Account: account}) +} +func (s *Store) NeedsDay(ctx context.Context, date string, recent bool) (bool, error) { + var cooling bool + err := s.Conn.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM gzev_mileage_attempt WHERE account=? AND stat_date=? AND retry_after>UTC_TIMESTAMP(3))", s.Account, date).Scan(&cooling) + if err != nil || cooling { + return false, err + } + if recent { + return true, nil + } + var retry bool + err = s.Conn.QueryRowContext(ctx, "SELECT missing_count>0 AND last_success_at 0 { + return errors.New("source vehicle list shrank; previous day retained for review") + } + _, err = tx.ExecContext(ctx, "INSERT IGNORE INTO gzev_mileage_snapshot(account,stat_date,snapshot_hash,fetched_at,raw_pages) VALUES(?,?,?,?,?)", s.Account, d.Date, checksum, d.FetchedAt.UTC(), string(raw)) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, "DELETE FROM gzev_daily_mileage WHERE account=? AND stat_date=?", s.Account, d.Date) + if err != nil { + return err + } + missing := 0 + for _, r := range d.Rows { + status := "reported" + if !r.Total.Valid || !r.Hydrogen.Valid || !r.Charge.Valid { + status = "missing" + missing++ + } + _, err = tx.ExecContext(ctx, `INSERT INTO gzev_daily_mileage(account,vin,stat_date,plate_number,vehicle_seq,production_company,operation_company,brand,model,total_km,hydrogen_km,charge_km,quality_status,raw_row,fetched_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, s.Account, r.VIN, d.Date, r.Plate, r.VehicleSeq, r.ProductionCompany, r.OperationCompany, r.Brand, r.Model, r.Total.SQLValue(), r.Hydrogen.SQLValue(), r.Charge.SQLValue(), status, string(r.Raw), d.FetchedAt.UTC()) + if err != nil { + return err + } + } + _, err = tx.ExecContext(ctx, `INSERT INTO gzev_mileage_day(account,stat_date,row_count,missing_count,last_success_at,snapshot_hash) VALUES(?,?,?,?,?,?) ON DUPLICATE KEY UPDATE row_count=VALUES(row_count),missing_count=VALUES(missing_count),last_success_at=VALUES(last_success_at),snapshot_hash=VALUES(snapshot_hash)`, s.Account, d.Date, len(d.Rows), missing, d.FetchedAt.UTC(), checksum) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, "DELETE FROM gzev_mileage_attempt WHERE account=? AND stat_date=?", s.Account, d.Date); err != nil { + return err + } + return tx.Commit() +} +func (s *Store) ResolveAuth(ctx context.Context) error { + _, err := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET active=FALSE,resolved_at=UTC_TIMESTAMP(3) WHERE account=? AND active=TRUE", s.Account) + return err +} + +// AuthFailure persists delivery state. Retry at most hourly and remind at most daily. +// A provider timeout is ambiguous: no immediate retry to avoid duplicate notifications. +func (s *Store) AuthFailure(ctx context.Context, send func(context.Context) error) error { + _, err := s.Conn.ExecContext(ctx, `INSERT INTO gzev_auth_alert(account,first_failed_at) VALUES(?,UTC_TIMESTAMP(3)) ON DUPLICATE KEY UPDATE first_failed_at=IF(active,first_failed_at,UTC_TIMESTAMP(3)),last_attempt_at=IF(active,last_attempt_at,NULL),last_sent_at=IF(active,last_sent_at,NULL),resolved_at=NULL,active=TRUE`, s.Account) + if err != nil { + return err + } + var due bool + err = s.Conn.QueryRowContext(ctx, `SELECT (last_attempt_at IS NULL OR last_attempt_at<=UTC_TIMESTAMP()-INTERVAL 1 HOUR) AND (last_sent_at IS NULL OR last_sent_at<=UTC_TIMESTAMP()-INTERVAL 24 HOUR) FROM gzev_auth_alert WHERE account=?`, s.Account).Scan(&due) + if err != nil || !due { + return err + } + _, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_attempt_at=UTC_TIMESTAMP(3) WHERE account=?", s.Account) + if err != nil { + return err + } + if err = send(ctx); err != nil { + _, persistErr := s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_error='Notification delivery failed or not configured' WHERE account=?", s.Account) + return errors.Join(fmt.Errorf("auth notification: %w", err), persistErr) + } + _, err = s.Conn.ExecContext(ctx, "UPDATE gzev_auth_alert SET last_sent_at=UTC_TIMESTAMP(3),last_error='' WHERE account=?", s.Account) + return err +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/store_test.go b/vehicle-data-platform/apps/api/internal/gzev/store_test.go new file mode 100644 index 00000000..041b4e1c --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/store_test.go @@ -0,0 +1,79 @@ +package gzev + +import ( + "context" + "encoding/json" + "errors" + "github.com/DATA-DOG/go-sqlmock" + "testing" + "time" +) + +func TestSaveDayRollsBackIfAnyRowFails(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + conn, _ := db.Conn(context.Background()) + defer conn.Close() + store := Store{conn, "account"} + mock.ExpectBegin() + mock.ExpectQuery("SELECT vin").WithArgs("account", "2024-01-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"})) + mock.ExpectExec("INSERT IGNORE INTO gzev_mileage_snapshot").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("DELETE FROM gzev_daily_mileage").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("INSERT INTO gzev_daily_mileage").WillReturnError(errors.New("disk full")) + mock.ExpectRollback() + d := Day{Date: "2024-01-01", FetchedAt: time.Now(), Pages: []json.RawMessage{json.RawMessage(`{}`)}, Rows: []Row{{VIN: "LNXNEGRR7SR319456", Raw: json.RawMessage(`{}`)}}} + if store.SaveDay(context.Background(), d) == nil { + t.Fatal("expected transaction failure") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} +func TestSaveRejectsNullRegression(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + conn, _ := db.Conn(context.Background()) + defer conn.Close() + store := Store{conn, "account"} + mock.ExpectBegin() + mock.ExpectQuery("SELECT vin").WillReturnRows(sqlmock.NewRows([]string{"vin", "total", "hydrogen", "charge"}).AddRow("LNXNEGRR7SR319456", true, false, false)) + mock.ExpectRollback() + if store.SaveDay(context.Background(), Day{Date: "2024-01-01", Rows: []Row{{VIN: "LNXNEGRR7SR319456"}}}) == nil { + t.Fatal("overwrote measured value with null") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} +func TestAuthNotificationCooldown(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + conn, _ := db.Conn(context.Background()) + defer conn.Close() + store := Store{conn, "account"} + mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(false)) + if err := store.AuthFailure(context.Background(), func(context.Context) error { t.Fatal("duplicate SMS sent"); return nil }); err != nil { + t.Fatal(err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} +func TestAuthNotificationFailureIsPersisted(t *testing.T) { + db, mock, _ := sqlmock.New() + defer db.Close() + conn, _ := db.Conn(context.Background()) + defer conn.Close() + store := Store{conn, "account"} + mock.ExpectExec("INSERT INTO gzev_auth_alert").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery("SELECT .*last_attempt_at").WillReturnRows(sqlmock.NewRows([]string{"due"}).AddRow(true)) + mock.ExpectExec("UPDATE gzev_auth_alert SET last_attempt_at").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("UPDATE gzev_auth_alert SET last_error").WillReturnResult(sqlmock.NewResult(0, 1)) + if err := store.AuthFailure(context.Background(), func(context.Context) error { return errors.New("not configured") }); err == nil { + t.Fatal("lost delivery failure") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/gzev/upload.go b/vehicle-data-platform/apps/api/internal/gzev/upload.go new file mode 100644 index 00000000..41e9ea5c --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/upload.go @@ -0,0 +1,161 @@ +package gzev + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// UploadHandler accepts local SSH access or the configured HTTPS origin with a one-use recovery code. +type UploadHandler struct { + PublicOrigin string + RecoveryPath string + SessionPath string + Account string + Verify func(context.Context, Session, string) error + csrf string +} + +func NewUploadHandler(path, account string) (*UploadHandler, error) { + nonce := make([]byte, 32) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + return &UploadHandler{SessionPath: path, Account: account, csrf: hex.EncodeToString(nonce), Verify: func(ctx context.Context, s Session, a string) error { return NewClient(s).VerifyAccount(ctx, a) }}, nil +} +func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + // Keep the Origin header on same-origin form POSTs; no-referrer makes + // browsers send Origin: null, which the strict check below must reject. + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'") + local := r.Host == "127.0.0.1:20312" || r.Host == "localhost:20312" + public := h.PublicOrigin != "" && "https://"+r.Host == h.PublicOrigin + if !local && !public { + http.Error(w, "仅允许通过 SSH 安全连接访问", 403) + return + } + if r.URL.Path != "/" && r.URL.Path != "/gzev/session/" { + http.NotFound(w, r) + return + } + if r.Method == http.MethodGet { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + uploadPage.Execute(w, map[string]string{"Account": h.Account, "CSRF": h.csrf, "Public": fmt.Sprint(h.PublicOrigin != "")}) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", "GET, POST") + http.Error(w, "Method not allowed", 405) + return + } + expectedOrigin := "http://" + r.Host + if public { + expectedOrigin = h.PublicOrigin + } + if r.Header.Get("Origin") != expectedOrigin { + http.Error(w, "请求来源不正确,请刷新上传页面", 403) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 32<<10) + if err := r.ParseMultipartForm(32 << 10); err != nil { + http.Error(w, "文件或表单无效,大小不能超过 32 KB", 400) + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(h.csrf)) != 1 { + http.Error(w, "页面已过期,请刷新后重新提交", 403) + return + } + var s Session + file, _, fileErr := r.FormFile("session") + if fileErr == nil { + defer file.Close() + dec := json.NewDecoder(file) + dec.DisallowUnknownFields() + if err := dec.Decode(&s); err != nil { + http.Error(w, "会话文件格式错误", 400) + return + } + if dec.Decode(new(any)) != io.EOF { + http.Error(w, "会话文件含多余内容", 400) + return + } + } else if fileErr == http.ErrMissingFile { + token := strings.TrimSpace(r.FormValue("token")) + token = strings.TrimPrefix(token, "Bearer ") + s = Session{Authorization: "Bearer " + token, Cookies: []SessionCookie{{Name: "token", Value: token}}} + if routing := strings.TrimSpace(r.FormValue("routing")); routing != "" { + s.Cookies = append(s.Cookies, SessionCookie{Name: "bigs", Value: routing}) + } + } else { + http.Error(w, "无法读取上传文件", 400) + return + } + if ValidateSession(s) != nil { + http.Error(w, "会话格式错误,只接受 token 和可选的 bigs Cookie", 400) + return + } + action := func() error { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + if err := h.Verify(ctx, s, h.Account); err != nil { + return errors.New("verification failed") + } + return WriteSession(h.SessionPath, s) + } + var err error + if h.PublicOrigin != "" { + err = ConsumeRecovery(h.RecoveryPath, r.FormValue("recovery_code"), action) + } else { + err = action() + } + if err != nil { + http.Error(w, "验证或保存未通过。请检查恢复码、登录状态及账号;现有会话未覆盖。", 422) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + io.WriteString(w, "更新成功

登录状态已更新

验证通过,下一次定时采集将自动继续补齐里程数据。

返回") +} +func WriteSession(path string, s Session) error { + if err := ValidateSession(s); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + f, err := os.CreateTemp(filepath.Dir(path), ".session-*") + if err != nil { + return err + } + name := f.Name() + defer os.Remove(name) + if err := json.NewEncoder(f).Encode(s); err != nil { + f.Close() + return err + } + if err := f.Sync(); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(name, path) +} + +var uploadPage = template.Must(template.New("upload").Parse(`更新广州平台登录状态

更新广州平台登录状态

请先登录广州平台,账号:{{.Account}}。

提交后会验证账号和登录状态。验证通过后,下一次定时采集自动继续。

{{if eq .Public "true"}}{{end}}
上传文件时优先使用文件内容。不要提交账号密码或短信验证码。
如何取得 token?
  1. 在 Chrome 登录广州平台,完成必要的短信和图形验证码。
  2. 打开开发者工具 → Application → Cookies → gzev.d.lnoneos.com。
  3. 复制 token 的值;如有 bigs,也一并复制。
  4. 回到此页面粘贴并提交。
`)) diff --git a/vehicle-data-platform/apps/api/internal/gzev/upload_test.go b/vehicle-data-platform/apps/api/internal/gzev/upload_test.go new file mode 100644 index 00000000..a41036b5 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/gzev/upload_test.go @@ -0,0 +1,102 @@ +package gzev + +import ( + "bytes" + "context" + "errors" + "mime/multipart" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func uploadRequest(t *testing.T, h *UploadHandler, origin, code string) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + form := multipart.NewWriter(&body) + form.WriteField("csrf", h.csrf) + form.WriteField("token", "new-session") + form.WriteField("recovery_code", code) + form.Close() + req := httptest.NewRequest("POST", "https://vehicle.d.lnoneos.com/gzev/session/", &body) + req.Header.Set("Content-Type", form.FormDataContentType()) + req.Header.Set("Origin", origin) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} +func TestPublicUploadRequiresCodeAndConsumesIt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.json") + h, _ := NewUploadHandler(path, "expected") + h.PublicOrigin = "https://vehicle.d.lnoneos.com" + h.RecoveryPath = path + ".recovery.json" + calls := 0 + h.Verify = func(_ context.Context, s Session, account string) error { + calls++ + if account != "expected" || s.Authorization != "Bearer new-session" { + t.Fatal("wrong account or session") + } + return nil + } + if r := uploadRequest(t, h, h.PublicOrigin, ""); r.Code != 422 || calls != 0 { + t.Fatal("missing code reached verification") + } + code, err := EnsureRecoveryCode(h.RecoveryPath) + if err != nil { + t.Fatal(err) + } + for _, origin := range []string{"https://attacker.invalid", "null", ""} { + if r := uploadRequest(t, h, origin, code); r.Code != 403 || calls != 0 { + t.Fatalf("untrusted origin %q accepted", origin) + } + } + if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 200 { + t.Fatalf("upload failed: %d %s", r.Code, r.Body.String()) + } + session, err := ReadSession(path) + if err != nil || session.Authorization != "Bearer new-session" { + t.Fatal("session was not securely saved") + } + if r := uploadRequest(t, h, h.PublicOrigin, code); r.Code != 422 || calls != 1 { + t.Fatal("reused code accepted") + } +} +func TestInvalidSessionDoesNotOverwriteOrConsume(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.json") + os.WriteFile(path, []byte("original"), 0600) + h, _ := NewUploadHandler(path, "expected") + h.PublicOrigin = "https://vehicle.d.lnoneos.com" + h.RecoveryPath = path + ".recovery.json" + h.Verify = func(context.Context, Session, string) error { return errors.New("expired credential") } + code, _ := EnsureRecoveryCode(h.RecoveryPath) + r := uploadRequest(t, h, h.PublicOrigin, code) + if r.Code != 422 { + t.Fatal(r.Code) + } + b, _ := os.ReadFile(path) + if string(b) != "original" { + t.Fatal("overwrote valid previous file") + } + if _, err := os.Stat(h.RecoveryPath); err != nil { + t.Fatal("failed upload consumed recovery code") + } + if strings.Contains(r.Body.String(), "new-session") { + t.Fatal("response leaked token") + } +} +func TestUploadRejectsUntrustedHostAndHidesToken(t *testing.T) { + h, _ := NewUploadHandler(filepath.Join(t.TempDir(), "session.json"), "expected") + r := httptest.NewRecorder() + h.ServeHTTP(r, httptest.NewRequest("GET", "http://attacker.invalid/", nil)) + if r.Code != 403 { + t.Fatal(r.Code) + } + r = httptest.NewRecorder() + h.ServeHTTP(r, httptest.NewRequest("GET", "http://127.0.0.1:20312/", nil)) + if r.Code != 200 || r.Header().Get("Cache-Control") != "no-store" || r.Header().Get("Referrer-Policy") != "same-origin" || !strings.Contains(r.Body.String(), "验证并更新") { + t.Fatal("upload UI unavailable") + } +} diff --git a/vehicle-data-platform/apps/api/internal/hydrogen/mass.go b/vehicle-data-platform/apps/api/internal/hydrogen/mass.go new file mode 100644 index 00000000..b1847dc3 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/hydrogen/mass.go @@ -0,0 +1,26 @@ +package hydrogen + +import "math" + +// PressureMassKg converts pressure (MPa), temperature (C) and total water volume (L) +// using the existing real-gas compressibility equation. +func PressureMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) { + temperatureK := temperatureC + 273.15 + if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 { + return 0, false + } + a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403} + b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0} + c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5} + + z := 1.0 + for index := range a { + z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index]) + } + if z <= 0 { + return 0, false + } + density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000 + mass := density * capacityLiter / 1000 + return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500 +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html index f7595342..2af81f53 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html @@ -16,6 +16,9 @@ .auth{margin-top:20px;padding:17px 18px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.auth code{display:block;margin-top:8px;color:#38383a;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}.protocol-table{width:100%;margin-top:18px;border-collapse:collapse}.protocol-table th,.protocol-table td{padding:12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}.protocol-table th{color:var(--muted);font-size:13px;font-weight:500}.protocol-table td:first-child{width:170px;font:12px ui-monospace,SFMono-Regular,Menlo,monospace}.error-table{margin-top:18px}.request-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:21px}.request-sample{padding:15px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.request-sample h3{margin:0 0 6px;font-size:13px;font-weight:650}.request-sample .endpoint{margin:0 0 11px;font-size:11px}.request-sample .code{padding:12px;border-radius:9px;font-size:11px}@media(max-width:680px){.request-grid{grid-template-columns:1fr}.request-sample{padding:13px}} @media(max-width:900px){:root{--sidebar:218px}.content{padding:42px 34px 76px}.grid{grid-template-columns:1fr;gap:22px}} @media(max-width:680px){body{background:var(--surface)}.layout{display:block}.sidebar{z-index:3;display:flex;align-items:center;gap:0;position:sticky;width:100%;height:auto;padding:0 12px;border-right:0;border-bottom:1px solid var(--line);overflow-x:auto;overflow-y:hidden;white-space:nowrap;background:rgba(255,255,255,.92)}.brand{padding:13px 10px 13px 0}.brand-name{font-size:14px}.brand-subtitle,.nav-title{display:none}.nav-group{display:contents;border:0;padding:0}.nav-link{margin:0;padding:13px 9px;font-size:13px}.nav-link::before{display:none}.external{display:none}.content{padding:31px 20px 64px}.intro{padding-bottom:27px}h1{font-size:31px}.intro p{font-size:15px}.section{padding:31px 0;scroll-margin-top:54px}.section:target{margin:0 -8px;padding-right:8px;padding-left:8px}.section-head{gap:9px}.section h2{font-size:21px}.anchor{display:none}.grid{margin-top:19px}.param-table th,.param-table td{padding:9px 5px}.param-table th:nth-child(2),.param-table td:nth-child(2){width:47px}.base-url{max-width:100%;overflow:auto}.code{font-size:11px}} + + .content{min-width:0}.operating-subsection{margin-top:28px}.operating-subsection h3{margin:0 0 12px;font-size:18px}.operating-subsection h4{margin:20px 0 10px;font-size:14px}.operating-subsection .code{margin:12px 0}.operating-table-scroll{overflow-x:auto;margin:14px 0}.operating-table-scroll .param-table{min-width:620px}.operating-subsection .param-table td:first-child{overflow-wrap:anywhere;max-width:270px}.operating-list{padding-left:22px;color:#59595d}.operating-list li{margin:9px 0}.operating-subsection details{margin:14px 0}.operating-subsection summary{cursor:pointer;font-size:14px;font-weight:600;color:var(--blue);padding:8px 0}.operating-endpoint{display:block;line-height:1.8;overflow-wrap:anywhere;word-break:break-word}.operating-status{display:block;padding:12px 14px;border-left:3px solid var(--blue);background:var(--blue-soft);color:#34506e;font-size:13px;margin-top:18px} + @media print{body{background:#fff;font-size:10pt}.layout{display:block}.sidebar{display:none}.content{padding:0;max-width:none}.section{border:0;padding:12px 0}.operating-table-scroll{overflow:visible}.operating-table-scroll .param-table{min-width:0;font-size:8pt}.operating-subsection h3{break-after:avoid}tr{break-inside:avoid}.code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f2f3f5;color:#1d1d1f;font-size:8pt}.operating-list{color:#303033}details:not([open]){display:none}} @@ -24,7 +27,7 @@ 羚牛 · 数据开放平台车辆数据 API Reference @@ -37,9 +40,24 @@ Base URL https://open.d.lnoneos.com -

认证与授权

所有接口均使用平台管理员签发的 32 位 appKey。appKey 有效期及车辆授权期必须完整覆盖查询时间。

#
请求头Authorization: Bearer <YOUR_APP_KEY>
Content-Type: application/json
授权边界:不传车牌时,仅返回该 appKey 在查询日期或时间段内有效授权的全部车辆;显式传入的车牌未授权时,接口返回 403。所有接口响应均以两空格缩进的 JSON 返回,便于直接阅读和留存。
+

认证与授权

所有接口均使用平台管理员签发的 32 位 appKey。appKey 有效期及车辆授权期必须完整覆盖查询时间。

#
请求头Authorization: Bearer <YOUR_APP_KEY>
Content-Type: application/json
授权边界:车牌是否可省略以各接口参数表为准;综合运营查询车牌可选,省略或空数组返回所选时间范围内均有效授权的全部车辆,不受 200 辆限制;显式指定车牌最多 200 项。日统计授权覆盖查询自然日,实时授权覆盖请求时点,综合查询先校验全部所选范围。综合查询返回紧凑 JSON;下方示例已格式化。
-

请求体样例

所有接口使用 Content-Type: application/json。以下 JSON 可直接复制;未列出的可选字段可省略。

#

车辆单日用氢量

/vehicles/hydrogen-consumption/query
{
+      

请求体样例

所有接口使用 Content-Type: application/json。以下 JSON 可直接复制;未列出的可选字段可省略。

#

综合运营数据

/vehicles/operating-data/query
{
+  "dailyDate": "2026-10-23",
+  "plateNumbers": [
+    "粤A09635F"
+  ],
+  "dataTypes": [
+    "REALTIME",
+    "DAILY_MILEAGE",
+    "DAILY_HYDROGEN"
+  ],
+  "protocolPriority": [
+    "GB32960",
+    "MQTT",
+    "JT808"
+  ]
+}

车辆单日用氢量

/vehicles/hydrogen-consumption/query
{
   "date": "2026-08-06",
   "plateNumbers": ["浙F06618F"]
 }

车辆单日里程

/vehicles/mileage/query
{
@@ -79,11 +97,254 @@
   "maxTimeDifferenceSeconds": 300
 }

合法批量HTTP200/SUCCESS,逐项状态判断:NORMAL、NO_DATA、MISSING、STALE、UNSUPPORTED、INVALID、FORBIDDEN、ERROR。非NORMAL质量null,不丢项,不补零。

每app每实例30批/60秒,最多2个并发批;429读取Retry-After秒后重试。整批9秒预算、4个工作协程,超时项ERROR而不是NO_DATA。200项是结构上限,不保证200个不同点均能按时完成;建议从20个不同点开始,超时缩小批量并退避重试。

历史容量需要历史适用版本;当前表不能回套历史。当前只有REPORTED质量可用,缺历史容量版本时不估算,返回MISSING。同批按规范化VIN+time+protocol去重,同点共享单次查询/授权结果和来源版本,所有requestId独立回显;不同点仍是非原子快照,保留各项版本和原始证据。质量保留原精度,Seeker自行计算两端净减少量并在负差值时归零。
+

车辆综合运营数据批量查询

POST /api/v1/vehicles/operating-data/query

文档 V1.5 · OpenAPI 1.12.2 · 2026-10-26 · 随当前服务版本发布

#

一次请求按需返回实时、单日里程及单日用氢。SIMPLE模式固定9字段;默认FULL保留完整来源、质量及时间。两种模式共用授权和批量查询,不增加数据库查询,不表示共同快照。

精简模式 SIMPLE · 固定 9 字段

请求显式增加 responseMode: "SIMPLE"。省略或传 FULL 保留已上线完整响应。外层字段保持一致,data 每辆车仅返回下表9项,未知或未选字段显式 null。

{
+  "responseMode": "SIMPLE",
+  "dailyDate": "2026-10-23",
+  "plateNumbers": [
+    "沪A01933F"
+  ]
+}
字段类型返回规则
plateNumberstring规范化后的请求车牌,保持请求顺序。
reportTimestring | null实时选中主记录的event_time,真实源采集/上报时刻;缺失为null,不使用平台接收、投影更新或请求时间替代。FULL recordTime保持原兼容口径。
offlineboolean | null主实时记录可用且online已知时取反;无记录或查询失败为null,不能当成离线。
remainingHydrogenKgnumber | null剩余氢量kg。仅kg字段NORMAL、数值有效且有可信采集时间,响应时不超过300秒并不晚于服务时点1分钟时展示;陈旧/缺失/异常为null,真实0保留。
socPercentnumber | null动力电池SOC百分比;实时主记录有效且数值在0–100内,未知为null。
totalMileageKmnumber | null实时主记录累计里程km;不使用日统计累计值兜底。
dailyHydrogenConsumptionKgnumber | nullqueryDate用氢量kg;子查询成功、status=NORMAL、qualityStatus=OK时返回有效非负数值,否则null。日内值可为PRELIMINARY,不保证终报。
dailyMileageKmnumber | nullqueryDate里程km;执行成功、业务NORMAL且非CARRIED_FORWARD时返回有效非负数值;实测零保留,结转补零和异常为null。
hydrogenConsumptionPer100KmKgnumber | nullkg/100km预留字段;当前缺少可比区间完整证据,固定null,不参与summary计数。禁止调用方以同日日指标直接相除。

精简响应示例(合成数据)

{
+  "code": "SUCCESS",
+  "message": "success",
+  "traceId": "f924a83d8a93475bac26b054305fc427",
+  "queryDate": "2026-10-23",
+  "timezone": "Asia/Shanghai",
+  "requestAt": "2026-10-23T12:00:00+08:00",
+  "generatedAt": "2026-10-23T12:00:01+08:00",
+  "data": [
+    {
+      "plateNumber": "沪A01933F",
+      "reportTime": "2026-10-23T11:59:30+08:00",
+      "offline": false,
+      "remainingHydrogenKg": 16,
+      "socPercent": 78.8,
+      "totalMileageKm": 80707,
+      "dailyHydrogenConsumptionKg": 4.003,
+      "dailyMileageKm": 128,
+      "hydrogenConsumptionPer100KmKg": null
+    }
+  ],
+  "summary": {
+    "requestedCount": 1,
+    "returnedCount": 1,
+    "successCount": 1,
+    "partialSuccessCount": 0,
+    "failedCount": 0
+  }
+}
summary:仅统计所选类型的展示字段:实时5项、日里程1项、日用氢1项。全部有值计入successCount;部分有值计入partialSuccessCount;全部为空计入failedCount。车牌与预留百公里氢耗不参与;未选类型也不参与。真实0、offline=false均为有效值。null及failedCount不能直接作为自动重试依据。FULL模式原有执行统计不变。

离线不触发缓存或历史兜底;最新主记录自带reportTime,在线状态不决定SOC和总里程是否存在。剩余氢量额外执行300秒时效门槛。任何源失败只影响对应字段,不能把offline未知写成true。

当前没有可证明可比区间的正式百公里氢耗算法,因此hydrogenConsumptionPer100KmKg固定null。不能因为同一天有氢量和里程就自行相除。日内用氢可能是初步统计,正式对账继续使用FULL查询质量、阶段和来源。

请求参数

{
+  "dailyDate": "2026-10-23",
+  "plateNumbers": [
+    "粤A09635F"
+  ],
+  "dataTypes": [
+    "REALTIME",
+    "DAILY_MILEAGE",
+    "DAILY_HYDROGEN"
+  ],
+  "protocolPriority": [
+    "GB32960",
+    "MQTT",
+    "JT808"
+  ]
+}
参数必填规则
responseMode否FULL / SIMPLE;省略默认FULL,显式空字符串或null不合法。SIMPLE返回固定9字段,未选类型字段也为null。
plateNumbers否省略或传空数组时查询所选时间范围内均有效授权的全部车辆,按车牌排序;无有效授权车辆返回空数组。省略或空数组不受 200 辆限制,不分页、不截断;显式指定最多 200 项,超出返回 HTTP 400 / BATCH_SIZE_EXCEEDED。指定时每项为字符串,去首尾空白、转大写后非空、最多 32 个 Unicode 字符;不允许重复。
dailyDate条件必填选择任一日统计时必填,严格 yyyy-MM-dd,不晚于北京时间当日。仅实时可省略;若提供仍校验,但不影响实时选源或实时授权,queryDate 仍为 null。
dataTypes否省略默认 REALTIME、DAILY_MILEAGE、DAILY_HYDROGEN 全部;显式数组非空、不重复、不接受未知枚举。未选数据块从响应中省略。
protocolPriority否仅选择 DAILY_MILEAGE 时可提供。非空、不重复,仅 GB32960、MQTT、JT808。未列出的协议不兜底;省略沿用原里程选源。对实时及用氢不生效。
请求体最大 64 KiB。拒绝未知字段、显式 null、非法类型、多个 JSON 根对象,以及规范化后重复车牌。无新增固定历史下界;可查询日期不代表保留了完整历史统计。

仅查询实时

{
+  "plateNumbers": [
+    "粤A09635F"
+  ],
+  "dataTypes": [
+    "REALTIME"
+  ]
+}

不查询日统计,仅返回 realtime,queryDate=null。appKey 与所查车辆须在当前请求时点有效。

调用示例

curl --request POST \
+  --url https://open.d.lnoneos.com/api/v1/vehicles/operating-data/query \
+  --header 'Authorization: Bearer YOUR_APP_KEY' \
+  --header 'Content-Type: application/json' \
+  --data '{"plateNumbers":["粤A09635F"],"dailyDate":"2026-10-23"}'

认证、授权与车辆关联

Authorization: Bearer <32位appKey>
+Content-Type: application/json
  • appKey 当前启用且在有效期内。实时查询校验 appKey 与车辆授权覆盖 requestAt;车辆授权的结束时点不包含在有效区间中。
  • 日统计要求 appKey 和逐车授权完整覆盖 dailyDate 的北京时间自然日。授权不满一整天时,不开放半日统计。
  • 同时选择实时和历史统计时,两套授权都须通过;历史授权不能用于取得当前实时位置,当前授权也不能替代历史授权。
  • 先完成全部范围校验,再查询业务数据。任一车辆越权整批 403,不返回其他车辆或已成功块;授权依赖故障按 500 失败关闭。
  • 按 VIN 关联并复用固定的车牌/VIN 映射,保持请求顺序;映射冲突按内部错误处理,不猜测匹配。
  • appKey 由调用方后端持有,不嵌入公开前端或小程序。日志不记录明文 appKey。

返回结构与汇总

字段类型含义与空值规则
codestring固定 SUCCESS;不保证每个数据块均执行成功。
messagestring顶层处理说明。
traceIdstring服务端生成的唯一请求标识。
queryDatedate | null包含日统计时为dailyDate;仅实时为null。
timezonestring枚举:Asia/Shanghai。
requestAtdate-time固定请求基准时点,用于实时授权与记录年龄判断。
generatedAtdate-time响应生成时点,不是任何业务数据的更新时间。
dataarray车辆数组,顺序与规范化后的请求名单一致。
summaryobject按车辆统计所选块的执行情况,详见下节。

以下完整结构和字段表适用于FULL。每辆车外层包含 vin、plateNumber,以及所选择的 realtime、dailyMileage、dailyHydrogen。成功块保留完整业务字段;源可选字段缺失统一为 null,真实 0 和 false 保留。块内不重复 vin 和 plateNumber。所有本接口时间戳输出为带 +08:00 的 RFC3339,可含小数秒。

summary 字段定义
requestedCount校验通过的请求车辆数。
returnedCountdata 条数;HTTP 200 时等于 requestedCount。
successCount所有所选块 queryStatus 均为 SUCCESS 的车辆数。
partialSuccessCount至少一个所选块 SUCCESS、至少一个 FAILED 的车辆数。
failedCount所有所选块均 FAILED 的车辆数。
FULL模式中 successCount + partialSuccessCount + failedCount = returnedCount。NO_DATA、DATA_ANOMALY、STALE 和 CARRIED_FORWARD 不影响执行成功计数。仅选择一种数据时 partialSuccessCount 恒为 0。顶层 SUCCESS / HTTP 200 不能代替逐块检查。

完整响应示例

以下为合成示例,仅用于说明结构。单车请求对应单条 data。示例包含部分储氢字段缺失和当天初步用氢结果,并不代表已取得真实联调数据。

完整成功响应 · 200(可展开 / 收起)
{
+  "code": "SUCCESS",
+  "message": "success",
+  "traceId": "f924a83d8a93475bac26b054305fc427",
+  "queryDate": "2026-10-23",
+  "timezone": "Asia/Shanghai",
+  "requestAt": "2026-10-23T09:30:15+08:00",
+  "generatedAt": "2026-10-23T09:30:16+08:00",
+  "data": [
+    {
+      "vin": "LTEST000000000001",
+      "plateNumber": "粤A09635F",
+      "realtime": {
+        "gpsFixStatus": "FIXED",
+        "locationRecordTime": "2026-10-23T09:29:56+08:00",
+        "coordinateSystem": "GCJ02",
+        "protocol": "GB32960",
+        "longitude": 113.264385,
+        "latitude": 23.129112,
+        "speedKmh": 35.6,
+        "socPercent": 68.0,
+        "totalMileageKm": 43968.2,
+        "recordTime": "2026-10-23T09:29:56+08:00",
+        "timeDifferenceSeconds": 19,
+        "online": true,
+        "activeToday": true,
+        "motionStatus": "driving",
+        "locationAvailable": true,
+        "status": "NORMAL",
+        "hydrogenPressureTemperatureSource": null,
+        "remainingHydrogenPercentSource": null,
+        "hydrogenFullCapacityKg": null,
+        "hydrogenTankCapacityL": null,
+        "hydrogenFullPressureMPa": null,
+        "hydrogenReferenceTemperatureC": null,
+        "hydrogenEstimatePressureMPa": null,
+        "hydrogenEstimateTemperatureC": null,
+        "hydrogenCalculationVersion": null,
+        "hydrogenCapacitySource": null,
+        "hydrogenPercentReason": "MISSING_TANK_CAPACITY",
+        "remainingHydrogenKg": 16.0,
+        "remainingHydrogenPercent": null,
+        "hydrogenRecordTime": "2026-10-23T09:29:56+08:00",
+        "hydrogenDataStatus": "PARTIAL",
+        "remainingHydrogenKgStatus": "NORMAL",
+        "remainingHydrogenPercentStatus": "MISSING",
+        "hydrogenValueSource": "REPORTED",
+        "hydrogenSourceProtocol": "GB32960",
+        "hydrogenStaleAfterSeconds": 300,
+        "hydrogenExpectedIntervalSeconds": null,
+        "queryStatus": "SUCCESS",
+        "errorCode": null,
+        "errorMessage": null
+      },
+      "dailyMileage": {
+        "queryStatus": "SUCCESS",
+        "errorCode": null,
+        "errorMessage": null,
+        "status": "NORMAL",
+        "date": "2026-10-23",
+        "dailyMileageKm": 38.9,
+        "totalMileageKm": 43968.2,
+        "sourceProtocol": "GB32960",
+        "dataQuality": null,
+        "dataTime": "2026-10-23T09:29:50+08:00",
+        "updatedAt": "2026-10-23T09:30:02+08:00",
+        "statisticsStartTime": "2026-10-22T23:59:40+08:00",
+        "statisticsEndTime": "2026-10-23T09:29:50+08:00"
+      },
+      "dailyHydrogen": {
+        "queryStatus": "SUCCESS",
+        "errorCode": null,
+        "errorMessage": null,
+        "status": "NORMAL",
+        "date": "2026-10-23",
+        "hydrogenConsumptionKg": 3.573,
+        "qualityStatus": "OK",
+        "qualityReason": null,
+        "calculationPhase": "PRELIMINARY",
+        "algorithmVersion": "PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5",
+        "statisticsStartTime": null,
+        "statisticsEndTime": "2026-10-23T09:29:56+08:00",
+        "updatedAt": "2026-10-23T09:30:03+08:00"
+      }
+    }
+  ],
+  "summary": {
+    "requestedCount": 1,
+    "returnedCount": 1,
+    "successCount": 1,
+    "partialSuccessCount": 0,
+    "failedCount": 0
+  }
+}
示例实时 kg 可用、百分比为 MISSING;日用氢为 PRELIMINARY,且两类日统计边界不一致。应展示各自指标,不能直接计算百公里氢耗。
完整部分失败响应 · 200
{
+  "code": "SUCCESS",
+  "message": "success",
+  "traceId": "be2ed2362ee44c7098fd3d347c41f2f9",
+  "queryDate": "2026-10-23",
+  "timezone": "Asia/Shanghai",
+  "requestAt": "2026-10-23T09:30:15+08:00",
+  "generatedAt": "2026-10-23T09:30:16+08:00",
+  "data": [
+    {
+      "vin": "LTEST000000000001",
+      "plateNumber": "粤A09635F",
+      "realtime": {
+        "gpsFixStatus": "FIXED",
+        "locationRecordTime": "2026-10-23T09:29:56+08:00",
+        "coordinateSystem": "GCJ02",
+        "protocol": "GB32960",
+        "longitude": 113.264385,
+        "latitude": 23.129112,
+        "speedKmh": 35.6,
+        "socPercent": 68.0,
+        "totalMileageKm": 43968.2,
+        "recordTime": "2026-10-23T09:29:56+08:00",
+        "timeDifferenceSeconds": 19,
+        "online": true,
+        "activeToday": true,
+        "motionStatus": "driving",
+        "locationAvailable": true,
+        "status": "NORMAL",
+        "hydrogenPressureTemperatureSource": null,
+        "remainingHydrogenPercentSource": null,
+        "hydrogenFullCapacityKg": null,
+        "hydrogenTankCapacityL": null,
+        "hydrogenFullPressureMPa": null,
+        "hydrogenReferenceTemperatureC": null,
+        "hydrogenEstimatePressureMPa": null,
+        "hydrogenEstimateTemperatureC": null,
+        "hydrogenCalculationVersion": null,
+        "hydrogenCapacitySource": null,
+        "hydrogenPercentReason": "MISSING_TANK_CAPACITY",
+        "remainingHydrogenKg": 16.0,
+        "remainingHydrogenPercent": null,
+        "hydrogenRecordTime": "2026-10-23T09:29:56+08:00",
+        "hydrogenDataStatus": "PARTIAL",
+        "remainingHydrogenKgStatus": "NORMAL",
+        "remainingHydrogenPercentStatus": "MISSING",
+        "hydrogenValueSource": "REPORTED",
+        "hydrogenSourceProtocol": "GB32960",
+        "hydrogenStaleAfterSeconds": 300,
+        "hydrogenExpectedIntervalSeconds": null,
+        "queryStatus": "SUCCESS",
+        "errorCode": null,
+        "errorMessage": null
+      },
+      "dailyMileage": {
+        "queryStatus": "SUCCESS",
+        "errorCode": null,
+        "errorMessage": null,
+        "status": "NORMAL",
+        "date": "2026-10-23",
+        "dailyMileageKm": 38.9,
+        "totalMileageKm": 43968.2,
+        "sourceProtocol": "GB32960",
+        "dataQuality": null,
+        "dataTime": "2026-10-23T09:29:50+08:00",
+        "updatedAt": "2026-10-23T09:30:02+08:00",
+        "statisticsStartTime": "2026-10-22T23:59:40+08:00",
+        "statisticsEndTime": "2026-10-23T09:29:50+08:00"
+      },
+      "dailyHydrogen": {
+        "queryStatus": "FAILED",
+        "status": null,
+        "errorCode": "HYDROGEN_QUERY_TIMEOUT",
+        "errorMessage": "daily hydrogen query timeout"
+      }
+    }
+  ],
+  "summary": {
+    "requestedCount": 1,
+    "returnedCount": 1,
+    "successCount": 0,
+    "partialSuccessCount": 1,
+    "failedCount": 0
+  }
+}

失败块只包含 queryStatus、status:null、errorCode、errorMessage;不附带旧业务数值。实时和里程成功时,用氢失败不丢弃其他结果。全部子查询失败仍可返回 200,此时 failedCount=returnedCount。

realtime 全部字段

以下为 queryStatus=SUCCESS 的完整字段集合;vin、plateNumber 取车辆外层身份。

字段类型含义与空值规则
queryStatusstringSUCCESS 表示该子服务可见查询执行完成;仍需检查业务状态和逐字段质量。
errorCodestring | null成功块固定为 null。
errorMessagestring | null成功块固定为 null。
gpsFixStatusstring实际位置报文定位位;GB32960 bit0=0、JT808 bit1=1 表示 FIXED;MQTT 或缺少可信定位位为 UNKNOWN。不以在线或数据年龄推断;枚举:FIXED, NO_FIX, UNKNOWN。
locationRecordTimedate-time | null位置行对应实际采集时间,独立于主记录 recordTime;历史 FIXED 不因车辆离线改变 未知时为 null。
coordinateSystemstring仅 GB2025 显式坐标类型 1=WGS84、2=GCJ02,其余 UNKNOWN;GB2016/JT808/MQTT 当前无确证为 UNKNOWN,未知坐标系不得直接当高德坐标使用;枚举:WGS84, GCJ02, UNKNOWN。
protocolstring | null本条实时位置、速度、里程及记录时间实际采用的采集协议;GB32960=车辆协议,MQTT=MQTT车端来源,JT808=定位终端来源 未知时为 null。
longitudenumber | null所选来源的最新有效经度;无位置时为 null
latitudenumber | null所选来源的最新有效纬度;无位置时为 null
speedKmhnumber | null所选来源瞬时速度,单位 km/h 未知时为 null。
socPercentnumber | null动力电池 SOC(%);未知为 null,不能替代储氢百分比。
totalMileageKmnumber | null所选来源累计总里程,单位 km 未知时为 null。
recordTimedate-time | null实时主记录的实际采集时间,统一输出带 +08:00 的 RFC3339。 未知时为 null。
timeDifferenceSecondsinteger | nullrequestAt 与主记录的时间差(秒),沿用源服务非负处理;表示记录年龄,不表示实际离线时长。未知为 null。
onlineboolean任一采集协议是否在最近60秒内上报
activeTodayboolean任一采集协议在当前自然日(Asia/Shanghai)内上报过
motionStatusstringdriving=在线且速度大于3km/h;idle=在线且速度不大于3km/h;offline=当前不在线或无实时记录;枚举:driving, idle, offline。
locationAvailablebooleantrue=longitude/latitude 有效;false=当前无有效位置
statusstring当前实时主记录状态为 NORMAL / NO_DATA;不代表所有位置或储氢字段可用。
hydrogenPressureTemperatureSourcestring | null最大氢压/最大氢温聚合读数近似;两者不保证来自同一瓶,不是逐瓶质量求和或全瓶完整性证明 未知时为 null。;枚举:MAX_SENSOR_AGGREGATE。
remainingHydrogenPercentSourcestring | null百分比始终为模型满充容量推算;与 kg 来源独立,计算前缺条件时为 null;枚举:ESTIMATED。
hydrogenFullCapacityKgnumber | null实际用于比例分母的未舍入满充质量,kg;PressureHydrogenMassKg(35,15,VIN水容积),不是扣除残余后的可用容量 未知时为 null。
hydrogenTankCapacityLnumber | nullVIN 配置的全车储氢水容积,L;必须 active 且大于0、不超过10000,不根据车型臆造默认值 未知时为 null。
hydrogenFullPressureMPanumber | null本次业务确认的满充参考压力,MPa;不代表自动核验每辆车铭牌额定压力 未知时为 null。;枚举:35。
hydrogenReferenceTemperatureCnumber | null满充参考温度,摄氏度 未知时为 null。;枚举:15。
hydrogenEstimatePressureMPanumber | null仅平台估算 kg 时使用的同帧最大氢压,MPa;支持0,不补默认值 未知时为 null。
hydrogenEstimateTemperatureCnumber | null仅平台估算 kg 时使用的同帧最大氢温,摄氏度;模型输入范围−40–726.85°C,不是车辆安全温度阈值;缺失不补默认温度 未知时为 null。
hydrogenCalculationVersionstring | null实时储氢模型版本;不修改日用氢算法 未知时为 null。;枚举:REAL_GAS_35MPA_15C_V1。
hydrogenCapacitySourcestring | null满充容量采用的 VIN 配置数据表 未知时为 null。;枚举:vehicle_hydrogen_tank_capacity。
hydrogenPercentReasonstring | null缺少或拒绝百分比的原因;超出业务满充参考仅拒绝百分比,不伪装为100,也不据此判断车辆安全状态 未知时为 null。;枚举:MISSING_HYDROGEN_MEASUREMENT, INVALID_MASS_READING, INVALID_PRESSURE_TEMPERATURE, INCOMPLETE_PRESSURE_TEMPERATURE, MISSING_TANK_CAPACITY, MASS_CALCULATION_FAILED, EXCEEDS_NOMINAL_FULL_CAPACITY。
remainingHydrogenKgnumber | null全车氢质量 kg;优先 GB32960 广东扩展终端上报(0–200 kg),缺质量时以同帧最大氢压/氢温和 VIN 全车水容积作实气估算(0–500 kg)。真实零保留,缺失/异常为 null,陈旧值保留并标 STALE
remainingHydrogenPercentnumber | null剩余质量除以 VIN 水容积在 35 MPa、15°C 的实气模型满充质量乘100,属于 ESTIMATED;缺容量为 null/MISSING。超过100返回 null/INVALID,不截断为100,kg独立保留;不以动力电池SOC替代
hydrogenRecordTimedate-time | null氢质量对应原始同帧实际采集时间,RFC 3339 带时区;不是请求时间或缓存更新时间 未知时为 null。
hydrogenDataStatusstringNORMAL 两项有效(可包含估算,见来源);PARTIAL 部分字段有效;STALE 数据陈旧;MISSING 缺少数据或容量;UNSUPPORTED 不支持;INVALID 异常。数值和字段级状态独立判断;枚举:NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID。
remainingHydrogenKgStatusstring枚举:NORMAL, STALE, MISSING, UNSUPPORTED, INVALID。
remainingHydrogenPercentStatusstring独立于 kg 状态;容量缺失 MISSING,比例超100 INVALID;有值时继承 kg 的 NORMAL 或 STALE;枚举:NORMAL, STALE, MISSING, UNSUPPORTED, INVALID。
hydrogenValueSourcestring | nullkg 来源;REPORTED 仅确认终端上报,无法判断终端内部测量或估算;ESTIMATED 为平台压力/温度/容积实气模型估算 未知时为 null。;枚举:REPORTED, ESTIMATED。
hydrogenSourceProtocolstring | null储氢数据采用的协议,独立于位置的 protocol 未知时为 null。;枚举:GB32960, MQTT, JT808。
hydrogenStaleAfterSecondsinteger | nullGB32960 服务时效策略为 300 秒;不是协议采样周期承诺 未知时为 null。
hydrogenExpectedIntervalSecondsinteger | null当前无已确认上报周期,固定 null
主 status=NORMAL 不保证位置或每个储氢字段可用,必须结合 GPS、坐标系、采集时间和逐字段储氢质量判断。实时主 protocol、储氢协议和采集时点可以不同。离线不等于 GPS 异常;UNKNOWN 坐标系不能直接按 GCJ02 上图。

聚合层不新增旧值兜底。原实时服务已有的有界原始帧回补仍保留,必须带实际 hydrogenRecordTime、来源和 STALE 状态。现有补充查询共享 3 秒预算,补充存储错误可降级为 MISSING/UNKNOWN,主查询仍 SUCCESS;queryStatus 不承诺区分所有被源服务吸收的底层失败。

dailyMileage 全部字段

字段类型含义与空值规则
queryStatusstringSUCCESS 表示该子服务可见查询执行完成;仍需检查业务状态和逐字段质量。
errorCodestring | null成功块固定为 null。
errorMessagestring | null成功块固定为 null。
statisticsStartTimedate-time | null日里程起点累计读数的时间;可跨越多个缺报日;历史结转补零或无数据时为 null
statisticsEndTimedate-time | null当日所选来源最晚 latest_event_time,等于 dataTime;历史结转补零或无数据时为 null。与用氢接口无共同快照保证
datedate查询自然日(Asia/Shanghai),与顶层 queryDate 一致,格式 yyyy-MM-dd。
dailyMileageKmnumber | null日里程(km),复用原服务:优先已核验终端日统计,否则进行同来源累计读数对账;结转为 0,异常可为 null,GPS 估算不参与。
totalMileageKmnumber | null当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
dataTimedate-time | null本次统计实际采用的最后一条车辆源数据时间;status=NO_DATA 时为 null
updatedAtdate-time | null本行所依据统计周期的计算时间;前向填充时为上一统计周期计算时间,status=NO_DATA 时为 null
sourceProtocolstring | null本行实际选中的来源协议;status=NO_DATA 时为 null;枚举:GB32960, MQTT, JT808。
dataQualitystring | nullCARRIED_FORWARD 表示缺报结转;其他值表示无法连续对账的原因,此时 dailyMileageKm 为 null,保留累计读数及来源证据
statusstringNORMAL=存在可用数据;NO_DATA=授权范围内无可用数据;DATA_ANOMALY=检测到数据异常,详情见 dataQuality;枚举:NORMAL, NO_DATA, DATA_ANOMALY。
CARRIED_FORWARD 是 dataQuality 的值,status 仍为 NORMAL。日里程补 0、累计值与来源/时间沿用历史记录,不代表当日实测零里程;统计边界沿用源结果。不得隐式合并 realtime.totalMileageKm 与 dailyMileage.totalMileageKm。

dailyHydrogen 全部字段

字段类型含义与空值规则
queryStatusstringSUCCESS 表示该子服务可见查询执行完成;仍需检查业务状态和逐字段质量。
errorCodestring | null成功块固定为 null。
errorMessagestring | null成功块固定为 null。
statisticsStartTimedate-time | nullFINAL 证据区间最早开始时间;PRELIMINARY 仅有结束水位故为 null;缺失或异常证据为 null。边界是证据包络,不承诺连续覆盖
statisticsEndTimedate-time | nullFINAL 证据区间最晚结束时间,PRELIMINARY 为真实 lastEventTime 水位;缺失或异常证据为 null;不是 API 查询时间
updatedAtdate-time | null同一用氢统计行的数据库更新时间,RFC 3339 带时区;无统计行时为 null
datedate查询自然日(Asia/Shanghai),与顶层 queryDate 一致,格式 yyyy-MM-dd。
hydrogenConsumptionKgnumber | null单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null
calculationPhasestring | nullPRELIMINARY 仅供标注为初步的日内监控;FINAL 表示批量重算已完成,补传或算法调整仍可能再次重算,不保证固定结算时刻 未知时为 null。
algorithmVersionstring | null氢耗计算算法版本 未知时为 null。
qualityStatusstring | nullOK 对应 NORMAL;SUSPECT 对应 DATA_ANOMALY 并保留数值供审计,不得用于正常百公里氢耗;NO_DATA 对应无可用统计 未知时为 null。
qualityReasonstring | null质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计 未知时为 null。
statusstringNORMAL 对应有效质量 OK;DATA_ANOMALY 对应可疑质量 SUSPECT;NO_DATA 为无可用统计。

PRELIMINARY 仅用于明确标注为初步的日内监控;FINAL 表示完成相应批量重算,仍可被补传或算法修订回修。日期本身不决定阶段。DATA_ANOMALY 可保留数值供审计,不能当作正常用氢结果。没有统计行时值为 null,不填 0。

执行状态与错误码

情况queryStatus业务结果
子服务完成并有数据SUCCESS保留源 status、质量和逐字段状态。
子服务完成但无可用记录SUCCESSstatus=NO_DATA;数值按源定义为空,实时布尔/状态字段按原规则保留。
源业务数据异常SUCCESSstatus=DATA_ANOMALY;保留允许返回的审计字段。
子服务错误 / 超时FAILEDstatus=null;仅返回执行错误字段。
源可选补充已降级SUCCESS保留 MISSING / UNKNOWN 等源状态,不能断言设备没有记录。
字段类型含义与空值规则
queryStatusstringFAILED:该块查询错误、超时或结果转换失败。
statusstring | null枚举:null。
errorCodestring枚举:REALTIME_QUERY_FAILED, REALTIME_QUERY_TIMEOUT, MILEAGE_QUERY_FAILED, MILEAGE_QUERY_TIMEOUT, HYDROGEN_QUERY_FAILED, HYDROGEN_QUERY_TIMEOUT, SOURCE_DATA_INVALID。
errorMessagestring
HTTP顶层 code说明
200SUCCESS聚合流程完成,含部分或全部子查询失败;逐块判断。
400INVALID_REQUEST结构、类型、车牌、枚举、协议或批量数量不合法。
400INVALID_DATE_FORMAT缺少必需日期或日期格式不正确。
400INVALID_DATE_RANGE查询日期晚于北京时间当日。
401UNAUTHORIZEDappKey 缺失、无效、停用或当前过期。
403FORBIDDEN车辆或查询时段未授权;整批拒绝。
413PAYLOAD_TOO_LARGE请求体超过 64 KiB。
429RATE_LIMIT_EXCEEDED应用或全局限额超限;读取 Retry-After 秒数后退避重试。
500INTERNAL_ERROR授权依赖故障、身份冲突或无法完成聚合的内部异常。
{
+  "code": "FORBIDDEN",
+  "message": "无数据访问权限",
+  "traceId": "ee030dc62c1b4c9aa9b43cc9fa6b4b23"
+}

非 200 响应不返回 data 或 summary。块错误码为 REALTIME_QUERY_FAILED/TIMEOUT、MILEAGE_QUERY_FAILED/TIMEOUT、HYDROGEN_QUERY_FAILED/TIMEOUT;这里的 TIMEOUT 表示完整代码后缀 _QUERY_TIMEOUT。源字段无法安全序列化或转换时间时为 SOURCE_DATA_INVALID。单个类型批量查询整体失败时,该类型对全部车辆均标 FAILED;单车结果转换异常可仅影响该车该块。

容量、超时与调用建议

项目当前实现
车辆批量显式车牌最多 200 项,超出返回 BATCH_SIZE_EXCEEDED;省略或空数组返回所选授权范围交集的全部车辆,不受 200 辆限制。按类型对 VIN 集合批量查询。
内部执行所选三类最多三个任务并行,共享授权结果与固定映射;不逐车 HTTP 回调原接口。
总预算6 秒,受更短的上游请求截止时间约束。
子查询预算所选数据查询共享最多 4.5 秒;只把尚未完成的类型标记超时,保留已经完成的块。
应用限额每应用、每服务实例 30 批/60 秒,最多 2 批并发;与历史剩余氢量接口配额独立。
端点总并发每个服务实例最多 16 批;超限立即 429。
多实例与网关统一配额由网关协调;网关预算须长于应用总预算并留传输余量。
响应格式本端点返回紧凑 JSON;本文示例为便于阅读进行了格式化。
性能证据已有本地测试与 Mock 基准,不包含线上数据库、网络或生产压测;不承诺未经验证的 P95。

只需某一类数据时显式传 dataTypes。收到部分失败后优先仅重试失败类型,遵循 Retry-After 并退避;不要把 NO_DATA 当成网络失败反复重试。当前没有新增聚合缓存、统计表或数据库迁移。

统计可比性与版本说明

FULL不返回百公里氢耗,SIMPLE预留该字段并固定null。相同 VIN、相同日期、NORMAL、相近更新时间,均不足以证明分子分母的统计区间可比。
  • 调用方计算前至少检查:两块执行成功、业务正常,用氢质量 OK,里程为正且不是结转或异常;VIN 与日期一致。
  • 还需有可核查的区间、证据覆盖及来源口径。statisticsStartTime/statisticsEndTime 只是证据包络;即使边界相等也不能单独证明连续覆盖。证据不足时只展示独立指标。
  • 正式报表要求适用结果为 FINAL 并保留算法版本、查询时刻和结果快照;初步监控另标 PRELIMINARY。
版本 / 状态说明
文档 V1.5 · 2026-10-26按当前开发实现同步。对应 OpenAPI 1.12.2。
实现状态已完成接口实现及测试;本文随开放平台服务发布,版本与实际状态可通过 /healthz 核对。
与 V1.1 评审稿的变化限额与超时已明确;保留现有业务字段,失败块结构固定;补齐完整字段、示例和已知源降级边界。
旧接口继续保留;本端点不修改其返回字段、统计或选源口径。瞬时车辆授权到期边界按结束时点不包含处理。

车辆单日用氢量

POST/api/v1/vehicles/hydrogen-consumption/query

查询车辆单日用氢量,单位 kg。NORMAL + OK + PRELIMINARY 仅供初步监控;正式报表要求 FINAL 并核对证据与版本。两个日统计接口没有共同快照,区间缺失或不同不能直接计算百公里氢耗。

#

请求参数

字段必填说明
date是日期,格式 yyyy-MM-dd
plateNumbers否车牌数组;省略或 [] 时查询全部授权车辆

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"plateNumber": "浙F06618F",
"hydrogenConsumptionKg": 12.315,
"status": "NORMAL"
}]
}
  • 400:日期或车牌格式不正确
  • 401:appKey 无效、停用或过期
  • 403:指定车辆未授权

车辆单日里程

POST/api/v1/vehicles/mileage/query

返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。

#

请求参数

字段必填说明
date是日期,格式 yyyy-MM-dd
plateNumbers否省略时查询全部授权车辆
protocolPriority否协议选源顺序,例如 ["GB32960","MQTT","JT808"]
缺数规则:日里程优先采用中台已选中且质量通过的终端日统计及其同来源基线,来源切换不单独触发异常;缺报日沿用累计值、日里程补 0(CARRIED_FORWARD),跨缺报期增量计入恢复日。没有已验证日统计时才按相邻日累计差计算;无法验证的首次基线、来源切换或累计回退返回 DATA_ANOMALY,日里程为 null。GPS 估算不参与本接口。

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679,
"sourceProtocol": "GB32960",
"status": "NORMAL"
}]
}
  • 400:日期、车牌或协议参数错误
  • 403:授权期未覆盖查询日
  • 无统计:单车以 NO_DATA 返回
-

车辆区间日里程

POST/api/v1/vehicles/mileage/range/query

按车辆、日期分页返回区间日里程,最长查询区间为 366 天。

#

请求参数

字段必填说明
startDate / endDate是日期区间,yyyy-MM-dd
plateNumbers否车牌数组,最多 5000 辆
protocolPriority否协议选源顺序
pageSize / cursor否分页大小及下一页游标

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"date": "2026-08-06",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679
}],
"nextCursor": null
}
  • 400:区间超限或游标与原参数不一致
  • 403:授权未完整覆盖查询区间
+

车辆区间日里程

POST/api/v1/vehicles/mileage/range/query

按车辆、日期分页返回区间日里程,最长查询区间为 3 年(1096 天)。

#

请求参数

字段必填说明
startDate / endDate是日期区间,yyyy-MM-dd
plateNumbers否车牌数组,最多 5000 辆
protocolPriority否协议选源顺序
pageSize / cursor否分页大小及下一页游标

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"date": "2026-08-06",
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679
}],
"nextCursor": null
}
  • 400:区间超限或游标与原参数不一致
  • 403:授权未完整覆盖查询区间

指定时刻总里程

POST/api/v1/vehicles/total-mileage/query

按 VIN 返回不晚于指定时刻的最近一条总里程及实际采集协议。

#

请求参数

字段必填说明
vin是17 位已授权 VIN
time是北京时间 yyyy-MM-dd HH:mm:ss
protocol否GB32960、MQTT 或 JT808;省略时按默认顺序选取

成功返回 · 200

{
"code": "SUCCESS",
"data": {
"totalMileageKm": 12345.678,
"protocol": "GB32960",
"recordTime": "2026-08-06 10:29:45",
"timeDifferenceSeconds": 15
}
}
  • 400:VIN、时间或协议不合法
  • 403:VIN 未授权
  • 无记录:返回 NO_DATA
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml index a47ae4ef..d5aa9d64 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: 车辆数据开放平台 API - version: 1.10.0 + version: 1.12.2 license: name: Proprietary description: | @@ -16,6 +16,90 @@ tags: - name: 开放平台管理 description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口 paths: + /api/v1/vehicles/operating-data/query: + post: + tags: [合作方数据接口] + summary: 批量查询车辆综合运营数据 + description: | + responseMode省略或FULL保持已上线完整结构;SIMPLE固定返回9个业务字段并在内部按质量过滤。 + SIMPLE的百公里氢耗字段暂固定null,不进行未经证明可比的日用氢/日里程相除;不影响summary成功计数。 + SIMPLE summary按所选展示字段可用性统计;FULL按子查询执行结果统计。未选类型对应的SIMPLE字段仍返回null但不参与计数。 + SIMPLE舍弃陈旧/无可信采集时间的剩余氢量、异常用氢及结转补零;原完整数据和来源状态仍在内部保留。 + 车牌可选,省略或空数组查询所选时间范围均有效授权的全部车辆(取交集),无车辆返回空数组;省略或空数组不受200辆上限限制,不分页、不截断。显式指定车牌最多200项,超出返回BATCH_SIZE_EXCEEDED。规范化后不允许重复。 + dataTypes省略时查询实时、单日里程和单日用氢;未选块省略。含日统计时dailyDate必填且不得为未来日期。 + protocolPriority仅影响DAILY_MILEAGE,未选里程时不可提供;实时保持原有选源。 + appKey须当前有效,实时车辆授权覆盖requestAt,日统计授权完整覆盖查询自然日。任一越权整批403;先授权再读取。 + 各数据块保留源字段、质量与时间,不是共同快照;不计算百公里氢耗。同日或NORMAL不能证明统计区间可比。 + FULL的queryStatus仅表示子服务可见执行结果,SUCCESS仍可能NO_DATA、DATA_ANOMALY或实时补充降级MISSING/UNKNOWN。 + 里程结转沿用status=NORMAL及dataQuality=CARRIED_FORWARD,不能解释为实测零;储氢陈旧和估算来源原样保留。 + 各块失败独立返回FAILED及错误码,不以NO_DATA或旧值掩盖。所有子查询失败仍返回200和准确summary。 + 所有时间戳转为带+08:00的RFC3339,未知可选字段显式null;原独立接口不受影响。 + 每应用每服务实例30批/60秒、最多2批并发;新端点全局每实例最多16批。429包含Retry-After,多实例配额由网关统一协调。 + 总预算6秒,批量子查询共享最多4.5秒预算;上层网关需给出更长预算。未承诺未经压测的P95目标。 + operationId: queryVehicleOperatingData + security: + - AppKeyAuth: [] + requestBody: + required: true + description: 最大64KiB;不接受未知字段或显式null。 + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingDataQuery' + example: + responseMode: SIMPLE + plateNumbers: [粤A09635F] + dailyDate: '2026-10-23' + dataTypes: [REALTIME, DAILY_MILEAGE, DAILY_HYDROGEN] + protocolPriority: [GB32960, MQTT, JT808] + responses: + '200': + description: 聚合处理完成;FULL逐块检查执行和质量,SIMPLE返回9字段并按所选展示字段可用性汇总。 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/OperatingDataResponse' + - $ref: '#/components/schemas/OperatingSimpleResponse' + '400': + description: 参数、日期或协议不合法;显式plateNumbers超过200项时code为BATCH_SIZE_EXCEEDED + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingErrorResponse' + '401': + description: appKey当前无效 + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingErrorResponse' + '403': + description: 车辆或所选时段未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingErrorResponse' + '413': + description: 请求体超过64KiB + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingErrorResponse' + '429': + description: 应用或全局限额超限 + headers: + Retry-After: + schema: {type: integer, minimum: 1} + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingErrorResponse' + '500': + description: 授权依赖、身份映射或聚合内部异常 + content: + application/json: + schema: + $ref: '#/components/schemas/OperatingErrorResponse' /api/v1/vehicles/hydrogen-remaining/history/query: post: tags: [合作方数据接口] @@ -137,7 +221,7 @@ paths: tags: [合作方数据接口] summary: 分页查询车辆区间日里程 description: | - 最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。 + 最长支持 3 年(1096 天)。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。 首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。 快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。 protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。 @@ -491,6 +575,391 @@ components: schema: $ref: '#/components/schemas/ErrorResponse' schemas: + OperatingSimpleVehicle: + type: "object" + additionalProperties: false + required: ["plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"] + properties: + plateNumber: + type: "string" + description: "规范化后的请求车牌,保持请求顺序。" + reportTime: + type: "string" + nullable: true + description: "实时选中主记录的event_time,即真实源采集/上报时刻;缺失为null,不使用received_at、updated_at或请求时间替代。FULL recordTime仍保持原兼容口径。" + format: "date-time" + offline: + type: "boolean" + nullable: true + description: "主实时记录可用且online已知时取反;无记录或查询失败为null,不能当成离线。" + remainingHydrogenKg: + type: "number" + nullable: true + description: "剩余氢量kg。仅kg字段NORMAL、数值有效且有可信采集时间,响应时不超过300秒并不晚于服务时点1分钟时展示;陈旧/缺失/异常为null,真实0保留。" + minimum: 0 + socPercent: + type: "number" + nullable: true + description: "动力电池SOC百分比;实时主记录有效且数值在0–100内,未知为null。" + minimum: 0 + maximum: 100 + totalMileageKm: + type: "number" + nullable: true + description: "实时主记录累计里程km;不使用日统计累计值兜底。" + minimum: 0 + dailyHydrogenConsumptionKg: + type: "number" + nullable: true + description: "queryDate用氢量kg;子查询成功、status=NORMAL、qualityStatus=OK时返回有效非负数值,否则null。日内值可为PRELIMINARY,不保证终报。" + minimum: 0 + dailyMileageKm: + type: "number" + nullable: true + description: "queryDate里程km;执行成功、业务NORMAL且非CARRIED_FORWARD时返回有效非负数值;实测零保留,结转补零和异常为null。" + minimum: 0 + hydrogenConsumptionPer100KmKg: + type: "number" + nullable: true + description: "kg/100km预留字段;当前缺少可比区间完整证据,固定null,不参与summary计数。禁止调用方以同日日指标直接相除。" + enum: [null] + OperatingSimpleSummary: + type: "object" + description: "按所选展示字段的可用性统计:实时5项+日里程1项+日用氢1项。全有值successCount、部分有值partialSuccessCount、全null failedCount。plateNumber和暂未支持的百公里氢耗不参与;未选类型不参与。null及failedCount并不等于查询执行失败,不作为自动重试依据。" + required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"] + properties: + requestedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/requestedCount"} + returnedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/returnedCount"} + successCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/successCount"} + partialSuccessCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/partialSuccessCount"} + failedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/failedCount"} + OperatingSimpleResponse: + type: "object" + required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"] + properties: + code: {"$ref": "#/components/schemas/OperatingDataResponse/properties/code"} + message: {"$ref": "#/components/schemas/OperatingDataResponse/properties/message"} + traceId: {"$ref": "#/components/schemas/OperatingDataResponse/properties/traceId"} + queryDate: {"$ref": "#/components/schemas/OperatingDataResponse/properties/queryDate"} + timezone: {"$ref": "#/components/schemas/OperatingDataResponse/properties/timezone"} + requestAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/requestAt"} + generatedAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/generatedAt"} + data: + type: "array" + minItems: 0 + items: {"$ref": "#/components/schemas/OperatingSimpleVehicle"} + summary: {"$ref": "#/components/schemas/OperatingSimpleSummary"} + OperatingDataQuery: + type: "object" + oneOf: + - required: [dataTypes] + properties: + dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}} + - required: [dailyDate] + not: + required: [dataTypes] + properties: + dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}} + additionalProperties: false + properties: + responseMode: + type: string + enum: [FULL, SIMPLE] + default: FULL + description: 省略保持完整结构;显式SIMPLE返回固定9字段;不接受空字符串或null。 + plateNumbers: + type: "array" + minItems: 0 + maxItems: 200 + uniqueItems: true + items: + type: "string" + minLength: 1 + maxLength: 32 + description: "可选;省略或空数组查询所选时间范围内均有效授权的全部车辆,按车牌排序。省略或空数组返回全部有效授权车辆,不受200辆上限限制,不分页、不截断;显式指定最多200项,超出返回BATCH_SIZE_EXCEEDED。去首尾空白、转大写后仍必须唯一。" + dailyDate: + type: "string" + format: "date" + description: "含日统计时必填;仅实时可省略;提供时仍校验。无固定历史下界,能否取得结果取决于有效授权及保留的统计投影,不承诺历史完整性。" + dataTypes: + type: "array" + minItems: 1 + maxItems: 3 + uniqueItems: true + items: + type: "string" + enum: ["REALTIME", "DAILY_MILEAGE", "DAILY_HYDROGEN"] + description: "省略默认全部;未选数据块不出现。" + protocolPriority: + type: "array" + minItems: 1 + maxItems: 3 + uniqueItems: true + items: + type: "string" + enum: ["GB32960", "MQTT", "JT808"] + description: "仅选DAILY_MILEAGE时允许;未列出协议不兜底。" + OperatingRealtimeVehicleResultSuccess: + type: "object" + required: ["queryStatus", "errorCode", "errorMessage", "gpsFixStatus", "locationRecordTime", "coordinateSystem", "protocol", "longitude", "latitude", "speedKmh", "socPercent", "totalMileageKm", "recordTime", "timeDifferenceSeconds", "online", "activeToday", "motionStatus", "locationAvailable", "status", "hydrogenPressureTemperatureSource", "remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL", "hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa", "hydrogenEstimateTemperatureC", "hydrogenCalculationVersion", "hydrogenCapacitySource", "hydrogenPercentReason", "remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime", "hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus", "hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds", "hydrogenExpectedIntervalSeconds"] + properties: + queryStatus: + type: "string" + enum: ["SUCCESS"] + errorCode: + type: "string" + nullable: true + enum: [null] + errorMessage: + type: "string" + nullable: true + enum: [null] + gpsFixStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/gpsFixStatus"} + locationRecordTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + coordinateSystem: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/coordinateSystem"} + protocol: + type: "string" + nullable: true + description: "沿用源字段含义;原可选字段缺失时输出null。" + longitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/longitude"} + latitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/latitude"} + speedKmh: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/speedKmh"} + socPercent: + type: "number" + minimum: 0 + maximum: 100 + nullable: true + description: "动力电池SOC;未知为null。" + totalMileageKm: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/totalMileageKm"} + recordTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + timeDifferenceSeconds: + type: "integer" + minimum: 0 + nullable: true + description: "源主记录年龄,不表示实际离线时长。" + online: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/online"} + activeToday: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/activeToday"} + motionStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/motionStatus"} + locationAvailable: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/locationAvailable"} + status: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/status"} + hydrogenPressureTemperatureSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPressureTemperatureSource"} + remainingHydrogenPercentSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentSource"} + hydrogenFullCapacityKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullCapacityKg"} + hydrogenTankCapacityL: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenTankCapacityL"} + hydrogenFullPressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullPressureMPa"} + hydrogenReferenceTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenReferenceTemperatureC"} + hydrogenEstimatePressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimatePressureMPa"} + hydrogenEstimateTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimateTemperatureC"} + hydrogenCalculationVersion: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCalculationVersion"} + hydrogenCapacitySource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCapacitySource"} + hydrogenPercentReason: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPercentReason"} + remainingHydrogenKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKg"} + remainingHydrogenPercent: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercent"} + hydrogenRecordTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + hydrogenDataStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenDataStatus"} + remainingHydrogenKgStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKgStatus"} + remainingHydrogenPercentStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentStatus"} + hydrogenValueSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenValueSource"} + hydrogenSourceProtocol: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenSourceProtocol"} + hydrogenStaleAfterSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenStaleAfterSeconds"} + hydrogenExpectedIntervalSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenExpectedIntervalSeconds"} + OperatingMileageResultSuccess: + type: "object" + required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "date", "dailyMileageKm", "totalMileageKm", "dataTime", "updatedAt", "sourceProtocol", "dataQuality", "status"] + properties: + queryStatus: + type: "string" + enum: ["SUCCESS"] + errorCode: + type: "string" + nullable: true + enum: [null] + errorMessage: + type: "string" + nullable: true + enum: [null] + statisticsStartTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + statisticsEndTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + date: {"$ref": "#/components/schemas/MileageResult/properties/date"} + dailyMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/dailyMileageKm"} + totalMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/totalMileageKm"} + dataTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + updatedAt: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + sourceProtocol: {"$ref": "#/components/schemas/MileageResult/properties/sourceProtocol"} + dataQuality: + type: "string" + nullable: true + description: "沿用源字段含义;原可选字段缺失时输出null。" + status: {"$ref": "#/components/schemas/MileageResult/properties/status"} + OperatingHydrogenResultSuccess: + type: "object" + required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "updatedAt", "date", "hydrogenConsumptionKg", "calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason", "status"] + properties: + queryStatus: + type: "string" + enum: ["SUCCESS"] + errorCode: + type: "string" + nullable: true + enum: [null] + errorMessage: + type: "string" + nullable: true + enum: [null] + statisticsStartTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + statisticsEndTime: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + updatedAt: + type: "string" + format: "date-time" + nullable: true + description: "保留源时刻,输出RFC3339带+08:00;未知为null。" + date: {"$ref": "#/components/schemas/HydrogenResult/properties/date"} + hydrogenConsumptionKg: {"$ref": "#/components/schemas/HydrogenResult/properties/hydrogenConsumptionKg"} + calculationPhase: + type: "string" + nullable: true + description: "沿用源字段含义;原可选字段缺失时输出null。" + algorithmVersion: + type: "string" + nullable: true + description: "沿用源字段含义;原可选字段缺失时输出null。" + qualityStatus: + type: "string" + nullable: true + description: "沿用源字段含义;原可选字段缺失时输出null。" + qualityReason: + type: "string" + nullable: true + description: "沿用源字段含义;原可选字段缺失时输出null。" + status: {"$ref": "#/components/schemas/HydrogenResult/properties/status"} + OperatingDataBlockFailure: + type: "object" + additionalProperties: false + required: ["queryStatus", "status", "errorCode", "errorMessage"] + properties: + queryStatus: + type: "string" + enum: ["FAILED"] + status: + type: "string" + nullable: true + enum: [null] + errorCode: + type: "string" + enum: ["REALTIME_QUERY_FAILED", "REALTIME_QUERY_TIMEOUT", "MILEAGE_QUERY_FAILED", "MILEAGE_QUERY_TIMEOUT", "HYDROGEN_QUERY_FAILED", "HYDROGEN_QUERY_TIMEOUT", "SOURCE_DATA_INVALID"] + errorMessage: + type: "string" + OperatingVehicleResult: + type: "object" + required: ["vin", "plateNumber"] + properties: + vin: + type: "string" + plateNumber: + type: "string" + realtime: + oneOf: [{"$ref": "#/components/schemas/OperatingRealtimeVehicleResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}] + dailyMileage: + oneOf: [{"$ref": "#/components/schemas/OperatingMileageResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}] + dailyHydrogen: + oneOf: [{"$ref": "#/components/schemas/OperatingHydrogenResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}] + OperatingDataSummary: + type: "object" + description: "按车辆统计所选块的queryStatus;NO_DATA/异常仍为执行成功。successCount+partialSuccessCount+failedCount=returnedCount=requestedCount。" + required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"] + properties: + requestedCount: + type: "integer" + minimum: 0 + returnedCount: + type: "integer" + minimum: 0 + successCount: + type: "integer" + minimum: 0 + partialSuccessCount: + type: "integer" + minimum: 0 + failedCount: + type: "integer" + minimum: 0 + OperatingDataResponse: + type: "object" + required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"] + properties: + code: + type: "string" + enum: ["SUCCESS"] + message: + type: "string" + traceId: + type: "string" + queryDate: + type: "string" + format: "date" + nullable: true + description: "包含日统计时为dailyDate;仅实时为null。" + timezone: + type: "string" + enum: ["Asia/Shanghai"] + requestAt: + type: "string" + format: "date-time" + generatedAt: + type: "string" + format: "date-time" + data: + type: "array" + minItems: 0 + items: {"$ref": "#/components/schemas/OperatingVehicleResult"} + summary: {"$ref": "#/components/schemas/OperatingDataSummary"} + OperatingErrorResponse: + type: "object" + required: ["code", "message", "traceId"] + properties: + code: + type: "string" + enum: ["INVALID_REQUEST", "BATCH_SIZE_EXCEEDED", "INVALID_DATE_FORMAT", "INVALID_DATE_RANGE", "UNAUTHORIZED", "FORBIDDEN", "PAYLOAD_TOO_LARGE", "RATE_LIMIT_EXCEEDED", "INTERNAL_ERROR"] + message: + type: "string" + traceId: + type: "string" VehicleDailyQuery: type: object additionalProperties: false @@ -541,7 +1010,7 @@ components: endDate: type: string format: date - description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天 + description: 区间结束自然日,含当日;与 startDate 最多相隔 1095 天(含首尾共1096天) plateNumbers: type: array maxItems: 5000 diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go index c9c64ca4..18ad4c59 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go @@ -132,3 +132,31 @@ func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *t } } } + +func TestOperatingDataDocumentationAndCatalog(t *testing.T) { + for _, text := range []string{OperatingDataQueryPath, "queryStatus", "dailyDate", "partialSuccessCount", "CARRIED_FORWARD", "Retry-After", "MISSING"} { + if !strings.Contains(string(openAPISpec), text) { + t.Errorf("OpenAPI missing %s", text) + } + if !strings.Contains(string(simpleDocsHTML), text) { + t.Errorf("HTML missing %s", text) + } + } + found := false + for _, product := range dataProducts() { + if product.Path == OperatingDataQueryPath { + found = product.Code == "operating_data" + } + } + if !found { + t.Fatal("aggregate API missing from catalog") + } +} + +func TestOperatingSimpleModeDocumentation(t *testing.T) { + for _, field := range []string{"responseMode", "SIMPLE", "reportTime", "offline", "dailyHydrogenConsumptionKg", "hydrogenConsumptionPer100KmKg"} { + if !strings.Contains(string(openAPISpec), field) || !strings.Contains(string(simpleDocsHTML), field) { + t.Errorf("SIMPLE contract missing %s", field) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/handler.go b/vehicle-data-platform/apps/api/internal/openplatform/handler.go index d25511a2..a4d2e1a0 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/handler.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/handler.go @@ -58,6 +58,7 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler { } func (h *Handler) registerExternalDataRoutes() { + h.mux.HandleFunc("POST "+OperatingDataQueryPath, h.operatingData) h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen) h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen) h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage) @@ -109,6 +110,7 @@ func (h *Handler) registerPortalRoutes() { func NewDataHandler(service *Service) *Handler { handler := &Handler{service: service, mux: http.NewServeMux()} + handler.mux.HandleFunc("POST "+OperatingDataQueryPath, handler.operatingData) handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen) handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen) handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage) @@ -125,7 +127,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func IsPublicPath(path string) bool { - return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath + return path == OperatingDataQueryPath || path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath } func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) { @@ -720,6 +722,10 @@ func externalTraceID(r *http.Request) string { if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 { return traceID } + return newExternalTraceID() +} + +func newExternalTraceID() string { var value [16]byte if _, err := rand.Read(value[:]); err == nil { return hex.EncodeToString(value[:]) @@ -756,6 +762,7 @@ func requestRemoteAddress(r *http.Request) string { func dataProducts() []DataProduct { return []DataProduct{ + {Code: "operating_data", Name: "车辆综合运营数据", Description: "批量聚合实时、单日里程与单日用氢,分别保留质量、时间和执行状态。", Version: "v1", Status: "available", Method: http.MethodPost, Path: OperatingDataQueryPath, Unit: ""}, {Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"}, { Code: "daily_hydrogen", Name: "单日用氢量", diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go index 49b92ea2..6e5b7053 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/model.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -157,22 +157,23 @@ type RealtimeVehicleRequest struct { } type RealtimeVehiclePoint struct { - LiveHydrogen RealtimeHydrogenData - LocationObservedAt time.Time - LocationEventID string - LocationReceivedAt time.Time - GPSFixStatus string - CoordinateSystem string - VIN string - Protocol string - Longitude float64 - Latitude float64 - SpeedKmh float64 - SOCPercent *float64 - TotalMileageKm float64 - ObservedAt time.Time - Online bool - ActiveToday bool + LiveHydrogen RealtimeHydrogenData + LocationObservedAt time.Time + LocationEventID string + LocationReceivedAt time.Time + GPSFixStatus string + CoordinateSystem string + VIN string + Protocol string + Longitude float64 + Latitude float64 + SpeedKmh float64 + SOCPercent *float64 + TotalMileageKm float64 + TotalMileageAvailable *bool + ObservedAt time.Time + Online bool + ActiveToday bool } type RealtimeVehicleResult struct { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go index 1564d325..a9ace458 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -63,6 +63,11 @@ WHERE app_key_hash=? } func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) { + // Instant grants expire exclusively; a whole-day window may end at valid_to. + endOperator := ">=" + if dayStart.Equal(dayEnd) { + endOperator = ">" + } args := make([]any, 0, len(plates)+3) args = append(args, appID, dayStart, dayEnd) query := ` @@ -71,7 +76,7 @@ FROM vehicle_open_app_vehicle g JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin WHERE g.app_id=? AND g.valid_from<=? - AND (g.valid_to IS NULL OR g.valid_to>=?) + AND (g.valid_to IS NULL OR g.valid_to` + endOperator + `?) AND TRIM(COALESCE(b.plate,''))<>''` if len(plates) > 0 { placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",") @@ -195,7 +200,7 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n args = append(args, now.Add(-10*time.Minute)) query := ` SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0), - COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at, + COALESCE(l.speed_kmh,0),l.soc_percent,l.total_mileage_km,l.updated_at, MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today, l.event_time,l.received_at,l.event_id FROM vehicle_realtime_location l @@ -212,12 +217,15 @@ ORDER BY l.vin, onlineThreshold := now.Add(-time.Minute) for rows.Next() { var point RealtimeVehiclePoint - var soc sql.NullFloat64 + var soc, totalMileage sql.NullFloat64 var locationAt, receivedAt sql.NullTime var eventID sql.NullString - if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil { + if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &totalMileage, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil { return nil, err } + point.TotalMileageKm = totalMileage.Float64 // Preserve the FULL endpoint's compatibility value. + available := totalMileage.Valid + point.TotalMileageAvailable = &available point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 { value := round3(soc.Float64) diff --git a/vehicle-data-platform/apps/api/internal/openplatform/operating_data.go b/vehicle-data-platform/apps/api/internal/openplatform/operating_data.go new file mode 100644 index 00000000..e08c9684 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/operating_data.go @@ -0,0 +1,502 @@ +package openplatform + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +const OperatingDataQueryPath = "/api/v1/vehicles/operating-data/query" +const operatingBudget = 6 * time.Second +const operatingQueryBudget = 4500 * time.Millisecond + +const ( + operatingRealtime = "REALTIME" + operatingMileage = "DAILY_MILEAGE" + operatingHydrogen = "DAILY_HYDROGEN" +) + +type OperatingDataRequest struct { + ResponseMode string `json:"responseMode,omitempty"` + PlateNumbers []string `json:"plateNumbers,omitempty"` + DailyDate string `json:"dailyDate,omitempty"` + DataTypes []string `json:"dataTypes,omitempty"` + ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"` +} + +func (r *OperatingDataRequest) UnmarshalJSON(data []byte) error { + type plain OperatingDataRequest + var value plain + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&value); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if fields == nil { + return fmt.Errorf("request must be an object") + } + if _, exists := fields["dailyDate"]; exists && value.DailyDate == "" { + return fmt.Errorf("dailyDate must not be empty") + } + if _, exists := fields["responseMode"]; exists && value.ResponseMode == "" { + return fmt.Errorf("responseMode must not be empty") + } + for key, raw := range fields { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return fmt.Errorf("%s must not be null", key) + } + } + *r = OperatingDataRequest(value) + return nil +} + +// RawMessage retains source number precision while normalizing optional fields +// without duplicating the existing result DTOs or changing their serializers. +type OperatingDataBlock map[string]json.RawMessage + +type OperatingVehicleResult struct { + VIN string `json:"vin"` + PlateNumber string `json:"plateNumber"` + Realtime OperatingDataBlock `json:"realtime,omitempty"` + DailyMileage OperatingDataBlock `json:"dailyMileage,omitempty"` + DailyHydrogen OperatingDataBlock `json:"dailyHydrogen,omitempty"` +} + +type OperatingDataSummary struct { + RequestedCount int `json:"requestedCount"` + ReturnedCount int `json:"returnedCount"` + SuccessCount int `json:"successCount"` + PartialSuccessCount int `json:"partialSuccessCount"` + FailedCount int `json:"failedCount"` +} + +type OperatingDataResponse struct { + Code string `json:"code"` + Message string `json:"message"` + TraceID string `json:"traceId"` + QueryDate *string `json:"queryDate"` + Timezone string `json:"timezone"` + RequestAt string `json:"requestAt"` + GeneratedAt string `json:"generatedAt"` + Data []OperatingVehicleResult `json:"data"` + Summary OperatingDataSummary `json:"summary"` +} + +type operatingBatchSizeError struct{} + +func (*operatingBatchSizeError) Error() string { return "plateNumbers exceeds 200" } +func (*operatingBatchSizeError) Unwrap() error { return ErrInvalidRequest } + +type operatingDateError struct{ code string } + +func (e *operatingDateError) Error() string { return e.code } + +func (s *Service) validateOperatingData(r OperatingDataRequest, now time.Time) ([]string, map[string]bool, []string, time.Time, error) { + if r.ResponseMode != "" && r.ResponseMode != "FULL" && r.ResponseMode != "SIMPLE" { + return nil, nil, nil, time.Time{}, fmt.Errorf("%w: responseMode must be FULL or SIMPLE", ErrInvalidRequest) + } + if len(r.PlateNumbers) > 200 { + return nil, nil, nil, time.Time{}, &operatingBatchSizeError{} + } + plates, err := normalizePlates(r.PlateNumbers, 200) + if err != nil { + return nil, nil, nil, time.Time{}, err + } + kinds := r.DataTypes + if kinds == nil { + kinds = []string{operatingRealtime, operatingMileage, operatingHydrogen} + } + selected := make(map[string]bool, 3) + for _, kind := range kinds { + if selected[kind] || (kind != operatingRealtime && kind != operatingMileage && kind != operatingHydrogen) { + return nil, nil, nil, time.Time{}, fmt.Errorf("%w: invalid dataTypes", ErrInvalidRequest) + } + selected[kind] = true + } + if len(selected) == 0 { + return nil, nil, nil, time.Time{}, fmt.Errorf("%w: empty dataTypes", ErrInvalidRequest) + } + var day time.Time + if r.DailyDate != "" || selected[operatingMileage] || selected[operatingHydrogen] { + day, err = time.ParseInLocation("2006-01-02", r.DailyDate, s.location) + if err != nil || day.Format("2006-01-02") != r.DailyDate { + return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_FORMAT"} + } + if day.After(now) { + return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_RANGE"} + } + } + if r.ProtocolPriority.Present && !selected[operatingMileage] { + return nil, nil, nil, time.Time{}, fmt.Errorf("%w: protocolPriority requires DAILY_MILEAGE", ErrInvalidRequest) + } + protocols, err := normalizeProtocolPriority(r.ProtocolPriority) + return plates, selected, protocols, day, err +} + +// Validate every selected authorization scope before reading business data. The +// same frozen VIN mapping is then reused by all three batched repositories. +func (s *Service) authorizeOperatingScopes(ctx context.Context, key string, app AppCredential, plates []string, selected map[string]bool, now, day time.Time) (AppCredential, map[string]AuthorizedVehicle, error) { + hash := sha256.Sum256([]byte(strings.ToLower(key))) + allVehicles := len(plates) == 0 + var vehicles map[string]AuthorizedVehicle + check := func(start, end time.Time) error { + found, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end) + if err != nil { + return err + } + if !allVehicles && len(found) != len(plates) { + return ErrForbidden + } + checkedPlates := plates + if allVehicles { + checkedPlates = vehiclePlates(found) + } + for _, plate := range checkedPlates { + v, ok := found[plate] + if !ok || strings.TrimSpace(v.VIN) == "" || (v.Plate != "" && v.Plate != plate) { + return ErrForbidden + } + if previous, exists := vehicles[plate]; exists && previous.VIN != v.VIN { + return errors.New("operating query identity changed") + } + } + if allVehicles && vehicles != nil { + // Only return vehicles authorized for every selected time window. + for plate := range vehicles { + if _, exists := found[plate]; !exists { + delete(vehicles, plate) + } + } + } else { + vehicles = found + } + if vehicles == nil { + vehicles = make(map[string]AuthorizedVehicle) + } + return nil + } + if selected[operatingRealtime] { + if err := check(now, now); err != nil { + return app, nil, err + } + } + if selected[operatingMileage] || selected[operatingHydrogen] { + end := day.AddDate(0, 0, 1) + historicalApp, err := s.repository.Authenticate(ctx, hash, now, day, end) + if errors.Is(err, ErrUnauthorized) { + return app, nil, ErrForbidden + } + if err != nil { + return app, nil, err + } + if historicalApp.ID != app.ID { + return app, nil, errors.New("operating query app identity changed") + } + if err := check(day, end); err != nil { + return app, nil, err + } + } + return app, vehicles, nil +} + +func (s *Service) QueryOperatingData(ctx context.Context, key, traceID string, request OperatingDataRequest) (*OperatingDataResponse, error) { + now := s.now().In(s.location) + plates, selected, protocols, day, err := s.validateOperatingData(request, now) + if err != nil { + return nil, err + } + bounded, cancel := context.WithTimeout(ctx, operatingBudget) + defer cancel() + // Authenticate before applying the existing per-app limiter. Independent state + // avoids consuming the historical-hydrogen endpoint's quota. + if !appKeyPattern.MatchString(key) { + return nil, ErrUnauthorized + } + app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(key))), now, now, now) + if err != nil { + return nil, err + } + release, err := s.operatingLimiter.acquire(app.ID, now) + if err != nil { + return nil, err + } + defer release() + select { + case s.operatingSlots <- struct{}{}: + defer func() { <-s.operatingSlots }() + default: + return nil, &historicalHydrogenRateError{RetryAfter: 1} + } + // Authentication is shared with scope checks below, not repeated per type. + app, vehicles, err := s.authorizeOperatingScopes(bounded, key, app, plates, selected, now, day) + if err != nil { + _ = s.repository.Audit(bounded, app.ID, "operating_data_query", "denied", traceID, len(plates), "scope validation failed") + return nil, err + } + if len(plates) == 0 { + plates = vehiclePlates(vehicles) + } + result := &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: traceID, Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339Nano), Data: make([]OperatingVehicleResult, len(plates)), Summary: OperatingDataSummary{RequestedCount: len(plates), ReturnedCount: len(plates)}} + if selected[operatingMileage] || selected[operatingHydrogen] { + result.QueryDate = &request.DailyDate + } + if len(plates) == 0 { + _ = s.repository.Audit(bounded, app.ID, "operating_data_query", "success", traceID, 0, "") + result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano) + return result, nil + } + vins := vehicleVINs(vehicles) + type completed struct { + kind string + blocks []OperatingDataBlock + } + done := make(chan completed, len(selected)) + queryCtx, queryCancel := context.WithTimeout(bounded, operatingQueryBudget) + defer queryCancel() + for kind := range selected { + go func(kind string) { + started := time.Now() + var blocks []OperatingDataBlock + var err error + switch kind { + case operatingRealtime: + var points map[string]RealtimeVehiclePoint + points, err = s.repository.RealtimeVehicles(queryCtx, vins, now) + if err == nil { + values := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location) + if request.ResponseMode == "SIMPLE" { + prepareSimpleRealtime(values, plates, vehicles, points, s.location) + } + blocks = operatingBlocks(values, s.location) + } + case operatingMileage: + var values map[string]DailyMileage + values, err = s.repository.ReconciledMileageRange(queryCtx, vins, request.DailyDate, request.DailyDate, protocols) + if err == nil { + blocks = operatingBlocks(buildMileageResults(plates, vehicles, values, request.DailyDate), s.location) + } + case operatingHydrogen: + var values map[string]DailyHydrogen + values, err = s.repository.DailyHydrogen(queryCtx, vins, request.DailyDate) + if err == nil { + blocks = operatingBlocks(buildHydrogenResults(plates, vehicles, values, request.DailyDate), s.location) + } + } + if err != nil { + blocks = make([]OperatingDataBlock, len(plates)) + for i := range blocks { + blocks[i] = operatingFailure(kind, errors.Is(err, context.DeadlineExceeded)) + } + } + if err != nil { + log.Printf("openplatform operating query unavailable: traceId=%s appId=%d type=%s durationMs=%d errorType=%T", traceID, app.ID, kind, time.Since(started).Milliseconds(), err) + } + done <- completed{kind, blocks} + }(kind) + } + byKind := make(map[string][]OperatingDataBlock, len(selected)) + for len(byKind) < len(selected) { + select { + case result := <-done: + byKind[result.kind] = result.blocks + case <-queryCtx.Done(): + // Drain already completed work before timing out only the outstanding types. + for { + select { + case result := <-done: + byKind[result.kind] = result.blocks + default: + goto drained + } + } + drained: + for kind := range selected { + if _, ok := byKind[kind]; !ok { + blocks := make([]OperatingDataBlock, len(plates)) + for i := range blocks { + blocks[i] = operatingFailure(kind, true) + } + byKind[kind] = blocks + } + } + } + } + for i, plate := range plates { + row := OperatingVehicleResult{VIN: vehicles[plate].VIN, PlateNumber: plate} + succeeded := 0 + for kind, blocks := range byKind { + block := blocks[i] + if string(block["queryStatus"]) == `"SUCCESS"` { + succeeded++ + } + switch kind { + case operatingRealtime: + row.Realtime = block + case operatingMileage: + row.DailyMileage = block + case operatingHydrogen: + row.DailyHydrogen = block + } + } + switch { + case succeeded == len(selected): + result.Summary.SuccessCount++ + case succeeded == 0: + result.Summary.FailedCount++ + default: + result.Summary.PartialSuccessCount++ + } + result.Data[i] = row + } + status := "success" + if result.Summary.SuccessCount != len(plates) { + status = "partial" + if result.Summary.FailedCount == len(plates) { + status = "error" + } + } + detail := "" + if request.ResponseMode == "SIMPLE" { + detail = "responseMode=SIMPLE" + } + _ = s.repository.Audit(bounded, app.ID, "operating_data_query", status, traceID, len(plates), detail) + result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano) + return result, nil +} + +func operatingFailure(kind string, timeout bool) OperatingDataBlock { + prefix := map[string]string{operatingRealtime: "REALTIME", operatingMileage: "MILEAGE", operatingHydrogen: "HYDROGEN"}[kind] + suffix := "_QUERY_FAILED" + if timeout { + suffix = "_QUERY_TIMEOUT" + } + code, _ := json.Marshal(prefix + suffix) + return OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": code, "errorMessage": json.RawMessage(`"data query unavailable"`)} +} + +func operatingBlocks[T any](values []T, location *time.Location) []OperatingDataBlock { + blocks := make([]OperatingDataBlock, len(values)) + for i, value := range values { + block, err := operatingBlock(value, location) + if err != nil { + block = OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": json.RawMessage(`"SOURCE_DATA_INVALID"`), "errorMessage": json.RawMessage(`"source data cannot be serialized"`)} + } + blocks[i] = block + } + return blocks +} + +func operatingBlock(value any, location *time.Location) (OperatingDataBlock, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var block OperatingDataBlock + if err = json.Unmarshal(data, &block); err != nil { + return nil, err + } + delete(block, "vin") + delete(block, "plateNumber") + var optional []string + switch value.(type) { + case RealtimeVehicleResult: + optional = []string{"protocol", "recordTime", "socPercent", "timeDifferenceSeconds"} + case MileageResult: + optional = []string{"dataQuality"} + case HydrogenResult: + optional = []string{"calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason"} + } + for _, name := range optional { + if _, exists := block[name]; !exists { + block[name] = json.RawMessage("null") + } + } + for _, name := range []string{"recordTime", "locationRecordTime", "hydrogenRecordTime", "dataTime", "updatedAt", "statisticsStartTime", "statisticsEndTime"} { + raw, ok := block[name] + if !ok || string(raw) == "null" { + continue + } + var text string + if err = json.Unmarshal(raw, &text); err != nil { + return nil, err + } + at, parseErr := time.Parse(time.RFC3339Nano, text) + if parseErr != nil { + at, parseErr = time.ParseInLocation("2006-01-02 15:04:05", text, location) + } + if parseErr != nil { + return nil, parseErr + } + block[name], _ = json.Marshal(at.In(location).Format(time.RFC3339Nano)) + } + block["queryStatus"] = json.RawMessage(`"SUCCESS"`) + block["errorCode"] = json.RawMessage("null") + block["errorMessage"] = json.RawMessage("null") + return block, nil +} + +func (h *Handler) operatingData(w http.ResponseWriter, r *http.Request) { + traceID := newExternalTraceID() + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + var request OperatingDataRequest + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&request); err != nil { + writeOperatingDecodeError(w, traceID, err) + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + writeOperatingDecodeError(w, traceID, err) + return + } + result, err := h.service.QueryOperatingData(r.Context(), externalBearer(r), traceID, request) + if err != nil { + var limit *historicalHydrogenRateError + var date *operatingDateError + var batch *operatingBatchSizeError + switch { + case errors.As(err, &limit): + w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter)) + writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMIT_EXCEEDED", Message: "请求过于频繁,请稍后重试", TraceID: traceID}) + case errors.As(err, &batch): + writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "BATCH_SIZE_EXCEEDED", Message: "plateNumbers最多200项;查询全部授权车辆请省略plateNumbers或传空数组", TraceID: traceID}) + case errors.As(err, &date): + writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: date.code, Message: "dailyDate无效或超出允许范围", TraceID: traceID}) + default: + writeExternalError(w, traceID, err) + } + return + } + // Large batches need no pretty-printing: keep the new endpoint compact while + // preserving all existing endpoints' response formatting. + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if request.ResponseMode == "SIMPLE" { + response := simplifyOperatingData(result, h.service.now().In(h.service.location)) + response.GeneratedAt = h.service.now().In(h.service.location).Format(time.RFC3339Nano) + _ = json.NewEncoder(w).Encode(response) + return + } + _ = json.NewEncoder(w).Encode(result) +} + +func writeOperatingDecodeError(w http.ResponseWriter, traceID string, err error) { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeExternal(w, http.StatusRequestEntityTooLarge, ExternalResponse{Code: "PAYLOAD_TOO_LARGE", Message: "请求体超过64KiB", TraceID: traceID}) + return + } + writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求参数不正确", TraceID: traceID}) +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/operating_data_simple.go b/vehicle-data-platform/apps/api/internal/openplatform/operating_data_simple.go new file mode 100644 index 00000000..f5fa9dec --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/operating_data_simple.go @@ -0,0 +1,138 @@ +package openplatform + +import ( + "encoding/json" + "math" + "time" +) + +// All nine keys are serialized, including unknown and unselected values as null. +type OperatingSimpleVehicle struct { + PlateNumber string `json:"plateNumber"` + ReportTime *string `json:"reportTime"` + Offline *bool `json:"offline"` + RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"` + SOCPercent *float64 `json:"socPercent"` + TotalMileageKm *float64 `json:"totalMileageKm"` + DailyHydrogenConsumptionKg *float64 `json:"dailyHydrogenConsumptionKg"` + DailyMileageKm *float64 `json:"dailyMileageKm"` + HydrogenConsumptionPer100KmKg *float64 `json:"hydrogenConsumptionPer100KmKg"` +} + +// Explicit fields shadow the embedded FULL data and summary in JSON; all shared +// envelope metadata remains identical. The original result is not mutated. +type OperatingSimpleResponse struct { + *OperatingDataResponse + Data []OperatingSimpleVehicle `json:"data"` + Summary OperatingDataSummary `json:"summary"` +} + +func simplifyOperatingData(full *OperatingDataResponse, now time.Time) *OperatingSimpleResponse { + metadata := *full + metadata.Data = nil // Do not retain the large internal FULL payload during encoding. + result := &OperatingSimpleResponse{OperatingDataResponse: &metadata, Data: make([]OperatingSimpleVehicle, len(full.Data)), Summary: OperatingDataSummary{RequestedCount: full.Summary.RequestedCount, ReturnedCount: len(full.Data)}} + for i, source := range full.Data { + row := OperatingSimpleVehicle{PlateNumber: source.PlateNumber} + expected, available := 0, 0 + if source.Realtime != nil { + expected += 5 + if simpleBlockNormal(source.Realtime) { + row.ReportTime = simpleTime(source.Realtime, "recordTime") + var online *bool + if json.Unmarshal(source.Realtime["online"], &online) == nil && online != nil { + offline := !*online + row.Offline = &offline + } + row.SOCPercent = simpleNumber(source.Realtime, "socPercent", 100) + row.TotalMileageKm = simpleNumber(source.Realtime, "totalMileageKm", math.MaxFloat64) + if simpleText(source.Realtime, "remainingHydrogenKgStatus") == "NORMAL" { + if at := simpleTime(source.Realtime, "hydrogenRecordTime"); at != nil { + observed, _ := time.Parse(time.RFC3339Nano, *at) + if !observed.After(now.Add(time.Minute)) && now.Sub(observed) <= time.Duration(realtimeHydrogenStaleSeconds)*time.Second { + row.RemainingHydrogenKg = simpleNumber(source.Realtime, "remainingHydrogenKg", 500) + } + } + } + } + for _, valid := range []bool{row.ReportTime != nil, row.Offline != nil, row.RemainingHydrogenKg != nil, row.SOCPercent != nil, row.TotalMileageKm != nil} { + if valid { + available++ + } + } + } + if source.DailyMileage != nil { + expected++ + if simpleBlockNormal(source.DailyMileage) && simpleText(source.DailyMileage, "dataQuality") != "CARRIED_FORWARD" { + row.DailyMileageKm = simpleNumber(source.DailyMileage, "dailyMileageKm", math.MaxFloat64) + } + if row.DailyMileageKm != nil { + available++ + } + } + if source.DailyHydrogen != nil { + expected++ + if simpleBlockNormal(source.DailyHydrogen) && simpleText(source.DailyHydrogen, "qualityStatus") == "OK" { + row.DailyHydrogenConsumptionKg = simpleNumber(source.DailyHydrogen, "hydrogenConsumptionKg", math.MaxFloat64) + } + if row.DailyHydrogenConsumptionKg != nil { + available++ + } + } + // No comparable-coverage proof is available from the independent projections. + // Do not manufacture kg/100km by dividing same-day values. The reserved field + // stays null and is excluded from availability counts until formally supported. + switch { + case available == 0: + result.Summary.FailedCount++ + case available == expected: + result.Summary.SuccessCount++ + default: + result.Summary.PartialSuccessCount++ + } + result.Data[i] = row + } + return result +} + +func simpleBlockNormal(block OperatingDataBlock) bool { + return simpleText(block, "queryStatus") == "SUCCESS" && simpleText(block, "status") == StatusNormal +} +func simpleText(block OperatingDataBlock, key string) string { + var value string + _ = json.Unmarshal(block[key], &value) + return value +} +func simpleNumber(block OperatingDataBlock, key string, maximum float64) *float64 { + var value *float64 + if json.Unmarshal(block[key], &value) != nil || value == nil || math.IsNaN(*value) || math.IsInf(*value, 0) || *value < 0 || *value > maximum { + return nil + } + return value +} +func simpleTime(block OperatingDataBlock, key string) *string { + value := simpleText(block, key) + at, err := time.Parse(time.RFC3339Nano, value) + if err != nil || at.IsZero() { + return nil + } + return &value +} + +// The legacy FULL recordTime follows projection.updated_at, and its total +// mileage historically coerces SQL NULL to zero. SIMPLE uses the actual event +// time and nullable source evidence without changing the FULL contract. +func prepareSimpleRealtime(values []RealtimeVehicleResult, plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, location *time.Location) { + for i, plate := range plates { + point, ok := points[vehicles[plate].VIN] + if !ok { + continue + } + values[i].RecordTime = "" + if !point.LocationObservedAt.IsZero() { + values[i].RecordTime = point.LocationObservedAt.In(location).Format(time.RFC3339Nano) + } + if point.TotalMileageAvailable != nil && !*point.TotalMileageAvailable { + values[i].TotalMileageKm = nil + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/operating_data_simple_test.go b/vehicle-data-platform/apps/api/internal/openplatform/operating_data_simple_test.go new file mode 100644 index 00000000..97a26b96 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/operating_data_simple_test.go @@ -0,0 +1,254 @@ +package openplatform + +import ( + "context" + "encoding/json" + "errors" + "github.com/DATA-DOG/go-sqlmock" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" +) + +func simpleFixture(t testing.TB) (*OperatingDataResponse, time.Time) { + t.Helper() + now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + block := func(text string) OperatingDataBlock { + var b OperatingDataBlock + if err := json.Unmarshal([]byte(text), &b); err != nil { + t.Fatal(err) + } + return b + } + return &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: "trace", QueryDate: stringPointer("2026-10-23"), Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339), GeneratedAt: now.Format(time.RFC3339), Summary: OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}, Data: []OperatingVehicleResult{{VIN: "PRIVATE_VIN", PlateNumber: "粤A12345F", + Realtime: block(`{"queryStatus":"SUCCESS","status":"NORMAL","recordTime":"2026-10-23T11:59:59+08:00","online":false,"remainingHydrogenKg":0,"remainingHydrogenKgStatus":"NORMAL","hydrogenRecordTime":"2026-10-23T11:59:59+08:00","socPercent":0,"totalMileageKm":0}`), + DailyMileage: block(`{"queryStatus":"SUCCESS","status":"NORMAL","dailyMileageKm":0,"dataQuality":null,"totalMileageKm":999}`), + DailyHydrogen: block(`{"queryStatus":"SUCCESS","status":"NORMAL","qualityStatus":"OK","hydrogenConsumptionKg":0}`), + }}}, now +} +func TestSimpleOperatingDataNineFieldsAndZeroValues(t *testing.T) { + full, now := simpleFixture(t) + result := simplifyOperatingData(full, now) + row := result.Data[0] + if row.Offline == nil || !*row.Offline || row.ReportTime == nil { + t.Fatal("report time/offline mapping lost") + } + for _, v := range []*float64{row.RemainingHydrogenKg, row.SOCPercent, row.TotalMileageKm, row.DailyHydrogenConsumptionKg, row.DailyMileageKm} { + if v == nil || *v != 0 { + t.Fatal("real zero dropped") + } + } + if row.HydrogenConsumptionPer100KmKg != nil || result.Summary.SuccessCount != 1 { + t.Fatal("reserved field affects display availability") + } + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var encoded map[string]json.RawMessage + if err = json.Unmarshal(data, &encoded); err != nil { + t.Fatal(err) + } + expected := []string{"code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"} + if len(encoded) != len(expected) { + t.Fatalf("extra envelope fields: %s", data) + } + for _, key := range expected { + if _, ok := encoded[key]; !ok { + t.Fatalf("missing %s", key) + } + } + var rows []map[string]json.RawMessage + _ = json.Unmarshal(encoded["data"], &rows) + keys := []string{"plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"} + if len(rows[0]) != len(keys) { + t.Fatalf("not exactly nine fields: %s", data) + } + for _, key := range keys { + if _, ok := rows[0][key]; !ok { + t.Fatalf("missing simple key %s", key) + } + } + if string(rows[0]["hydrogenConsumptionPer100KmKg"]) != "null" || strings.Contains(string(data), "PRIVATE_VIN") { + t.Fatal("unproven derived value or internal identity exposed") + } + if !reflect.DeepEqual(full.Summary, OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}) { + t.Fatal("FULL result mutated") + } +} + +func TestSimpleOperatingDataQualityAndPartialFailures(t *testing.T) { + cases := []struct{ name, block, key, raw, field string }{ + {"stale hydrogen", "realtime", "remainingHydrogenKgStatus", `"STALE"`, "remainingHydrogenKg"}, + {"missing hydrogen", "realtime", "remainingHydrogenKgStatus", `"MISSING"`, "remainingHydrogenKg"}, + {"unsupported hydrogen", "realtime", "remainingHydrogenKgStatus", `"UNSUPPORTED"`, "remainingHydrogenKg"}, + {"expired hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T11:54:59+08:00"`, "remainingHydrogenKg"}, + {"future hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T12:01:01+08:00"`, "remainingHydrogenKg"}, + {"missing hydrogen time", "realtime", "hydrogenRecordTime", `null`, "remainingHydrogenKg"}, + {"negative mass", "realtime", "remainingHydrogenKg", `-1`, "remainingHydrogenKg"}, + {"invalid soc", "realtime", "socPercent", `101`, "socPercent"}, + {"missing realtime total", "realtime", "totalMileageKm", `null`, "totalMileageKm"}, + {"carried zero", "mileage", "dataQuality", `"CARRIED_FORWARD"`, "dailyMileageKm"}, + {"mileage anomaly", "mileage", "status", `"DATA_ANOMALY"`, "dailyMileageKm"}, + {"hydrogen anomaly", "hydrogen", "status", `"DATA_ANOMALY"`, "dailyHydrogenConsumptionKg"}, + {"hydrogen suspect", "hydrogen", "qualityStatus", `"SUSPECT"`, "dailyHydrogenConsumptionKg"}, + {"hydrogen failure", "hydrogen", "queryStatus", `"FAILED"`, "dailyHydrogenConsumptionKg"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + full, now := simpleFixture(t) + row := &full.Data[0] + b := row.Realtime + if tc.block == "mileage" { + b = row.DailyMileage + } + if tc.block == "hydrogen" { + b = row.DailyHydrogen + } + b[tc.key] = json.RawMessage(tc.raw) + result := simplifyOperatingData(full, now) + data, _ := json.Marshal(result.Data[0]) + var fields map[string]json.RawMessage + _ = json.Unmarshal(data, &fields) + if string(fields[tc.field]) != "null" || result.Summary.PartialSuccessCount != 1 { + t.Fatalf("unsafe value or summary: %s %+v", data, result.Summary) + } + // Unrelated known values survive a failure. + if result.Data[0].ReportTime == nil || result.Data[0].Offline == nil { + t.Fatal("unrelated display data erased") + } + }) + } +} + +func TestSimpleOperatingDataSelectionsAndNoData(t *testing.T) { + full, now := simpleFixture(t) + full.Data[0].DailyMileage = nil + full.Data[0].DailyHydrogen = nil + full.QueryDate = nil + result := simplifyOperatingData(full, now) + if result.QueryDate != nil || result.Data[0].DailyMileageKm != nil || result.Data[0].DailyHydrogenConsumptionKg != nil || result.Summary.SuccessCount != 1 { + t.Fatal("unselected fields affected counts") + } + full.Data[0].Realtime = operatingFailure(operatingRealtime, true) + result = simplifyOperatingData(full, now) + if result.Data[0].Offline != nil || result.Data[0].ReportTime != nil || result.Summary.FailedCount != 1 { + t.Fatal("failure became offline or display success") + } + full.Data[0].Realtime = OperatingDataBlock{"queryStatus": json.RawMessage(`"SUCCESS"`), "status": json.RawMessage(`"NO_DATA"`), "online": json.RawMessage(`false`)} + result = simplifyOperatingData(full, now) + if result.Data[0].Offline != nil || result.Summary.FailedCount != 1 { + t.Fatal("missing main record became offline") + } +} + +func TestSimpleOperatingDataHTTPModeAndCompatibility(t *testing.T) { + for _, mode := range []string{"", "FULL", "SIMPLE"} { + t.Run(mode, func(t *testing.T) { + s, f := newOperatingFixture() + f.query = func(_ context.Context, kind string) error { + if kind == operatingHydrogen { + return errors.New("upstream unavailable") + } + return nil + } + body := map[string]any{"plateNumbers": []string{"粤A12345F"}, "dailyDate": "2026-10-23"} + if mode != "" { + body["responseMode"] = mode + } + raw, _ := json.Marshal(body) + req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(string(raw))) + req.Header.Set("Authorization", "Bearer "+operatingTestKey) + w := httptest.NewRecorder() + NewDataHandler(s).ServeHTTP(w, req) + if w.Code != 200 { + t.Fatalf("HTTP=%d body=%s", w.Code, w.Body.String()) + } + var value struct { + Data []map[string]json.RawMessage `json:"data"` + Summary OperatingDataSummary `json:"summary"` + } + if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil { + t.Fatal(err) + } + if mode == "SIMPLE" { + if len(value.Data[0]) != 9 || value.Summary.FailedCount != 1 { + t.Fatal("SIMPLE shape or availability wrong") + } + } else { + if _, ok := value.Data[0]["realtime"]; !ok || value.Summary.PartialSuccessCount != 1 { + t.Fatal("FULL compatibility lost") + } + } + }) + } + for _, raw := range []string{`null`, `""`, `"simple"`, `"UNKNOWN"`, `[]`} { + t.Run("invalid "+raw, func(t *testing.T) { + s, _ := newOperatingFixture() + req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"responseMode":`+raw+`,"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`)) + w := httptest.NewRecorder() + NewDataHandler(s).ServeHTTP(w, req) + if w.Code != 400 { + t.Fatalf("invalid mode HTTP=%d", w.Code) + } + }) + } +} + +func BenchmarkSimpleOperatingData200Vehicles(b *testing.B) { + full, now := simpleFixture(b) + full.Data = make([]OperatingVehicleResult, 200) + fixture, _ := simpleFixture(b) + for i := range full.Data { + full.Data[i] = fixture.Data[0] + } + full.Summary.RequestedCount = 200 + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := json.Marshal(simplifyOperatingData(full, now)); err != nil { + b.Fatal(err) + } + } +} + +func TestSimpleUsesSourceEventTimeAndNullableMileage(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + eventAt := now.Add(-10 * time.Minute) + mock.ExpectQuery("l.soc_percent,l.total_mileage_km,l.updated_at").WithArgs(time.Date(2026, 10, 23, 0, 0, 0, 0, now.Location()), "vin", now.Add(-10*time.Minute)).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today", "event_time", "received_at", "event_id"}).AddRow("vin", "GB32960", 120, 30, 0, 0, nil, now, true, eventAt, now, "event")) + points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{"vin"}, now) + if err != nil { + t.Fatal(err) + } + plates := []string{"粤A12345F"} + vehicles := map[string]AuthorizedVehicle{plates[0]: {VIN: "vin", Plate: plates[0]}} + full := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location()) + if full[0].TotalMileageKm == nil || *full[0].TotalMileageKm != 0 || full[0].RecordTime != now.Format("2006-01-02 15:04:05") { + t.Fatal("FULL compatibility changed") + } + prepareSimpleRealtime(full, plates, vehicles, points, now.Location()) + if full[0].TotalMileageKm != nil || full[0].RecordTime != eventAt.Format(time.RFC3339Nano) { + t.Fatal("SIMPLE used update time or fabricated zero") + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + // An absent event time cannot be replaced with received_at or updated_at. + point := points["vin"] + point.LocationObservedAt = time.Time{} + available := true + point.TotalMileageAvailable = &available + points["vin"] = point + values := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location()) + prepareSimpleRealtime(values, plates, vehicles, points, now.Location()) + if values[0].RecordTime != "" || values[0].TotalMileageKm == nil || *values[0].TotalMileageKm != 0 { + t.Fatal("missing source time or real zero mishandled") + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/operating_data_test.go b/vehicle-data-platform/apps/api/internal/openplatform/operating_data_test.go new file mode 100644 index 00000000..ff43fc6e --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/operating_data_test.go @@ -0,0 +1,649 @@ +package openplatform + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "github.com/DATA-DOG/go-sqlmock" + "net/http/httptest" + "reflect" + "regexp" + "strings" + "sync" + "testing" + "time" +) + +const operatingTestKey = "0123456789abcdef0123456789abcdef" + +type operatingRepository struct { + *fakeRepository + mu sync.Mutex + calls map[string]int + auth func(time.Time, time.Time) error + scope func(time.Time, time.Time) (map[string]AuthorizedVehicle, error) + query func(context.Context, string) error + protocols []string +} + +func (f *operatingRepository) Authenticate(_ context.Context, _ [sha256.Size]byte, _ time.Time, start, end time.Time) (AppCredential, error) { + f.mu.Lock() + f.calls["auth"]++ + f.mu.Unlock() + if f.auth != nil { + if err := f.auth(start, end); err != nil { + return AppCredential{}, err + } + } + return f.app, nil +} +func (f *operatingRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, start, end time.Time) (map[string]AuthorizedVehicle, error) { + f.mu.Lock() + f.calls["scope"]++ + f.mu.Unlock() + if f.scope != nil { + return f.scope(start, end) + } + if len(plates) == 0 { + plates = vehiclePlates(f.vehicles) + } + found := make(map[string]AuthorizedVehicle, len(plates)) + for _, plate := range plates { + if v, ok := f.vehicles[plate]; ok { + found[plate] = v + } + } + return found, nil +} +func (f *operatingRepository) before(ctx context.Context, kind string) error { + f.mu.Lock() + f.calls[kind]++ + f.mu.Unlock() + if f.query != nil { + return f.query(ctx, kind) + } + return nil +} +func (f *operatingRepository) RealtimeVehicles(ctx context.Context, _ []string, _ time.Time) (map[string]RealtimeVehiclePoint, error) { + return f.realtime, f.before(ctx, operatingRealtime) +} +func (f *operatingRepository) DailyHydrogen(ctx context.Context, _ []string, _ string) (map[string]DailyHydrogen, error) { + return f.hydrogen, f.before(ctx, operatingHydrogen) +} +func (f *operatingRepository) ReconciledMileageRange(ctx context.Context, _ []string, _, _ string, protocols []string) (map[string]DailyMileage, error) { + f.mu.Lock() + f.protocols = append([]string(nil), protocols...) + f.mu.Unlock() + return f.mileage, f.before(ctx, operatingMileage) +} +func newOperatingFixture() (*Service, *operatingRepository) { + f := &operatingRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345F": {VIN: "LTEST32960VIN0001", Plate: "粤A12345F"}, "粤B12345F": {VIN: "LTEST32960VIN0002", Plate: "粤B12345F"}}}, calls: map[string]int{}} + s := NewService(f) + s.now = func() time.Time { return time.Date(2026, 10, 23, 9, 30, 0, 0, s.location) } + return s, f +} +func operatingRequest() OperatingDataRequest { + return OperatingDataRequest{PlateNumbers: []string{"粤B12345F", " 粤a12345f "}, DailyDate: "2026-10-23"} +} +func blockText(b OperatingDataBlock, key string) string { + var v string + _ = json.Unmarshal(b[key], &v) + return v +} + +func TestOperatingDataBatchesInParallelAndPreservesSemantics(t *testing.T) { + s, f := newOperatingFixture() + now := s.now() + f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: now.Add(-time.Second), Online: true, SpeedKmh: 5, LiveHydrogen: RealtimeHydrogenData{RemainingHydrogenKg: float64Pointer(0), RemainingHydrogenKgStatus: "NORMAL", RemainingHydrogenPercentStatus: "MISSING", HydrogenDataStatus: "PARTIAL", HydrogenValueSource: stringPointer("REPORTED"), HydrogenRecordTime: stringPointer("2026-10-23T09:29:59+08:00")}}} + f.hydrogen = map[string]DailyHydrogen{"LTEST32960VIN0001": {QualityStatus: "SUSPECT", QualityReason: "test anomaly", ConsumptionKg: 3, CalculationPhase: "PRELIMINARY", AlgorithmVersion: "test-v1"}} + f.mileage = map[string]DailyMileage{dailyMileageKey("LTEST32960VIN0001", "2026-10-23"): {Date: "2026-10-22", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 200, DataTime: "2026-10-22T23:00:00+08:00", UpdatedAt: "2026-10-23T05:00:00+08:00", DataQuality: "CARRIED_FORWARD"}} + started := make(chan string, 3) + release := make(chan struct{}) + f.query = func(ctx context.Context, kind string) error { + started <- kind + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + done := make(chan *OperatingDataResponse, 1) + errs := make(chan error, 1) + go func() { + r, e := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()) + done <- r + errs <- e + }() + for i := 0; i < 3; i++ { + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("batched queries did not start concurrently") + } + } + close(release) + result := <-done + if err := <-errs; err != nil { + t.Fatal(err) + } + if len(result.Data) != 2 || result.Data[0].PlateNumber != "粤B12345F" || result.Summary.SuccessCount != 2 || result.QueryDate == nil { + t.Fatalf("unexpected response: %+v", result) + } + for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} { + if f.calls[kind] != 1 { + t.Fatalf("%s calls=%d", kind, f.calls[kind]) + } + } + if f.calls["auth"] != 2 || f.calls["scope"] != 2 { + t.Fatalf("duplicate authorization reads: %v", f.calls) + } + row := result.Data[1] + if string(row.Realtime["remainingHydrogenKg"]) != "0" || blockText(row.Realtime, "hydrogenDataStatus") != "PARTIAL" || blockText(row.Realtime, "recordTime") != "2026-10-23T09:29:59+08:00" { + t.Fatalf("live quality/zero/time lost: %s", mustOperatingJSON(row.Realtime)) + } + if blockText(row.DailyMileage, "dataQuality") != "CARRIED_FORWARD" || blockText(row.DailyMileage, "status") != "NORMAL" || string(row.DailyMileage["dailyMileageKm"]) != "0" { + t.Fatal("carry-forward semantics changed") + } + if blockText(row.DailyHydrogen, "status") != "DATA_ANOMALY" || string(row.DailyHydrogen["hydrogenConsumptionKg"]) != "3" { + t.Fatal("anomaly evidence lost") + } + if blockText(result.Data[0].Realtime, "status") != "NO_DATA" || string(result.Data[0].Realtime["socPercent"]) != "null" { + t.Fatal("missing record not preserved") + } + // Compare with the independent endpoints through their original query services. + f.query = nil + old := NewService(f.fakeRepository) + old.now = s.now + plates := []string{"粤B12345F", "粤A12345F"} + rt, err := old.QueryRealtimeVehicles(context.Background(), operatingTestKey, "old", RealtimeVehicleRequest{PlateNumbers: plates}) + if err != nil { + t.Fatal(err) + } + h, err := old.QueryHydrogen(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"}) + if err != nil { + t.Fatal(err) + } + m, err := old.QueryMileage(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"}) + if err != nil { + t.Fatal(err) + } + for i := range rt { + a, _ := operatingBlock(rt[i], s.location) + b, _ := operatingBlock(h[i], s.location) + c, _ := operatingBlock(m[i], s.location) + if !reflect.DeepEqual(a, result.Data[i].Realtime) || !reflect.DeepEqual(b, result.Data[i].DailyHydrogen) || !reflect.DeepEqual(c, result.Data[i].DailyMileage) { + t.Fatal("independent query mismatch") + } + } +} +func mustOperatingJSON(v any) string { b, _ := json.Marshal(v); return string(b) } + +func TestOperatingDataAuthorizationFailsBeforeReadingData(t *testing.T) { + for _, scenario := range []string{"invalid key", "current key", "history key", "current scope", "history scope", "identity conflict", "scope unavailable", "auth unavailable"} { + t.Run(scenario, func(t *testing.T) { + s, f := newOperatingFixture() + key := operatingTestKey + want := ErrForbidden + if scenario == "invalid key" { + key = "bad" + want = ErrUnauthorized + } + f.auth = func(start, end time.Time) error { + if scenario == "current key" && start.Equal(end) { + return ErrUnauthorized + } + if scenario == "history key" && !start.Equal(end) { + return ErrUnauthorized + } + if scenario == "auth unavailable" { + return errors.New("database unavailable") + } + return nil + } + f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) { + if scenario == "current scope" && start.Equal(end) || scenario == "history scope" && !start.Equal(end) { + return map[string]AuthorizedVehicle{}, nil + } + if scenario == "scope unavailable" { + return nil, errors.New("database unavailable") + } + found := map[string]AuthorizedVehicle{} + for k, v := range f.vehicles { + found[k] = v + } + if scenario == "identity conflict" && !start.Equal(end) { + found["粤A12345F"] = AuthorizedVehicle{VIN: "different"} + } + return found, nil + } + _, err := s.QueryOperatingData(context.Background(), key, "trace", operatingRequest()) + switch scenario { + case "current key": + want = ErrUnauthorized + case "auth unavailable", "scope unavailable", "identity conflict": + want = nil + } + if err == nil || want != nil && !errors.Is(err, want) { + t.Fatalf("err=%v want=%v", err, want) + } + if want == nil && (errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden)) { + t.Fatal("infrastructure error hidden as denial") + } + for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} { + if f.calls[kind] != 0 { + t.Fatal("queried data before all scopes passed") + } + } + }) + } +} + +func TestOperatingDataSelectionsAndFailures(t *testing.T) { + for _, scenario := range []string{"only realtime", "only mileage", "hydrogen failure", "all failed", "invalid source"} { + t.Run(scenario, func(t *testing.T) { + s, f := newOperatingFixture() + r := operatingRequest() + switch scenario { + case "only realtime": + r.DataTypes = []string{operatingRealtime} + r.DailyDate = "" + case "only mileage": + r.DataTypes = []string{operatingMileage} + r.ProtocolPriority = ProtocolPriority{Present: true, Values: []string{"JT808"}} + case "invalid source": + f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {ObservedAt: s.now(), LiveHydrogen: RealtimeHydrogenData{HydrogenDataStatus: "NORMAL", HydrogenRecordTime: stringPointer("bad timestamp")}}} + } + f.query = func(_ context.Context, kind string) error { + if scenario == "all failed" || scenario == "hydrogen failure" && kind == operatingHydrogen { + return errors.New("SQL secret should not escape") + } + return nil + } + result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r) + if err != nil { + t.Fatal(err) + } + switch scenario { + case "only realtime": + if result.QueryDate != nil || result.Data[0].DailyMileage != nil || result.Data[0].DailyHydrogen != nil || f.calls["auth"] != 1 || f.calls["scope"] != 1 { + t.Fatal("unselected work executed") + } + case "only mileage": + if result.Data[0].Realtime != nil || f.calls["scope"] != 1 || !reflect.DeepEqual(f.protocols, []string{"JT808"}) { + t.Fatalf("selection/protocol ignored: %v", f.protocols) + } + case "hydrogen failure": + if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_FAILED" || len(result.Data[0].DailyHydrogen) != 4 { + t.Fatal("failure envelope incorrect") + } + case "all failed": + if result.Summary.FailedCount != 2 { + t.Fatal("all-failed count incorrect") + } + case "invalid source": + if blockText(result.Data[1].Realtime, "errorCode") != "SOURCE_DATA_INVALID" || result.Summary.PartialSuccessCount != 1 { + t.Fatal("invalid source not isolated") + } + } + if strings.Contains(mustOperatingJSON(result), "SQL secret") { + t.Fatal("upstream details leaked") + } + }) + } +} + +func TestOperatingDataDeadlineKeepsCompletedBlocks(t *testing.T) { + s, f := newOperatingFixture() + exited := make(chan struct{}) + f.query = func(ctx context.Context, kind string) error { + if kind == operatingHydrogen { + <-ctx.Done() + close(exited) + return ctx.Err() + } + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + result, err := s.QueryOperatingData(ctx, operatingTestKey, "trace", operatingRequest()) + if err != nil { + t.Fatal(err) + } + if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_TIMEOUT" { + t.Fatalf("deadline discarded successful blocks: %+v", result.Summary) + } + select { + case <-exited: + case <-time.After(time.Second): + t.Fatal("query not canceled") + } +} + +func TestOperatingDataHTTPValidationAndRouting(t *testing.T) { + tests := []struct { + body string + status int + }{ + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`, 200}, + {`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`, 200}, + {`{"dailyDate":"2026-10-23"}`, 200}, + {`{"plateNumbers":[],"dailyDate":"2026-10-23"}`, 200}, + {`{"dataTypes":["REALTIME"]}`, 200}, + {`{"plateNumbers":[],"dataTypes":["REALTIME"],"responseMode":"SIMPLE"}`, 200}, + {`{"plateNumbers":null,"dataTypes":["REALTIME"]}`, 400}, + {`{}`, 400}, {`null`, 400}, {`[]`, 400}, {`{"plateNumbers":[]}`, 400}, + {`{"plateNumbers":["粤A12345F"," 粤a12345f "],"dailyDate":"2026-10-23"}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":null}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-24"}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":null}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":[]}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":["REALTIME","REALTIME"]}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"],"protocolPriority":["GB32960"]}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","protocolPriority":null}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","extra":1}`, 400}, + {`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]} {}`, 400}, + {`{"plateNumbers":["` + strings.Repeat("x", 65<<10) + `"]}`, 413}, + } + for i, tc := range tests { + t.Run(fmt.Sprint(i), func(t *testing.T) { + s, _ := newOperatingFixture() + r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(tc.body)) + r.Header.Set("Authorization", "Bearer "+operatingTestKey) + w := httptest.NewRecorder() + NewDataHandler(s).ServeHTTP(w, r) + if w.Code != tc.status { + t.Fatalf("status=%d want=%d body=%s", w.Code, tc.status, w.Body.String()) + } + }) + } + if !IsPublicPath(OperatingDataQueryPath) { + t.Fatal("new endpoint not registered as appKey route") + } + for _, construct := range []func(*Service) *Handler{NewHandler, func(s *Service) *Handler { return NewExternalHandler(s, nil) }} { + s, _ := newOperatingFixture() + r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`)) + r.Header.Set("Authorization", "Bearer "+operatingTestKey) + w := httptest.NewRecorder() + construct(s).ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("handler route missing: %d", w.Code) + } + } +} + +func TestOperatingDataLimitsAndBatchBoundary(t *testing.T) { + s, f := newOperatingFixture() + r := OperatingDataRequest{DataTypes: []string{operatingRealtime}} + for i := 0; i < 200; i++ { + plate := fmt.Sprintf("TEST%03d", i) + r.PlateNumbers = append(r.PlateNumbers, plate) + f.vehicles[plate] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)} + } + // A repository returns only the authorized requested subset. + f.scope = func(_, _ time.Time) (map[string]AuthorizedVehicle, error) { + v := map[string]AuthorizedVehicle{} + for _, p := range r.PlateNumbers { + v[p] = f.vehicles[p] + } + return v, nil + } + result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r) + if err != nil || len(result.Data) != 200 { + t.Fatalf("boundary: %v", err) + } + r.PlateNumbers = append(r.PlateNumbers, "TEST201") + if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r); !errors.Is(err, ErrInvalidRequest) { + t.Fatal("201 accepted") + } + s, f = newOperatingFixture() + _ = f + for i := 0; i < 30; i++ { + if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err != nil { + t.Fatal(err) + } + } + w := httptest.NewRecorder() + req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`)) + req.Header.Set("Authorization", "Bearer "+operatingTestKey) + NewDataHandler(s).ServeHTTP(w, req) + if w.Code != 429 || w.Header().Get("Retry-After") == "" || !strings.Contains(w.Body.String(), "RATE_LIMIT_EXCEEDED") { + t.Fatalf("limit: %d %s", w.Code, w.Body.String()) + } + // Global capacity rejects work before any scope/data reads. + s, f = newOperatingFixture() + for i := 0; i < cap(s.operatingSlots); i++ { + s.operatingSlots <- struct{}{} + } + if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err == nil || f.calls["scope"] != 0 { + t.Fatal("global capacity ignored") + } +} + +func BenchmarkOperatingData200Vehicles(b *testing.B) { + s, f := newOperatingFixture() + r := operatingRequest() + r.PlateNumbers = nil + f.vehicles = map[string]AuthorizedVehicle{} + for i := 0; i < 200; i++ { + p := fmt.Sprintf("TEST%03d", i) + r.PlateNumbers = append(r.PlateNumbers, p) + f.vehicles[p] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)} + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.operatingLimiter = historicalHydrogenLimiter{} + f.audits = nil + if _, err := s.QueryOperatingData(context.Background(), operatingTestKey, "bench", r); err != nil { + b.Fatal(err) + } + } +} + +func TestOperatingInstantGrantExpiresExclusively(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + now := time.Date(2026, 10, 23, 10, 0, 0, 0, time.UTC) + mock.ExpectQuery(regexp.QuoteMeta("AND (g.valid_to IS NULL OR g.valid_to>?)")).WithArgs(uint64(7), now, now, "粤A12345F").WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"})) + _, err = NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, []string{"粤A12345F"}, now, now) + if err != nil { + t.Fatal(err) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestOperatingDataRetainsAllBusinessFields(t *testing.T) { + s, _ := newOperatingFixture() + for _, value := range []any{RealtimeVehicleResult{}, MileageResult{}, HydrogenResult{}} { + block, err := operatingBlock(value, s.location) + if err != nil { + t.Fatal(err) + } + var check func(reflect.Type) + check = func(typ reflect.Type) { + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + if field.Anonymous { + check(field.Type) + continue + } + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "vin" || name == "plateNumber" || name == "" { + continue + } + if _, ok := block[name]; !ok { + t.Errorf("%s missing %s", typ.Name(), name) + } + } + } + check(reflect.TypeOf(value)) + if _, exists := block["vin"]; exists { + t.Fatal("duplicated identity") + } + } +} + +func TestOperatingDataGeneratesDistinctTraceIDs(t *testing.T) { + s, _ := newOperatingFixture() + handler := NewDataHandler(s) + seen := map[string]bool{} + for i := 0; i < 2; i++ { + r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`)) + r.Header.Set("Authorization", "Bearer "+operatingTestKey) + r.Header.Set("X-Trace-Id", "reused-client-id") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + var result OperatingDataResponse + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if w.Code != 200 || result.TraceID == "reused-client-id" || len(result.TraceID) != 32 || seen[result.TraceID] { + t.Fatalf("invalid trace: %s", w.Body.String()) + } + seen[result.TraceID] = true + } +} + +func TestOperatingDataOptionalPlatesAuthorization(t *testing.T) { + for _, scenario := range []string{"all", "realtime only", "history only", "intersection", "empty current", "empty history", "no grants", "identity conflict", "large all", "large before intersection"} { + t.Run(scenario, func(t *testing.T) { + s, f := newOperatingFixture() + r := OperatingDataRequest{DailyDate: "2026-10-23"} + if scenario == "realtime only" { + r.DataTypes = []string{operatingRealtime} + r.DailyDate = "" + } + if scenario == "history only" { + r.DataTypes = []string{operatingMileage, operatingHydrogen} + } + f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) { + found := map[string]AuthorizedVehicle{} + for plate, v := range f.vehicles { + found[plate] = v + } + current := start.Equal(end) + if scenario == "no grants" || scenario == "empty current" && current || scenario == "empty history" && !current { + return map[string]AuthorizedVehicle{}, nil + } + if scenario == "intersection" && !current { + delete(found, "粤B12345F") + found["NEW"] = AuthorizedVehicle{VIN: "OTHER"} + } + if scenario == "identity conflict" && !current { + found["粤B12345F"] = AuthorizedVehicle{VIN: "CHANGED"} + } + if scenario == "large all" || scenario == "large before intersection" && current { + for i := 0; i < 201; i++ { + found[fmt.Sprintf("EXTRA%03d", i)] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)} + } + } + return found, nil + } + result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r) + if scenario == "identity conflict" { + if err == nil { + t.Fatalf("expected authorization error, got %v", err) + } + } else { + if err != nil { + t.Fatal(err) + } + want := 2 + if scenario == "large all" { + want = 203 + } + if scenario == "intersection" { + want = 1 + } + if scenario == "empty current" || scenario == "empty history" || scenario == "no grants" { + want = 0 + } + if result.Data == nil || len(result.Data) != want || result.Summary.RequestedCount != want || result.Summary.ReturnedCount != want { + t.Fatalf("incorrect authorized result: %+v", result) + } + if want > 0 && scenario != "large all" && result.Data[0].PlateNumber != "粤A12345F" { + t.Fatal("default vehicle order is not stable") + } + } + if err != nil || len(result.Data) == 0 { + for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} { + if f.calls[kind] != 0 { + t.Fatal("business data read without an authorized batch") + } + } + } + }) + } +} + +func TestOperatingData1151AuthorizedVehiclesHTTP(t *testing.T) { + for _, mode := range []string{"FULL", "SIMPLE"} { + for _, empty := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/empty=%t", mode, empty), func(t *testing.T) { + s, f := newOperatingFixture() + f.vehicles = map[string]AuthorizedVehicle{} + for i := 0; i < 1151; i++ { + plate := fmt.Sprintf("TEST%04d", i) + f.vehicles[plate] = AuthorizedVehicle{Plate: plate, VIN: fmt.Sprintf("VIN%014d", i)} + } + payload := map[string]any{"responseMode": mode, "dailyDate": "2026-10-23"} + if empty { + payload["plateNumbers"] = []string{} + } + r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(payload))) + r.Header.Set("Authorization", "Bearer "+operatingTestKey) + w := httptest.NewRecorder() + NewDataHandler(s).ServeHTTP(w, r) + var result struct { + Data []struct { + PlateNumber string `json:"plateNumber"` + } `json:"data"` + Summary OperatingDataSummary `json:"summary"` + } + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + if w.Code != 200 || len(result.Data) != 1151 || result.Summary.RequestedCount != 1151 || result.Summary.ReturnedCount != 1151 { + t.Fatalf("status=%d rows=%d summary=%+v", w.Code, len(result.Data), result.Summary) + } + for i, row := range result.Data { + if row.PlateNumber != fmt.Sprintf("TEST%04d", i) { + t.Fatal("missing, duplicate or unordered authorized vehicle") + } + } + for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} { + if f.calls[kind] != 1 { + t.Fatalf("%s not queried in one batch", kind) + } + } + }) + } + } +} + +func TestOperatingDataExplicitBatchSizeErrorHTTP(t *testing.T) { + s, _ := newOperatingFixture() + plates := make([]string, 201) + for i := range plates { + plates[i] = fmt.Sprintf("TEST%04d", i) + } + r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(map[string]any{"plateNumbers": plates, "dataTypes": []string{operatingRealtime}}))) + r.Header.Set("Authorization", "Bearer "+operatingTestKey) + w := httptest.NewRecorder() + NewDataHandler(s).ServeHTTP(w, r) + var response ExternalResponse + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if w.Code != 400 || response.Code != "BATCH_SIZE_EXCEEDED" || !strings.Contains(response.Message, "200") || response.TraceID == "" { + t.Fatalf("status=%d response=%+v", w.Code, response) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go index 0aee2610..887ce673 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -282,18 +282,24 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str _ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error()) return nil, err } + results := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location) + _ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "") + return results, nil +} + +func buildRealtimeVehicleResults(plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, now time.Time, location *time.Location) []RealtimeVehicleResult { results := make([]RealtimeVehicleResult, 0, len(plates)) for _, plate := range plates { vehicle := vehicles[plate] item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData, RealtimeHydrogenData: missingRealtimeHydrogen(""), GPSFixStatus: "UNKNOWN", CoordinateSystem: "UNKNOWN"} if point, ok := points[vehicle.VIN]; ok { - difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds()) + difference := int64(now.Sub(point.ObservedAt.In(location)).Seconds()) if difference < 0 { difference = 0 } item.Protocol = externalMileageProtocol(point.Protocol) - applyRealtimeLiveData(&item, point, s.location) - item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05") + applyRealtimeLiveData(&item, point, location) + item.RecordTime = point.ObservedAt.In(location).Format("2006-01-02 15:04:05") item.TimeDifferenceSeconds = &difference item.Online = point.Online item.ActiveToday = point.ActiveToday @@ -315,8 +321,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str } results = append(results, item) } - _ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "") - return results, nil + return results } func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) { @@ -349,6 +354,8 @@ func validCoordinate(longitude, latitude float64) bool { type Service struct { historicalLimiter historicalHydrogenLimiter + operatingLimiter historicalHydrogenLimiter + operatingSlots chan struct{} repository Repository now func() time.Time location *time.Location @@ -362,9 +369,10 @@ type parsedGrant struct { func NewService(repository Repository) *Service { return &Service{ - repository: repository, - now: time.Now, - location: time.FixedZone("Asia/Shanghai", 8*60*60), + repository: repository, + operatingSlots: make(chan struct{}, 16), + now: time.Now, + location: time.FixedZone("Asia/Shanghai", 8*60*60), } } @@ -387,6 +395,12 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error()) return nil, err } + results := buildHydrogenResults(plates, vehicles, values, date) + _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "") + return results, nil +} + +func buildHydrogenResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyHydrogen, date string) []HydrogenResult { results := make([]HydrogenResult, 0, len(plates)) for _, plate := range plates { vehicle := vehicles[plate] @@ -414,8 +428,7 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req } results = append(results, item) } - _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "") - return results, nil + return results } func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) { @@ -441,6 +454,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ _ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error()) return nil, err } + results := buildMileageResults(plates, vehicles, values, date) + _ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "") + return results, nil +} + +func buildMileageResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyMileage, date string) []MileageResult { results := make([]MileageResult, 0, len(plates)) for _, plate := range plates { vehicle := vehicles[plate] @@ -450,8 +469,7 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ } results = append(results, item) } - _ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "") - return results, nil + return results } func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) { @@ -716,8 +734,8 @@ func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, s return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: endDate precedes startDate", ErrInvalidRequest) } end := endInclusive.AddDate(0, 0, 1) - if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 366 { - return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 366 days", ErrInvalidRequest) + if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 1096 { + return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 1096 days", ErrInvalidRequest) } pageSize := request.PageSize if pageSize == 0 { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go index 0214a3c5..a5bf6965 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -661,7 +661,7 @@ func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testin func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) { service := NewService(&fakeRepository{}) for _, request := range []MileageRangeRequest{ - {StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100}, + {StartDate: "2023-07-01", EndDate: "2026-07-02", PageSize: 100}, {StartDate: "2026-07-02", EndDate: "2026-07-01", PageSize: 100}, {StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 5001}, } { @@ -801,3 +801,12 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) { t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String()) } } + +// Preserve the already deployed three-year window when adding new public APIs. +func TestMileageRangePreservesProductionThreeYearWindow(t *testing.T) { + s := NewService(&fakeRepository{}) + _, _, _, _, _, _, _, err := s.validateMileageRange(MileageRangeRequest{StartDate: "2023-07-01", EndDate: "2026-06-30", PageSize: 1}) + if err != nil { + t.Fatalf("production 1096-day window rejected: %v", err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go index 023cd9da..51251bc0 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/statistics.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/statistics.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "lingniu/vehicle-data-platform/apps/api/internal/hydrogen" ) var hydrogenMassFields = []string{ @@ -818,23 +820,7 @@ ORDER BY event_time ` + direction + ` LIMIT 1` } func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) { - temperatureK := temperatureC + 273.15 - if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 { - return 0, false - } - a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403} - b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0} - c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5} - z := 1.0 - for index := range a { - z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index]) - } - if z <= 0 { - return 0, false - } - density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000 - mass := density * capacityLiter / 1000 - return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500 + return hydrogen.PressureMassKg(pressureMPa, temperatureC, capacityLiter) } func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error { diff --git a/vehicle-data-platform/apps/api/internal/platform/access.go b/vehicle-data-platform/apps/api/internal/platform/access.go index 27c8ca75..3b965690 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access.go +++ b/vehicle-data-platform/apps/api/internal/platform/access.go @@ -124,6 +124,9 @@ func (s *Service) ClaimAccessIdentity(ctx context.Context, identityID string, in return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_INVALID", Message: "待认领来源标识无效,请刷新后重试"} } input.VIN = strings.ToUpper(strings.TrimSpace(input.VIN)) + if err := authorizeVehicleVIN(ctx, input.VIN); err != nil { + return AccessIdentityClaimResult{}, err + } if !validAccessClaimVIN(input.VIN) { return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VIN_INVALID", Message: "请选择有效的权威 VIN"} } @@ -230,6 +233,9 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe order := make([]string, 0, len(evidence)) for _, item := range evidence { vin := strings.TrimSpace(item.VIN) + if p, ok := PrincipalFromContext(ctx); ok && !p.CanVIN(vin) { + continue + } if _, exists := byVIN[vin]; !exists { order = append(order, vin) } diff --git a/vehicle-data-platform/apps/api/internal/platform/alert.go b/vehicle-data-platform/apps/api/internal/platform/alert.go index 5fb29e15..c7c45e61 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert.go @@ -44,6 +44,7 @@ func (s *Service) alertStore() (alertStore, error) { } func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) { + query.AllowedVINs = principalVehicleVINScope(ctx) store, err := s.alertStore() if err != nil { return AlertSummary{}, err @@ -52,6 +53,7 @@ func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSumm } func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) { + query.AllowedVINs = principalVehicleVINScope(ctx) if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { if strings.TrimSpace(query.Keyword) == "" { return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"} @@ -91,6 +93,9 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) if err != nil { return AlertEvent{}, err } + if err := authorizeVehicleVIN(ctx, event.VIN); err != nil { + return AlertEvent{}, err + } return normalizeVehicleEvent(event), nil } @@ -404,10 +409,22 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction if err != nil { return AlertEvent{}, err } + if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() { + existing, err := store.AlertEvent(ctx, strings.TrimSpace(id)) + if err != nil { + return AlertEvent{}, err + } + if err := authorizeVehicleVIN(ctx, existing.VIN); err != nil { + return AlertEvent{}, err + } + } event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request) if err != nil { return AlertEvent{}, err } + if err := authorizeVehicleVIN(ctx, event.VIN); err != nil { + return AlertEvent{}, err + } return normalizeVehicleEvent(event), nil } @@ -497,7 +514,7 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio default: return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"} } - if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() { query.AllowedVINs = alertNotificationAllowedVINs(principal) if len(query.AllowedVINs) == 0 { return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil @@ -515,7 +532,7 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN return 0, clientError{Code: "ALERT_NOTIFICATION_IDS_INVALID", Message: "请选择 1 到 100 条通知"} } request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin") - if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() { request.AllowedVINs = alertNotificationAllowedVINs(principal) if len(request.AllowedVINs) == 0 { return 0, nil diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_mock.go b/vehicle-data-platform/apps/api/internal/platform/alert_mock.go index 3e277be3..fb1c9539 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_mock.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_mock.go @@ -106,6 +106,15 @@ func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSumm } for _, item := range m.alertNotifications { if item.Channel == "in_app" && !item.Read { + if query.AllowedVINs != nil { + allowed := false + for _, vin := range query.AllowedVINs { + allowed = allowed || item.VehicleVIN == vin + } + if !allowed { + continue + } + } result.UnreadNotifications++ } } @@ -157,6 +166,15 @@ func (m *MockStore) AlertEvents(_ context.Context, query AlertQuery) (Page[Alert } func keepMockAlertEvent(event AlertEvent, query AlertQuery) bool { + if query.AllowedVINs != nil { + allowed := false + for _, vin := range query.AllowedVINs { + allowed = allowed || event.VIN == vin + } + if !allowed { + return false + } + } keyword := strings.ToLower(query.Keyword) if keyword != "" && !strings.Contains(strings.ToLower(event.VIN), keyword) && !strings.Contains(strings.ToLower(event.Plate), keyword) && !strings.Contains(strings.ToLower(event.RuleName), keyword) { return false diff --git a/vehicle-data-platform/apps/api/internal/platform/alert_store.go b/vehicle-data-platform/apps/api/internal/platform/alert_store.go index c38e8914..262dae6c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert_store.go @@ -37,7 +37,15 @@ FROM vehicle_alert_event e WHERE `+where, args...).Scan(&result.Active, &result. if err != nil { return AlertSummary{}, err } - if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE channel='in_app' AND is_read=0`).Scan(&result.UnreadNotifications); err != nil { + notificationWhere, notificationArgs := []string{"channel='in_app'", "is_read=0"}, []any{} + if query.AllowedVINs != nil { + if len(query.AllowedVINs) == 0 { + notificationWhere = append(notificationWhere, "1=0") + } else { + notificationWhere, notificationArgs = appendVINListFilter(notificationWhere, notificationArgs, "e.vin", strings.Join(query.AllowedVINs, ",")) + } + } + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `+strings.Join(notificationWhere, " AND "), notificationArgs...).Scan(&result.UnreadNotifications); err != nil { return AlertSummary{}, err } result.AsOf = time.Now().Format(time.RFC3339) @@ -73,6 +81,13 @@ func (s *ProductionStore) ActiveAlertVINs(ctx context.Context, protocol string) func buildAlertWhere(query AlertQuery) (string, []any) { where := []string{"1=1"} args := []any{} + if query.AllowedVINs != nil { + if len(query.AllowedVINs) == 0 { + where = append(where, "1=0") + } else { + where, args = appendVINListFilter(where, args, "e.vin", strings.Join(query.AllowedVINs, ",")) + } + } if query.Keyword != "" { like := "%" + query.Keyword + "%" where = append(where, "(e.vin LIKE ? OR e.plate LIKE ? OR e.rule_name LIKE ?)") diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_hydrogen_jobs.go b/vehicle-data-platform/apps/api/internal/platform/customer_hydrogen_jobs.go new file mode 100644 index 00000000..36467360 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_hydrogen_jobs.go @@ -0,0 +1,52 @@ +package platform + +import ( + "context" + "strings" +) + +type CustomerHydrogenJob struct { + Date string `json:"date"` + Status string `json:"status"` + CompletedAt string `json:"completedAt"` + Attempts int `json:"attempts"` +} +type customerHydrogenJobStore interface { + RequestCustomerHydrogenDays(context.Context, []string) ([]CustomerHydrogenJob, error) +} + +func (s *ProductionStore) RequestCustomerHydrogenDays(ctx context.Context, dates []string) ([]CustomerHydrogenJob, error) { + values := make([]string, len(dates)) + args := make([]any, len(dates)) + for i, date := range dates { + values[i] = "(?)" + args[i] = date + } + if len(dates) == 0 { + return []CustomerHydrogenJob{}, nil + } + _, err := s.db.ExecContext(ctx, `INSERT IGNORE INTO platform_customer_hydrogen_job(stat_date) VALUES `+strings.Join(values, ","), args...) + if err != nil { + return nil, err + } + // Recent dates can receive late telemetry. Completed older days remain cached; + // current/recent days are recomputed at most once per hour, outside requests. + _, err = s.db.ExecContext(ctx, `UPDATE platform_customer_hydrogen_job SET status='pending',requested_at=NOW(3),attempts=0,error_message='' WHERE stat_date>=DATE_SUB(CURDATE(),INTERVAL 2 DAY) AND stat_date BETWEEN ? AND ? AND status='complete' AND completed_at 3000 { + return fmt.Errorf("达标里程必须大于低里程阈值且不超过 3000 km") + } + if r.DeviationKm <= 0 || r.DeviationKm > 3000 || r.DeviationPercent <= 0 || r.DeviationPercent > 100 || r.MinimumGPSKm > 3000 { + return fmt.Errorf("偏差阈值应为 0–3000 km、0–100%%(不含零),最低 GPS 里程不超过 3000 km") + } + return nil +} + +type CustomerVehicleAssignment struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + Customer string `json:"customer"` + Department string `json:"department"` + ResponsiblePerson string `json:"responsiblePerson"` +} + +type CustomerVehicleDay struct { + Restricted bool `json:"restricted"` + HydrogenCoveragePercent *float64 `json:"hydrogenCoveragePercent"` + HydrogenObservedKm *float64 `json:"hydrogenObservedKm"` + HydrogenSource string `json:"hydrogenSource,omitempty"` + CustomerVehicleAssignment + Date string `json:"date"` + GPSKm *float64 `json:"gpsKm"` + HydrogenKm *float64 `json:"hydrogenKm"` + GPSStatus string `json:"gpsStatus"` + HydrogenStatus string `json:"hydrogenStatus"` + DifferenceKm *float64 `json:"differenceKm"` + DifferencePercent *float64 `json:"differencePercent"` + Alert string `json:"alert"` + Provisional bool `json:"provisional"` + QualityReason string `json:"qualityReason,omitempty"` +} + +func validCustomerMileage(v *float64) bool { + return v != nil && !math.IsNaN(*v) && !math.IsInf(*v, 0) && *v >= 0 +} + +func customerMileageBand(value *float64, rules CustomerMileageRules) string { + if !validCustomerMileage(value) { + return "missing" + } + if *value < rules.LowMileageKm { + return "low" + } + if *value < rules.TargetMileageKm { + return "below-target" + } + return "on-target" +} + +func evaluateCustomerVehicleDay(day CustomerVehicleDay, rules CustomerMileageRules) CustomerVehicleDay { + if day.Restricted { + day.GPSKm = nil + day.HydrogenKm = nil + day.GPSStatus = "restricted" + day.HydrogenStatus = "restricted" + day.DifferenceKm = nil + day.DifferencePercent = nil + day.Alert = "restricted" + return day + } + day.GPSStatus = customerMileageBand(day.GPSKm, rules) + day.HydrogenStatus = customerMileageBand(day.HydrogenKm, rules) + day.DifferenceKm, day.DifferencePercent = nil, nil + day.Alert = "normal" + if day.GPSStatus == "missing" || day.HydrogenStatus == "missing" { + day.Alert = "missing-data" + return day + } + difference := *day.GPSKm - *day.HydrogenKm + day.DifferenceKm = &difference + if *day.GPSKm > 0 { + percent := difference / *day.GPSKm * 100 + day.DifferencePercent = &percent + } + if strings.TrimSpace(day.QualityReason) != "" { + day.Alert = "data-quality" + return day + } + if day.Provisional && !rules.IncludeToday { + day.Alert = "provisional" + return day + } + if difference < -rules.DeviationKm { + day.Alert = "reverse-deviation" + return day + } + if *day.GPSKm >= rules.MinimumGPSKm && difference >= rules.DeviationKm && day.DifferencePercent != nil && *day.DifferencePercent >= rules.DeviationPercent { + day.Alert = "suspected-charging" + } + return day +} + +// CustomerMileageObservation holds independently selected GPS and hydrogen +// measurements. A missing source is nil, never an artificial zero. +type CustomerMileageObservation struct { + Restricted bool + HydrogenCoveragePercent *float64 + HydrogenObservedKm *float64 + HydrogenSource string + VIN string + Date string + GPSKm *float64 + HydrogenKm *float64 + QualityReason string +} + +type CustomerMileageDay struct { + Date string `json:"date"` + VehicleCount int `json:"vehicleCount"` + GPSVehicleCount int `json:"gpsVehicleCount"` + HydrogenVehicleCount int `json:"hydrogenVehicleCount"` + GPSKm *float64 `json:"gpsKm"` + HydrogenKm *float64 `json:"hydrogenKm"` + SuspectedChargingCount int `json:"suspectedChargingCount"` + DataIssueCount int `json:"dataIssueCount"` + LowMileageCount int `json:"lowMileageCount"` + Provisional bool `json:"provisional"` +} + +type CustomerMileageGroup struct { + Customer string `json:"customer"` + VehicleCount int `json:"vehicleCount"` + Departments []string `json:"departments"` + ResponsiblePeople []string `json:"responsiblePeople"` + Days []CustomerMileageDay `json:"days"` + Vehicles []CustomerVehicleDay `json:"vehicles"` +} + +// aggregateCustomerMileage receives authorized assignments only. It excludes +// unassigned VINs and refuses conflicting current assignments instead of placing +// the same vehicle under multiple customers. Caller must scope measurements too. +func aggregateCustomerMileage(assignments []CustomerVehicleAssignment, observations []CustomerMileageObservation, dates []string, today string, rules CustomerMileageRules) ([]CustomerMileageGroup, error) { + if err := rules.Validate(); err != nil { + return nil, err + } + byVIN := map[string]CustomerVehicleAssignment{} + for _, a := range assignments { + a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN)) + a.Customer = strings.TrimSpace(a.Customer) + a.Department = strings.TrimSpace(a.Department) + a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson) + if a.VIN == "" || a.Customer == "" { + continue + } + if prev, ok := byVIN[a.VIN]; ok && prev != a { + return nil, fmt.Errorf("车辆客户归属存在冲突,请刷新 OneOS 车辆数据") + } + byVIN[a.VIN] = a + } + type key struct{ vin, date string } + measured := map[key]CustomerMileageObservation{} + for _, o := range observations { + o.VIN = strings.ToUpper(strings.TrimSpace(o.VIN)) + if _, ok := byVIN[o.VIN]; !ok { + continue + } + k := key{o.VIN, o.Date} + if _, ok := measured[k]; ok { + return nil, fmt.Errorf("同一车辆日期存在重复里程结果") + } + measured[k] = o + } + groups := map[string]*CustomerMileageGroup{} + vins := make([]string, 0, len(byVIN)) + for vin := range byVIN { + vins = append(vins, vin) + } + sort.Strings(vins) + for _, vin := range vins { + a := byVIN[vin] + g := groups[a.Customer] + if g == nil { + g = &CustomerMileageGroup{Customer: a.Customer, Departments: []string{}, ResponsiblePeople: []string{}, Days: make([]CustomerMileageDay, len(dates)), Vehicles: []CustomerVehicleDay{}} + groups[a.Customer] = g + for i, date := range dates { + g.Days[i] = CustomerMileageDay{Date: date, Provisional: date == today} + } + } + g.VehicleCount++ + if a.Department != "" { + g.Departments = appendUniqueCustomerValue(g.Departments, a.Department) + } + if a.ResponsiblePerson != "" { + g.ResponsiblePeople = appendUniqueCustomerValue(g.ResponsiblePeople, a.ResponsiblePerson) + } + for i, date := range dates { + o := measured[key{vin, date}] + d := evaluateCustomerVehicleDay(CustomerVehicleDay{CustomerVehicleAssignment: a, Date: date, GPSKm: o.GPSKm, HydrogenKm: o.HydrogenKm, Restricted: o.Restricted, HydrogenCoveragePercent: o.HydrogenCoveragePercent, HydrogenObservedKm: o.HydrogenObservedKm, HydrogenSource: o.HydrogenSource, QualityReason: o.QualityReason, Provisional: date == today}, rules) + g.Vehicles = append(g.Vehicles, d) + day := &g.Days[i] + if !d.Restricted { + day.VehicleCount++ + } + if validCustomerMileage(d.GPSKm) { + addCustomerMileage(&day.GPSKm, *d.GPSKm) + day.GPSVehicleCount++ + } + if validCustomerMileage(d.HydrogenKm) { + addCustomerMileage(&day.HydrogenKm, *d.HydrogenKm) + day.HydrogenVehicleCount++ + } + if d.Alert == "suspected-charging" { + day.SuspectedChargingCount++ + } + if d.Alert == "missing-data" || d.Alert == "data-quality" || d.Alert == "reverse-deviation" { + day.DataIssueCount++ + } + if d.GPSStatus == "low" && (!d.Provisional || rules.IncludeToday) { + day.LowMileageCount++ + } + } + } + result := make([]CustomerMileageGroup, 0, len(groups)) + for _, g := range groups { + sort.Strings(g.Departments) + sort.Strings(g.ResponsiblePeople) + result = append(result, *g) + } + sort.Slice(result, func(i, j int) bool { return result[i].Customer < result[j].Customer }) + return result, nil +} + +func appendUniqueCustomerValue(values []string, value string) []string { + for _, existing := range values { + if existing == value { + return values + } + } + return append(values, value) +} +func addCustomerMileage(total **float64, value float64) { + if *total == nil { + *total = new(float64) + } + **total += value +} diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_mileage_handler.go b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_handler.go new file mode 100644 index 00000000..05e06f85 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_handler.go @@ -0,0 +1,34 @@ +package platform + +import ( + "encoding/json" + "io" + "net/http" +) + +func (h *Handler) handleCustomerMileage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + data, err := h.service.CustomerMileage(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} +func (h *Handler) handleCustomerMileageRules(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodGet { + data, err := h.service.CustomerMileageRules(r.Context()) + h.write(w, r, data, err) + return + } + var rules CustomerMileageRules + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&rules); err != nil { + h.write(w, r, nil, clientError{Code: "INVALID_JSON", Message: "预警规则格式无效"}) + return + } + if err := decoder.Decode(new(any)); err != io.EOF { + h.write(w, r, nil, clientError{Code: "INVALID_JSON", Message: "请求只能包含一份规则"}) + return + } + data, err := h.service.SaveCustomerMileageRules(r.Context(), rules) + h.write(w, r, data, err) +} diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_mileage_service.go b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_service.go new file mode 100644 index 00000000..624b530b --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_service.go @@ -0,0 +1,251 @@ +package platform + +import ( + "context" + "encoding/json" + "net/url" + "strings" + "time" +) + +type CustomerAssignmentSnapshot struct { + Rows []CustomerVehicleAssignment + ObservedAt time.Time + Tenant string + Live bool +} +type customerAssignmentContextKey struct{} + +func WithCustomerAssignments(ctx context.Context, snapshot CustomerAssignmentSnapshot) context.Context { + return context.WithValue(ctx, customerAssignmentContextKey{}, snapshot) +} + +type CustomerMileageResponse struct { + HydrogenJobs []CustomerHydrogenJob `json:"hydrogenJobs"` + Groups []CustomerMileageGroup `json:"groups"` + Rules CustomerMileageRules `json:"rules"` + Dates []string `json:"dates"` + AssignmentSource string `json:"assignmentSource"` + AssignmentObservedAt string `json:"assignmentObservedAt"` + AssignmentStale bool `json:"assignmentStale"` + AssignmentMissingCount int `json:"assignmentMissingCount"` + ExcludedUnassignedCount int `json:"excludedUnassignedCount"` + CanConfigure bool `json:"canConfigure"` +} + +func customerMileageAccess(ctx context.Context) (Principal, error) { + p, ok := PrincipalFromContext(ctx) + if !ok || p.AuthorizationStatus == "pending" || p.AuthorizationStatus == "loading" || !p.CanMenu("customer-mileage") { + return p, clientError{Code: "MENU_PERMISSION_DENIED", Message: "当前账号无权查看客户运营里程"} + } + return p, nil +} +func customerMileageScope(ctx context.Context, p Principal) string { + tenant := p.TenantRef + if snapshot, ok := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot); ok { + tenant = snapshot.Tenant + } + return "tenant:" + tenant +} +func (s *Service) customerMileageStore() (CustomerMileageStore, error) { + store, ok := s.store.(CustomerMileageStore) + if !ok { + return nil, clientError{Code: "CUSTOMER_MILEAGE_UNAVAILABLE", Message: "当前数据源尚未启用客户运营里程"} + } + return store, nil +} +func (s *Service) CustomerMileageRules(ctx context.Context) (CustomerMileageRules, error) { + p, err := customerMileageAccess(ctx) + if err != nil { + return CustomerMileageRules{}, err + } + store, err := s.customerMileageStore() + if err != nil { + return CustomerMileageRules{}, err + } + return store.CustomerMileageRules(ctx, customerMileageScope(ctx, p)) +} +func (s *Service) SaveCustomerMileageRules(ctx context.Context, r CustomerMileageRules) (CustomerMileageRules, error) { + p, err := customerMileageAccess(ctx) + if err != nil { + return r, err + } + if p.Role != "admin" || p.ReadOnly { + return r, clientError{Code: "PERMISSION_DENIED", Message: "仅管理员可修改客户运营里程规则"} + } + if err = r.Validate(); err != nil { + return r, clientError{Code: "CUSTOMER_MILEAGE_RULE_INVALID", Message: err.Error()} + } + store, err := s.customerMileageStore() + if err != nil { + return r, err + } + return store.SaveCustomerMileageRules(ctx, customerMileageScope(ctx, p), r, ActorFromContext(ctx)) +} + +func customerMileageDates(q url.Values, now time.Time) ([]string, error) { + zone := time.FixedZone("Asia/Shanghai", 8*3600) + today := now.In(zone).Format("2006-01-02") + from, to := q.Get("dateFrom"), q.Get("dateTo") + if to == "" { + to = now.In(zone).AddDate(0, 0, -1).Format("2006-01-02") + } + end, err := time.ParseInLocation("2006-01-02", to, zone) + if err != nil { + return nil, clientError{Code: "INVALID_DATE", Message: "结束日期格式应为 YYYY-MM-DD"} + } + if from == "" { + from = end.AddDate(0, 0, -6).Format("2006-01-02") + } + start, err := time.ParseInLocation("2006-01-02", from, zone) + if err != nil || start.After(end) || to > today || end.Sub(start) > 365*24*time.Hour { + return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "请选择不晚于今天、最多 366 天的有效日期范围"} + } + dates := []string{} + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + dates = append(dates, d.Format("2006-01-02")) + } + return dates, nil +} + +func (s *Service) CustomerMileage(ctx context.Context, q url.Values) (CustomerMileageResponse, error) { + response := CustomerMileageResponse{Groups: []CustomerMileageGroup{}} + p, err := customerMileageAccess(ctx) + if err != nil { + return response, err + } + store, err := s.customerMileageStore() + if err != nil { + return response, err + } + dates, err := customerMileageDates(q, time.Now()) + if err != nil { + return response, err + } + response.Dates = dates + snapshot, live := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot) + live = live && snapshot.Live + tenant := p.TenantRef + if snapshot.Tenant != "" { + tenant = snapshot.Tenant + } + if live { + snapshot.Rows, err = normalizeCustomerAssignments(snapshot.Rows) + if err != nil { + return response, err + } + response.AssignmentSource = "oneos-live" + // Persist only a complete, authenticated upstream snapshot. Never use client + // submitted metadata or another session's credentials to refresh it. + if err = store.SaveCustomerAssignments(ctx, tenant, snapshot.Rows, snapshot.ObservedAt); err != nil { + return response, err + } + } else { + snapshot.Rows, snapshot.ObservedAt, err = store.CustomerAssignments(ctx, tenant) + if err != nil { + return response, err + } + response.AssignmentSource = "oneos-synced" + } + response.AssignmentStale = snapshot.ObservedAt.IsZero() || time.Since(snapshot.ObservedAt) > 24*time.Hour + if !snapshot.ObservedAt.IsZero() { + response.AssignmentObservedAt = snapshot.ObservedAt.UTC().Format(time.RFC3339) + } + response.CanConfigure = p.Role == "admin" && !p.ReadOnly + response.Rules, err = store.CustomerMileageRules(ctx, customerMileageScope(ctx, p)) + if err != nil { + return response, err + } + selected := []CustomerVehicleAssignment{} + vins := []string{} + unassigned := map[string]bool{} + knownAssignments := map[string]bool{} + for _, a := range snapshot.Rows { + a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN)) + if !isLikelyVIN(a.VIN) || !p.CanVIN(a.VIN) { + continue + } + knownAssignments[a.VIN] = true + if strings.TrimSpace(a.Customer) == "" { + unassigned[a.VIN] = true + continue + } + if q.Get("customer") != "" && !strings.Contains(strings.ToLower(a.Customer), strings.ToLower(q.Get("customer"))) { + continue + } + if q.Get("department") != "" && !strings.Contains(strings.ToLower(a.Department), strings.ToLower(strings.TrimSpace(q.Get("department")))) { + continue + } + if q.Get("responsiblePerson") != "" && !strings.Contains(strings.ToLower(a.ResponsiblePerson), strings.ToLower(strings.TrimSpace(q.Get("responsiblePerson")))) { + continue + } + selected = append(selected, a) + vins = append(vins, a.VIN) + } + response.ExcludedUnassignedCount = len(unassigned) + if !live && p.HasVehicleScope() { + for _, vin := range p.VehicleVINs { + if p.CanVIN(vin) && !knownAssignments[vin] { + response.AssignmentMissingCount++ + } + } + } + if len(vins) == 0 { + return response, nil + } + if len(vins)*len(dates) > 100000 { + return response, clientError{Code: "CUSTOMER_MILEAGE_RANGE_TOO_LARGE", Message: "本次查询超过 100,000 个车辆日,请缩短日期或筛选客户、部门"} + } + // Construct a new query to discard all user-supplied authorization fields. + query := url.Values{"dateFrom": {dates[0]}, "dateTo": {dates[len(dates)-1]}, "vins": {strings.Join(vins, ",")}} + query, err = applyPrincipalVehicleScope(ctx, query) + if err != nil { + return response, err + } + if err = requirePrincipalHistoricalGrantScope(ctx, query); err != nil { + return response, err + } + observations, err := store.CustomerMileageObservations(ctx, query) + if err != nil { + return response, err + } + if p.HasHistoryTimeScope() { + starts := map[string]string{} + if err = json.Unmarshal([]byte(query.Get("scopeVehicleFrom")), &starts); err != nil { + return response, err + } + for _, a := range selected { + for _, date := range dates { + if date < starts[a.VIN] { + observations = append(observations, CustomerMileageObservation{VIN: a.VIN, Date: date, Restricted: true}) + } + } + } + } + response.Groups, err = aggregateCustomerMileage(selected, observations, dates, time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)).Format("2006-01-02"), response.Rules) + return response, err +} + +func normalizeCustomerAssignments(rows []CustomerVehicleAssignment) ([]CustomerVehicleAssignment, error) { + result := []CustomerVehicleAssignment{} + seen := map[string]CustomerVehicleAssignment{} + for _, a := range rows { + a.VIN = strings.ToUpper(strings.TrimSpace(a.VIN)) + a.Plate = strings.TrimSpace(a.Plate) + a.Customer = strings.TrimSpace(a.Customer) + a.Department = strings.TrimSpace(a.Department) + a.ResponsiblePerson = strings.TrimSpace(a.ResponsiblePerson) + if !isLikelyVIN(a.VIN) { + continue + } + if old, ok := seen[a.VIN]; ok { + if old != a { + return nil, clientError{Code: "CUSTOMER_ASSIGNMENT_CONFLICT", Message: "OneOS 同一 VIN 存在冲突归属,请先核查车辆目录"} + } + continue + } + seen[a.VIN] = a + result = append(result, a) + } + return result, nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_mileage_service_test.go b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_service_test.go new file mode 100644 index 00000000..22843c7e --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_service_test.go @@ -0,0 +1,141 @@ +package platform + +import ( + "context" + "net/url" + "strings" + "testing" + "time" +) + +type customerMileageTestStore struct { + Store + assignments []CustomerVehicleAssignment + query url.Values + saved int + rules CustomerMileageRules +} + +func (s *customerMileageTestStore) CustomerMileageRules(context.Context, string) (CustomerMileageRules, error) { + return s.rules, nil +} +func (s *customerMileageTestStore) SaveCustomerMileageRules(_ context.Context, _ string, r CustomerMileageRules, _ string) (CustomerMileageRules, error) { + s.saved++ + s.rules = r + return r, nil +} +func (s *customerMileageTestStore) CustomerMileageObservations(_ context.Context, q url.Values) ([]CustomerMileageObservation, error) { + s.query = q + return []CustomerMileageObservation{}, nil +} +func (s *customerMileageTestStore) SaveCustomerAssignments(context.Context, string, []CustomerVehicleAssignment, time.Time) error { + return nil +} +func (s *customerMileageTestStore) CustomerAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) { + return s.assignments, time.Now(), nil +} + +func TestCustomerMileageServiceScopesAssignmentsAndHistory(t *testing.T) { + vin := "LNX00000000000001" + other := "LNX00000000000002" + store := &customerMileageTestStore{Store: NewMockStore(), rules: DefaultCustomerMileageRules(), assignments: []CustomerVehicleAssignment{{VIN: vin, Customer: "甲"}, {VIN: other, Customer: "乙"}}} + service := NewService(store) + from := time.Date(2026, 1, 10, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{"customer-mileage"}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: from}}} + ctx := WithPrincipal(context.Background(), p) + result, err := service.CustomerMileage(ctx, url.Values{"dateFrom": {"2026-01-01"}, "dateTo": {"2026-01-31"}, "scopeVins": {other}, "scopeVehicleFrom": {"{}"}}) + if err != nil { + t.Fatal(err) + } + if len(result.Groups) != 1 || result.Groups[0].Customer != "甲" { + t.Fatalf("unauthorized assignment exposed: %+v", result.Groups) + } + if store.query.Get("vins") != vin || store.query.Get("scopeVins") != vin || !strings.Contains(store.query.Get("scopeVehicleFrom"), "2026-01-11") { + t.Fatalf("lost history scope: %v", store.query) + } + if _, err = service.SaveCustomerMileageRules(ctx, store.rules); err == nil || store.saved != 0 { + t.Fatal("customer changed shared rules") + } + p.MenuKeys = []string{"statistics"} + ctx = WithPrincipal(context.Background(), p) + if _, err = service.CustomerMileage(ctx, url.Values{}); err == nil { + t.Fatal("statistics permission implicitly exposed hydrogen") + } + p.MenuKeys = []string{"customer-mileage"} + p.VehicleGrants = nil + ctx = WithPrincipal(context.Background(), p) + if _, err = service.CustomerMileage(ctx, url.Values{}); err == nil { + t.Fatal("native historical grant bypassed") + } +} + +func TestCustomerMileageDateRangeValidation(t *testing.T) { + now := time.Date(2026, 9, 30, 1, 0, 0, 0, time.UTC) + dates, err := customerMileageDates(url.Values{}, now) + if err != nil || len(dates) != 7 || dates[0] != "2026-09-23" || dates[6] != "2026-09-29" { + t.Fatalf("bad default: %v %v", dates, err) + } + for _, q := range []url.Values{{"dateFrom": {"2026-10-01"}}, {"dateTo": {"2026-09-31"}}, {"dateFrom": {"2020-01-01"}}, {"dateFrom": {"2026-09-30"}, "dateTo": {"2026-09-29"}}} { + if _, err = customerMileageDates(q, now); err == nil { + t.Fatalf("bad dates accepted: %v", q) + } + } +} + +func TestCustomerMileageSQLPreservesScopeAndIndependentSources(t *testing.T) { + q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "vins": {"LNX00000000000001"}, "scopeVins": {"LNX00000000000001"}, "scopeVehicleFrom": {`{"LNX00000000000001":"2026-09-10"}`}} + sql := buildCustomerMileageSQL(q) + for _, want := range []string{"m.protocol = 'JT808'", "m.vin IN", "m.stat_date >="} { + if !strings.Contains(sql.Text, want) { + t.Fatalf("missing %s in query", want) + } + } + if strings.Contains(sql.Text, "LNX00000000000001") { + t.Fatal("VIN interpolated into SQL") + } +} + +func TestCustomerAssignmentConflictPreventsPersistence(t *testing.T) { + vin := "LNX00000000000001" + if _, err := normalizeCustomerAssignments([]CustomerVehicleAssignment{{VIN: vin, Customer: "甲"}, {VIN: vin, Customer: ""}}); err == nil { + t.Fatal("assigned/unassigned conflict accepted") + } + got, err := normalizeCustomerAssignments([]CustomerVehicleAssignment{{VIN: vin, Customer: " 甲 "}, {VIN: vin, Customer: "甲"}}) + if err != nil || len(got) != 1 || got[0].Customer != "甲" { + t.Fatalf("normalized duplicates not collapsed: %+v %v", got, err) + } +} + +func TestCustomerMileageHistoricalRestrictionsAreNotMissingDataAlerts(t *testing.T) { + rules := DefaultCustomerMileageRules() + groups, err := aggregateCustomerMileage([]CustomerVehicleAssignment{{VIN: "VIN", Customer: "甲"}}, []CustomerMileageObservation{{VIN: "VIN", Date: "2026-09-01", Restricted: true}}, []string{"2026-09-01"}, "2026-09-30", rules) + if err != nil { + t.Fatal(err) + } + if groups[0].Days[0].DataIssueCount != 0 || groups[0].Days[0].VehicleCount != 0 || groups[0].Vehicles[0].Alert != "restricted" { + t.Fatalf("authorization absence misclassified: %+v", groups[0]) + } +} + +func TestCustomerMileageFuzzyAssignmentFiltersKeepVINScope(t *testing.T) { + vin := "LNX00000000000001" + store := &customerMileageTestStore{Store: NewMockStore(), rules: DefaultCustomerMileageRules(), assignments: []CustomerVehicleAssignment{ + {VIN: vin, Customer: "上海客户甲", Department: "华东业务一部", ResponsiblePerson: "负责人Alice"}, + {VIN: "LNX00000000000002", Customer: "上海客户乙", Department: "华东业务一部", ResponsiblePerson: "负责人Alice"}, + }} + p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{"customer-mileage"}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}}} + ctx := WithPrincipal(context.Background(), p) + q := url.Values{"dateFrom": {"2026-01-10"}, "dateTo": {"2026-01-10"}, "customer": {"上海"}, "department": {" 一部 "}, "responsiblePerson": {"alice"}} + result, err := NewService(store).CustomerMileage(ctx, q) + if err != nil { + t.Fatal(err) + } + if len(result.Groups) != 1 || result.Groups[0].Customer != "上海客户甲" || store.query.Get("vins") != vin { + t.Fatalf("partial filters lost scope: %+v %v", result.Groups, store.query) + } + q.Set("responsiblePerson", "不存在") + result, err = NewService(store).CustomerMileage(ctx, q) + if err != nil || len(result.Groups) != 0 { + t.Fatalf("non-matching keyword returned data: %+v %v", result, err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_mileage_store.go b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_store.go new file mode 100644 index 00000000..52b2731f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_store.go @@ -0,0 +1,215 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/url" + "strings" + "time" +) + +type CustomerMileageStore interface { + CustomerMileageRules(context.Context, string) (CustomerMileageRules, error) + SaveCustomerMileageRules(context.Context, string, CustomerMileageRules, string) (CustomerMileageRules, error) + CustomerMileageObservations(context.Context, url.Values) ([]CustomerMileageObservation, error) + SaveCustomerAssignments(context.Context, string, []CustomerVehicleAssignment, time.Time) error + CustomerAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) +} + +func (s *ProductionStore) CustomerMileageRules(ctx context.Context, scope string) (CustomerMileageRules, error) { + var raw []byte + var version int64 + err := s.db.QueryRowContext(ctx, `SELECT version,rules_json FROM platform_customer_mileage_rules WHERE scope_key=?`, scope).Scan(&version, &raw) + if errors.Is(err, sql.ErrNoRows) { + r := DefaultCustomerMileageRules() + r.Version = 0 + return r, nil + } + if err != nil { + return CustomerMileageRules{}, err + } + var r CustomerMileageRules + if err = json.Unmarshal(raw, &r); err != nil { + return r, err + } + r.Version = version + return r, r.Validate() +} + +func (s *ProductionStore) SaveCustomerMileageRules(ctx context.Context, scope string, r CustomerMileageRules, actor string) (CustomerMileageRules, error) { + if err := r.Validate(); err != nil { + return r, clientError{Code: "CUSTOMER_MILEAGE_RULE_INVALID", Message: err.Error()} + } + previous := r.Version + r.Version++ + raw, err := json.Marshal(r) + if err != nil { + return r, err + } + var result sql.Result + if previous == 0 { + result, err = s.db.ExecContext(ctx, `INSERT IGNORE INTO platform_customer_mileage_rules(scope_key,version,rules_json,updated_by) VALUES(?,?,?,?)`, scope, r.Version, string(raw), actor) + } else { + result, err = s.db.ExecContext(ctx, `UPDATE platform_customer_mileage_rules SET version=?,rules_json=?,updated_by=? WHERE scope_key=? AND version=?`, r.Version, string(raw), actor, scope, previous) + } + if err != nil { + return CustomerMileageRules{}, err + } + n, err := result.RowsAffected() + if err != nil { + return CustomerMileageRules{}, err + } + if n != 1 { + return CustomerMileageRules{}, clientError{Code: "CUSTOMER_MILEAGE_RULE_CONFLICT", Message: "规则已被其他用户更新,请重新加载后保存"} + } + return r, nil +} + +func (s *ProductionStore) SaveCustomerAssignments(ctx context.Context, tenant string, rows []CustomerVehicleAssignment, observed time.Time) error { + if len(rows) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + // A caller-scoped snapshot never deletes other users' metadata. Blank customer + // values deliberately replace old assignments when a vehicle is unassigned. + for start := 0; start < len(rows); start += 200 { + end := start + 200 + if end > len(rows) { + end = len(rows) + } + placeholders := make([]string, 0, end-start) + args := make([]any, 0, (end-start)*7) + for _, r := range rows[start:end] { + placeholders = append(placeholders, "(?,?,?,?,?,?,?)") + args = append(args, tenant, r.VIN, r.Plate, r.Customer, r.Department, r.ResponsiblePerson, observed) + } + _, err = tx.ExecContext(ctx, `INSERT INTO platform_customer_vehicle_assignment(tenant_ref,vin,plate,customer_name,department_name,responsible_person,observed_at) VALUES `+strings.Join(placeholders, ",")+` ON DUPLICATE KEY UPDATE plate=IF(VALUES(observed_at)>=observed_at,VALUES(plate),plate), customer_name=IF(VALUES(observed_at)>=observed_at,VALUES(customer_name),customer_name), department_name=IF(VALUES(observed_at)>=observed_at,VALUES(department_name),department_name), responsible_person=IF(VALUES(observed_at)>=observed_at,VALUES(responsible_person),responsible_person), observed_at=GREATEST(observed_at,VALUES(observed_at))`, args...) + if err != nil { + return err + } + } + return tx.Commit() +} + +func (s *ProductionStore) CustomerAssignments(ctx context.Context, tenant string) ([]CustomerVehicleAssignment, time.Time, error) { + rows, err := s.db.QueryContext(ctx, `SELECT vin,plate,customer_name,department_name,responsible_person,observed_at FROM platform_customer_vehicle_assignment WHERE tenant_ref=? ORDER BY vin`, tenant) + if err != nil { + return nil, time.Time{}, err + } + defer rows.Close() + result := []CustomerVehicleAssignment{} + var oldest time.Time + for rows.Next() { + var a CustomerVehicleAssignment + var observed time.Time + if err = rows.Scan(&a.VIN, &a.Plate, &a.Customer, &a.Department, &a.ResponsiblePerson, &observed); err != nil { + return nil, time.Time{}, err + } + result = append(result, a) + if oldest.IsZero() || observed.Before(oldest) { + oldest = observed + } + } + return result, oldest, rows.Err() +} + +func buildCustomerMileageSQL(q url.Values) SQLQuery { + where := []string{"m.stat_date >= ?", "m.stat_date <= ?", "m.protocol = 'JT808'"} + args := []any{q.Get("dateFrom"), q.Get("dateTo")} + where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins")) + where, args = appendVINListFilter(where, args, "m.vin", q.Get("scopeVins")) + where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom")) + return SQLQuery{Text: `SELECT m.vin,DATE_FORMAT(m.stat_date,'%Y-%m-%d'),m.daily_mileage_km FROM vehicle_daily_mileage m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.vin,m.stat_date`, Args: args} +} + +func (s *ProductionStore) CustomerMileageObservations(ctx context.Context, q url.Values) ([]CustomerMileageObservation, error) { + built := buildCustomerMileageSQL(q) + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) + if err != nil { + return nil, err + } + defer rows.Close() + result := []CustomerMileageObservation{} + indexes := map[string]int{} + for rows.Next() { + var vin, date string + var daily float64 + if err = rows.Scan(&vin, &date, &daily); err != nil { + return nil, err + } + key := vin + "/" + date + index, ok := indexes[key] + if !ok { + index = len(result) + indexes[key] = index + result = append(result, CustomerMileageObservation{VIN: vin, Date: date}) + } + o := &result[index] + if daily < 0 || daily > 3000 { + o.QualityReason = "GPS 日里程超出有效范围,需核查累计里程跳变" + continue + } + o.GPSKm = &daily + } + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() + return s.enrichCustomerHydrogen(ctx, q, result) +} + +func buildCustomerHydrogenProjectionSQL(q url.Values) SQLQuery { + where := []string{"m.stat_date >= ?", "m.stat_date <= ?", "m.account = 'guangdonglingniu'"} + args := []any{q.Get("dateFrom"), q.Get("dateTo")} + where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins")) + where, args = appendVINListFilter(where, args, "m.vin", q.Get("scopeVins")) + where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom")) + return SQLQuery{Text: `SELECT m.vin,DATE_FORMAT(m.stat_date,'%Y-%m-%d'),m.hydrogen_km FROM gzev_daily_mileage m WHERE ` + strings.Join(where, " AND ") + ` ORDER BY m.vin,m.stat_date`, Args: args} +} + +func (s *ProductionStore) enrichCustomerHydrogen(ctx context.Context, q url.Values, result []CustomerMileageObservation) ([]CustomerMileageObservation, error) { + built := buildCustomerHydrogenProjectionSQL(q) + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) + if err != nil { + return nil, err + } + defer rows.Close() + indexes := map[string]int{} + for i, r := range result { + indexes[r.VIN+"/"+r.Date] = i + } + selected := map[string]bool{} + for rows.Next() { + var vin, date string + var hydrogen sql.NullFloat64 + if err = rows.Scan(&vin, &date, &hydrogen); err != nil { + return nil, err + } + key := vin + "/" + date + if selected[key] { + continue + } + selected[key] = true + index, ok := indexes[key] + if !ok { + index = len(result) + indexes[key] = index + result = append(result, CustomerMileageObservation{VIN: vin, Date: date}) + } + o := &result[index] + o.HydrogenSource = "GZEV / hyRunMileage" + if hydrogen.Valid && hydrogen.Float64 >= 0 && hydrogen.Float64 <= 3000 { + value := hydrogen.Float64 + o.HydrogenKm = &value + } else if hydrogen.Valid { + o.QualityReason = "GZEV 氢能源日里程超出有效范围,需核查源数据" + } + } + return result, rows.Err() +} diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_mileage_store_test.go b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_store_test.go new file mode 100644 index 00000000..6f24e562 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_store_test.go @@ -0,0 +1,70 @@ +package platform + +import ( + "context" + "github.com/DATA-DOG/go-sqlmock" + "net/url" + "strings" + "testing" +) + +func TestCustomerMileageStoreUsesGZEVAndPreservesMissing(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("SELECT m.vin.*vehicle_daily_mileage").WithArgs("2026-10-01", "2026-10-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "day", "gps"}).AddRow("A", "2026-10-01", 250).AddRow("B", "2026-10-01", 100).AddRow("C", "2026-10-01", 100)) + mock.ExpectQuery("SELECT m.vin.*gzev_daily_mileage").WithArgs("2026-10-01", "2026-10-01").WillReturnRows(sqlmock.NewRows([]string{"vin", "day", "hydrogen"}).AddRow("A", "2026-10-01", 180).AddRow("B", "2026-10-01", nil).AddRow("C", "2026-10-01", 0).AddRow("D", "2026-10-01", 80)) + result, err := (&ProductionStore{db: db}).CustomerMileageObservations(context.Background(), url.Values{"dateFrom": {"2026-10-01"}, "dateTo": {"2026-10-01"}}) + if err != nil { + t.Fatal(err) + } + if len(result) != 4 || *result[0].GPSKm != 250 || *result[0].HydrogenKm != 180 || result[0].HydrogenSource != "GZEV / hyRunMileage" { + t.Fatalf("wrong source: %+v", result) + } + if result[1].HydrogenKm != nil || result[2].HydrogenKm == nil || *result[2].HydrogenKm != 0 || result[3].GPSKm != nil { + t.Fatal("null, zero, or independent sources lost") + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestCustomerGZEVSQLPreservesAuthorization(t *testing.T) { + q := url.Values{"dateFrom": {"2026-10-01"}, "dateTo": {"2026-10-07"}, "vins": {"VIN-A"}, "scopeVins": {"VIN-A"}, "scopeVehicleFrom": {`{"VIN-A":"2026-10-03"}`}} + built := buildCustomerHydrogenProjectionSQL(q) + for _, want := range []string{"gzev_daily_mileage", "m.account = 'guangdonglingniu'", "m.vin IN", "m.stat_date >="} { + if !strings.Contains(built.Text, want) { + t.Fatalf("missing %s", want) + } + } + if strings.Contains(built.Text, "VIN-A") { + t.Fatal("scope interpolated into SQL") + } +} + +func TestCustomerMileageRulesConcurrentSaveReturnsConflict(t *testing.T) { + for _, version := range []int64{0, 4} { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + rules := DefaultCustomerMileageRules() + rules.Version = version + pattern := "UPDATE platform_customer_mileage_rules" + if version == 0 { + pattern = "INSERT IGNORE INTO platform_customer_mileage_rules" + } + mock.ExpectExec(pattern).WillReturnResult(sqlmock.NewResult(0, 0)) + _, err = (&ProductionStore{db: db}).SaveCustomerMileageRules(context.Background(), "tenant:000000", rules, "admin") + ce, ok := asClientError(err) + if !ok || ce.Code != "CUSTOMER_MILEAGE_RULE_CONFLICT" { + t.Fatalf("concurrent save lost: %v", err) + } + if err = mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + db.Close() + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/customer_mileage_test.go b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_test.go new file mode 100644 index 00000000..61c22086 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/customer_mileage_test.go @@ -0,0 +1,108 @@ +package platform + +import ( + "math" + "testing" +) + +func TestCustomerMileageDeviationRequiresComparableEvidence(t *testing.T) { + rules := DefaultCustomerMileageRules() + ptr := func(v float64) *float64 { return &v } + for _, tt := range []struct { + name string + gps, h *float64 + today bool + quality, want string + }{ + {"large positive discrepancy", ptr(200), ptr(100), false, "", "suspected-charging"}, + {"relative threshold alone insufficient", ptr(50), ptr(25), false, "", "normal"}, + {"absolute threshold alone insufficient", ptr(300), ptr(265), false, "", "normal"}, + {"missing hydrogen is not zero", ptr(200), nil, false, "", "missing-data"}, + {"observed zero hydrogen", ptr(200), ptr(0), false, "", "suspected-charging"}, + {"no driving", ptr(0), ptr(0), false, "", "normal"}, + {"reverse discrepancy", ptr(100), ptr(200), false, "", "reverse-deviation"}, + {"unfinished day", ptr(200), ptr(0), true, "", "provisional"}, + {"unreliable evidence", ptr(200), ptr(0), false, "采样不完整", "data-quality"}, + {"invalid gps", ptr(math.NaN()), ptr(0), false, "", "missing-data"}, + } { + t.Run(tt.name, func(t *testing.T) { + got := evaluateCustomerVehicleDay(CustomerVehicleDay{GPSKm: tt.gps, HydrogenKm: tt.h, Provisional: tt.today, QualityReason: tt.quality}, rules) + if got.Alert != tt.want { + t.Fatalf("got %s want %s", got.Alert, tt.want) + } + }) + } + day := CustomerVehicleDay{GPSKm: ptr(200), HydrogenKm: ptr(100), Provisional: true} + rules.IncludeToday = true + if evaluateCustomerVehicleDay(day, rules).Alert != "suspected-charging" { + t.Fatal("live-day preference ignored") + } + rules.DeviationKm = 150 + if evaluateCustomerVehicleDay(day, rules).Alert != "normal" { + t.Fatal("changed rules did not change evaluation") + } +} + +func TestCustomerMileageBandsAndRuleValidation(t *testing.T) { + r := DefaultCustomerMileageRules() + if err := r.Validate(); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + km float64 + band string + }{{0, "low"}, {49.9, "low"}, {50, "below-target"}, {199.9, "below-target"}, {200, "on-target"}} { + if got := customerMileageBand(&tt.km, r); got != tt.band { + t.Fatalf("%v: %s", tt.km, got) + } + } + for _, mutate := range []func(*CustomerMileageRules){func(r *CustomerMileageRules) { r.LowMileageKm = r.TargetMileageKm }, func(r *CustomerMileageRules) { r.DeviationPercent = 101 }, func(r *CustomerMileageRules) { r.DeviationKm = 0 }, func(r *CustomerMileageRules) { r.MinimumGPSKm = math.Inf(1) }} { + invalid := r + mutate(&invalid) + if invalid.Validate() == nil { + t.Fatal("invalid rule accepted") + } + } +} + +func TestCustomerMileageAggregationScopeCoverageAndVehicleAlerts(t *testing.T) { + ptr := func(v float64) *float64 { return &v } + assignments := []CustomerVehicleAssignment{ + {VIN: "A", Customer: " 客户甲 ", Department: "业务一部", ResponsiblePerson: "张"}, + {VIN: "B", Customer: "客户甲", Department: "业务二部", ResponsiblePerson: "李"}, + {VIN: "C", Customer: "客户甲"}, + {VIN: "D", Customer: ""}, + } + assignments = append(assignments, assignments[0]) + observations := []CustomerMileageObservation{ + {VIN: "A", Date: "2026-09-28", GPSKm: ptr(200), HydrogenKm: ptr(100)}, + {VIN: "B", Date: "2026-09-28", GPSKm: ptr(100), HydrogenKm: ptr(200)}, + {VIN: "D", Date: "2026-09-28", GPSKm: ptr(9000), HydrogenKm: ptr(9000)}, + {VIN: "UNAUTHORIZED", Date: "2026-09-28", GPSKm: ptr(9000), HydrogenKm: ptr(9000)}, + } + got, err := aggregateCustomerMileage(assignments, observations, []string{"2026-09-28", "2026-09-29"}, "2026-09-30", DefaultCustomerMileageRules()) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].VehicleCount != 3 || len(got[0].Vehicles) != 6 { + t.Fatalf("wrong customer/vehicle cardinality: %+v", got) + } + g := got[0] + d := g.Days[0] + if *d.GPSKm != 300 || *d.HydrogenKm != 300 || d.GPSVehicleCount != 2 || d.HydrogenVehicleCount != 2 { + t.Fatalf("incorrect scope or coverage: %+v", d) + } + if d.SuspectedChargingCount != 1 || d.DataIssueCount != 2 { + t.Fatalf("vehicle-level discrepancies cancelled: %+v", d) + } + if g.Days[1].GPSKm != nil || g.Days[1].HydrogenKm != nil || g.Days[1].DataIssueCount != 3 { + t.Fatalf("missing day became zero: %+v", g.Days[1]) + } + if len(g.Departments) != 2 || len(g.ResponsiblePeople) != 2 { + t.Fatal("grouping metadata lost") + } + assignments = append(assignments, CustomerVehicleAssignment{VIN: "A", Customer: "客户乙"}) + if _, err := aggregateCustomerMileage(assignments, observations, []string{"2026-09-28"}, "2026-09-30", DefaultCustomerMileageRules()); err == nil { + t.Fatal("conflicting customer assignment counted twice") + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/data/vehicle_numbers.json b/vehicle-data-platform/apps/api/internal/platform/data/vehicle_numbers.json new file mode 100644 index 00000000..82efddd7 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/data/vehicle_numbers.json @@ -0,0 +1,557 @@ +{ + "source": "码头车辆编号.xlsx", + "sheet": "总表", + "sha256": "01b6510d55e41659f506b1fd3adfb430e137b95b2b935f281acb47caa0f830f2", + "vehicles": [ + { + "vin": "LA9GG64L9NBAF4191", + "plate": "浙F05388F", + "number": "01Q" + }, + { + "vin": "LA9GG64L3NBAF4204", + "plate": "浙F05798F", + "number": "02Q" + }, + { + "vin": "LA9GG64L2NBAF4193", + "plate": "浙F35688F", + "number": "03Q" + }, + { + "vin": "LA9GG64L1NBAF4203", + "plate": "浙F08268F", + "number": "04Q" + }, + { + "vin": "LA9GG64L3NBAF4168", + "plate": "浙F31668F", + "number": "05Q" + }, + { + "vin": "LA9GG64L1NBAF4198", + "plate": "浙F01208F", + "number": "06Q" + }, + { + "vin": "LA9GG68L0PBAF4268", + "plate": "浙F07033F", + "number": "07Q" + }, + { + "vin": "LA9GG64L3NBAF4185", + "plate": "浙F09300F", + "number": "08Q" + }, + { + "vin": "LA9GG68L2PBAF4286", + "plate": "浙F39086F", + "number": "09Q" + }, + { + "vin": "LA9GG68L7PBAF4820", + "plate": "浙F36386F", + "number": "10Q" + }, + { + "vin": "LA9GG68L6PBAF4808", + "plate": "浙F08076F", + "number": "11Q" + }, + { + "vin": "LA9GG64L8NBAF4201", + "plate": "浙F07599F", + "number": "12Q" + }, + { + "vin": "LA9GG64L7NBAF4187", + "plate": "浙F01355F", + "number": "13Q" + }, + { + "vin": "LA9GG68L2PBAF4787", + "plate": "浙F08021F", + "number": "14Q" + }, + { + "vin": "LA9GG68L6PBAF4775", + "plate": "浙F08197F", + "number": "15Q" + }, + { + "vin": "LA9GG68L1PBAF4795", + "plate": "浙F05186F", + "number": "16Q" + }, + { + "vin": "LA9GG68L3PBAF4281", + "plate": "浙F03383F", + "number": "17Q" + }, + { + "vin": "LA9GG68L9PBAF4267", + "plate": "浙F06909F", + "number": "18Q" + }, + { + "vin": "LA9GG68L2PBAF4241", + "plate": "浙F09038F", + "number": "19Q" + }, + { + "vin": "LA9GG68L0PBAF4805", + "plate": "浙F05519F", + "number": "20Q" + }, + { + "vin": "LA9GG64L3NBAF4199", + "plate": "浙F08886F", + "number": "21Q" + }, + { + "vin": "LA9GG64L5NBAF4186", + "plate": "浙F32999F", + "number": "22Q" + }, + { + "vin": "LA9GG64LXNBAF4197", + "plate": "浙F08699F", + "number": "23Q" + }, + { + "vin": "LA9GG68L2PBAF4272", + "plate": "浙F09070F", + "number": "24Q" + }, + { + "vin": "LA9GG64L6NBAF4200", + "plate": "浙F02698F", + "number": "25Q" + }, + { + "vin": "LA9GG64L6NBAF4195", + "plate": "浙F05969F", + "number": "26Q" + }, + { + "vin": "LA9GG64L5NBAF4012", + "plate": "浙F08833F", + "number": "27Q" + }, + { + "vin": "LA9GG64L2NBAF4016", + "plate": "浙F02818F", + "number": "28Q" + }, + { + "vin": "LA9GG64L3NBAF4011", + "plate": "浙F00900F", + "number": "29Q" + }, + { + "vin": "LA9GG64L4NBAF4017", + "plate": "浙F08066F", + "number": "30Q" + }, + { + "vin": "LA9GG64L4NBAF4020", + "plate": "浙F39885F", + "number": "31Q" + }, + { + "vin": "LA9GG64L6NBAF4018", + "plate": "浙F36000F", + "number": "32Q" + }, + { + "vin": "LA9GG64L0NBAF4015", + "plate": "浙F00688F", + "number": "33Q" + }, + { + "vin": "LA9GG64L7NBAF4013", + "plate": "浙F02228F", + "number": "34Q" + }, + { + "vin": "LA9GG68L8PBAF4289", + "plate": "浙F06196F", + "number": "35Q" + }, + { + "vin": "LA9GG64L8NBAF4019", + "plate": "浙F32111F", + "number": "36Q" + }, + { + "vin": "LA9GG64L5NBAF4172", + "plate": "浙F05202F", + "number": "37Q" + }, + { + "vin": "LA9GG64L9NBAF4174", + "plate": "浙F08860F", + "number": "38Q" + }, + { + "vin": "LA9GG64L1NBAF4167", + "plate": "浙F09968F", + "number": "39Q" + }, + { + "vin": "LA9GG64L1NBAF4170", + "plate": "浙F08133F", + "number": "40Q" + }, + { + "vin": "LA9GG64L7NBAF4173", + "plate": "浙F06885F", + "number": "41Q" + }, + { + "vin": "LA9GG64LXNBAF4166", + "plate": "浙F08112F", + "number": "42Q" + }, + { + "vin": "LA9GG64L8NBAF4179", + "plate": "浙F00661F", + "number": "43Q" + }, + { + "vin": "LA9GG64L4NBAF4177", + "plate": "浙F08863F", + "number": "44Q" + }, + { + "vin": "LA9GG64L8NBAF4165", + "plate": "浙F01505F", + "number": "45Q" + }, + { + "vin": "LA9GG64L5NBAF4169", + "plate": "浙F05521F", + "number": "46Q" + }, + { + "vin": "LA9GG64L0NBAF4192", + "plate": "浙F07559F", + "number": "47Q" + }, + { + "vin": "LA9GG64L2NBAF4176", + "plate": "浙F06508F", + "number": "48Q" + }, + { + "vin": "LA9GG64L4NBAF4180", + "plate": "浙F05110F", + "number": "49Q" + }, + { + "vin": "LA9GG64L0NBAF4175", + "plate": "浙F06618F", + "number": "50Q" + }, + { + "vin": "LA9GG64L6NBAF4181", + "plate": "浙F00399F", + "number": "51Q" + }, + { + "vin": "LA9GG68L0PBAF4271", + "plate": "浙F01689F", + "number": "52Q" + }, + { + "vin": "LA9GG64L1NBAF4184", + "plate": "浙F32233F", + "number": "53Q" + }, + { + "vin": "LA9GG64LXNBAF4183", + "plate": "浙F03100F", + "number": "54Q" + }, + { + "vin": "LA9GG64L8NBAF4196", + "plate": "浙F07808F", + "number": "55Q" + }, + { + "vin": "LA9GG64L8NBAF4182", + "plate": "浙F06220F", + "number": "56Q" + }, + { + "vin": "LA9GG68L9PBAF4785", + "plate": "浙F03738F", + "number": "57Q" + }, + { + "vin": "LA9GG68L7PBAF4817", + "plate": "浙F08907F", + "number": "58Q" + }, + { + "vin": "LA9GG68LXPBAF4794", + "plate": "浙F08662F", + "number": "59Q" + }, + { + "vin": "LA9GG68L6PBAF4811", + "plate": "浙F08207F", + "number": "60Q" + }, + { + "vin": "LA9GG68L3PBAF4779", + "plate": "浙F06221F", + "number": "61Q" + }, + { + "vin": "LA9GG68LXPBAF4780", + "plate": "浙F07681F", + "number": "62Q" + }, + { + "vin": "LA9GG68L1PBAF4781", + "plate": "浙F08709F", + "number": "63Q" + }, + { + "vin": "LA9GG68L5PBAF4783", + "plate": "浙F08233F", + "number": "64Q" + }, + { + "vin": "LA9GG68L6PBAF4792", + "plate": "浙F38919F", + "number": "65Q" + }, + { + "vin": "LA9GG68L3PBAF4801", + "plate": "浙F39987F", + "number": "66Q" + }, + { + "vin": "LA9GG68L5PBAF4802", + "plate": "浙F36788F", + "number": "67Q" + }, + { + "vin": "LA9GG68L2PBAF4773", + "plate": "浙F09995F", + "number": "68Q" + }, + { + "vin": "LA9GG68L4PBAF4774", + "plate": "浙F00582F", + "number": "69Q" + }, + { + "vin": "LA9GG68L8PBAF4776", + "plate": "浙F00780F", + "number": "70Q" + }, + { + "vin": "LA9GG68L4PBAF4791", + "plate": "浙F08921F", + "number": "71Q" + }, + { + "vin": "LA9GG68L0PBAF4819", + "plate": "浙F08191F", + "number": "72Q" + }, + { + "vin": "LA9GG68L1PBAF4778", + "plate": "浙F05268F", + "number": "73Q" + }, + { + "vin": "LA9GG68L3PBAF4782", + "plate": "浙F08117F", + "number": "74Q" + }, + { + "vin": "LA9GG64L9NBAF4188", + "plate": "浙F03228F", + "number": "75Q" + }, + { + "vin": "LA9GG68L6PBAF4789", + "plate": "浙F05696F", + "number": "76Q" + }, + { + "vin": "LA9GG68L5PBAF4797", + "plate": "浙F31088F", + "number": "77Q" + }, + { + "vin": "LA9GG68L7PBAF4798", + "plate": "浙F05191F", + "number": "78Q" + }, + { + "vin": "LA9GG68L9PBAF4799", + "plate": "浙F32918F", + "number": "79Q" + }, + { + "vin": "LA9GG68L7PBAF4803", + "plate": "浙F02798F", + "number": "80Q" + }, + { + "vin": "LA9GG68L8PBAF4812", + "plate": "浙F03360F", + "number": "81Q" + }, + { + "vin": "LA9GG68L9PBAF4771", + "plate": "浙F31066F", + "number": "82Q" + }, + { + "vin": "LA9GG68L3PBAF4796", + "plate": "浙F37838F", + "number": "83Q" + }, + { + "vin": "LA9GG68L1PBAF4800", + "plate": "浙F02078F", + "number": "84Q" + }, + { + "vin": "LA9GG68L9PBAF4804", + "plate": "浙F02881F", + "number": "85Q" + }, + { + "vin": "LA9GG68L2PBAF4806", + "plate": "浙F32110F", + "number": "86Q" + }, + { + "vin": "LA9GG68L5PBAF4816", + "plate": "浙F08821F", + "number": "87Q" + }, + { + "vin": "LA9GG68L7PBAF4784", + "plate": "浙F32578F", + "number": "88Q" + }, + { + "vin": "LA9GG68L8PBAF4793", + "plate": "浙F03890F", + "number": "89Q" + }, + { + "vin": "LA9GG68L3PBAF4815", + "plate": "浙F06269F", + "number": "90Q" + }, + { + "vin": "LA9GG68L0PBAF4772", + "plate": "浙F38009F", + "number": "91Q" + }, + { + "vin": "LA9GG68LXPBAF4777", + "plate": "浙F05086F", + "number": "92Q" + }, + { + "vin": "LA9GG68L0PBAF4786", + "plate": "浙F08000F", + "number": "93Q" + }, + { + "vin": "LA9GG68L2PBAF4790", + "plate": "浙F09282F", + "number": "94Q" + }, + { + "vin": "LA9GG68L4PBAF4807", + "plate": "浙F03208F", + "number": "95Q" + }, + { + "vin": "LA9GG68L8PBAF4809", + "plate": "浙F01987F", + "number": "96Q" + }, + { + "vin": "LA9GG68L4PBAF4810", + "plate": "浙F31398F", + "number": "97Q" + }, + { + "vin": "LA9GG68LXPBAF4813", + "plate": "浙F02678F", + "number": "98Q" + }, + { + "vin": "LA9GG68L1PBAF4814", + "plate": "浙F05569F", + "number": "99Q" + }, + { + "vin": "LA9GG68L9PBAF4818", + "plate": "浙F03131F", + "number": "100Q" + }, + { + "vin": "LA9GG64L6NBAF4178", + "plate": "浙F08278F", + "number": "备用车101Q" + }, + { + "vin": "LA9GG64L7NBAF4190", + "plate": "浙F03000F", + "number": "备用车102Q" + }, + { + "vin": "LA9GG68L4PBAF4788", + "plate": "浙F07179F", + "number": "备用车103Q" + }, + { + "vin": "LA9GG68L5PBAF4265", + "plate": "浙F07667F", + "number": "备用车104Q" + }, + { + "vin": "LA9GG68L5PBAF4279", + "plate": "浙F07869F", + "number": "备用车105Q" + }, + { + "vin": "LA9GG68L9PBAF4284", + "plate": "浙F01758F", + "number": "备用车106Q" + }, + { + "vin": "LA9GG68L1PBAF4263", + "plate": "浙F02218F", + "number": "备用车107Q" + }, + { + "vin": "LA9GG68L6PBAF4274", + "plate": "浙F02289F", + "number": "备用车108Q" + }, + { + "vin": "LA9GG68L3PBAF4264", + "plate": "浙F05633F", + "number": "备用车109Q" + }, + { + "vin": "LA9GG68L2PBAF4269", + "plate": "浙F08832F", + "number": "备用车110Q" + } + ] +} \ No newline at end of file diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 0e3f5e3b..8300d06c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -27,10 +27,19 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (h *Handler) routes() { + h.mux.HandleFunc("GET /api/v2/operation-compliance", h.handleOperationCompliance) + h.mux.HandleFunc("GET /api/v2/operation-compliance/options", h.handleOperationComplianceOptions) + h.mux.HandleFunc("GET /api/v2/operation-compliance/detail", h.handleOperationComplianceDetail) + h.mux.HandleFunc("GET /api/v2/customer-mileage", h.handleCustomerMileage) + h.mux.HandleFunc("GET /api/v2/customer-mileage/rules", h.handleCustomerMileageRules) + h.mux.HandleFunc("PUT /api/v2/customer-mileage/rules", h.handleCustomerMileageRules) h.mux.HandleFunc("GET /api/dashboard/summary", h.handleDashboardSummary) h.mux.HandleFunc("GET /api/vehicles", h.handleVehicles) h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve) h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage) + h.mux.HandleFunc("GET /api/v2/statistics/vehicle-options", h.handleMileageVehicleOptions) + h.mux.HandleFunc("GET /api/v2/statistics/vehicles", h.handleMileageVehicles) + h.mux.HandleFunc("POST /api/v2/statistics/vehicle-resolve", h.handleMileageVehicleResolve) h.mux.HandleFunc("GET /api/vehicles/coverage/summary", h.handleVehicleCoverageSummary) h.mux.HandleFunc("GET /api/vehicles/business-filters", h.handleVehicleBusinessFilters) h.mux.HandleFunc("GET /api/vehicle-service", h.handleVehicleDetail) @@ -761,7 +770,13 @@ func (h *Handler) handleVehicleDetail(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, http.StatusBadRequest, "VEHICLE_KEY_REQUIRED", "车辆关键词不能为空", "", traceID(r)) return } - data, err := h.service.VehicleDetail(r.Context(), keyword, r.URL.Query().Get("protocol")) + var data VehicleDetail + var err error + if r.URL.Query().Get("includeHistory") == "0" { + data, err = h.service.VehicleLiveDetail(r.Context(), keyword, r.URL.Query().Get("protocol")) + } else { + data, err = h.service.VehicleDetail(r.Context(), keyword, r.URL.Query().Get("protocol")) + } h.write(w, r, data, err) } @@ -955,7 +970,7 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er if clientErr, ok := asClientError(err); ok { status := http.StatusBadRequest switch { - case clientErr.Code == "VEHICLE_PERMISSION_DENIED": + case clientErr.Code == "VEHICLE_PERMISSION_DENIED", clientErr.Code == "MENU_PERMISSION_DENIED", clientErr.Code == "PERMISSION_DENIED": status = http.StatusForbidden case strings.HasPrefix(clientErr.Code, "HISTORY_SCOPE_"), clientErr.Code == "HISTORY_BEFORE_AUTHORIZATION": status = http.StatusForbidden diff --git a/vehicle-data-platform/apps/api/internal/platform/history_location_errors_test.go b/vehicle-data-platform/apps/api/internal/platform/history_location_errors_test.go new file mode 100644 index 00000000..73cfebe9 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/history_location_errors_test.go @@ -0,0 +1,80 @@ +package platform + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestHistoryLocationsPreservesTDengineFailures(t *testing.T) { + for _, test := range []struct { + name string + skipCount bool + message string + }{ + {name: "count connection failure", message: "Fail to get table info, error: Unable to establish connection"}, + {name: "query connection failure", skipCount: true, message: "Fail to get table info, error: Unable to establish connection"}, + {name: "metadata failure", message: "Fail to get table info"}, + } { + t.Run(test.name, func(t *testing.T) { + tdengine, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer tdengine.Close() + query := url.Values{"vin": {"VIN_TEST"}, "protocol": {"GB32960"}} + statement := "SELECT COUNT" + if test.skipCount { + query.Set("skipCount", "1") + statement = `SELECT CAST\(ts` + } + failure := errors.New(test.message) + mock.ExpectQuery(statement).WillReturnError(failure) + store := &ProductionStore{tdengine: tdengine, tdDatabase: "vehicle_test"} + _, err = store.HistoryLocationsFromTDengine(context.Background(), query) + if !errors.Is(err, failure) { + t.Fatalf("database failure must reach the caller instead of becoming an empty track: got %v, want %v", err, failure) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestHistoryLocationsReturnsEmptyPageForMissingSubtable(t *testing.T) { + for _, skipCount := range []bool{false, true} { + name := "count" + if skipCount { + name = "query" + } + t.Run(name, func(t *testing.T) { + tdengine, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer tdengine.Close() + query := url.Values{"vin": {"VIN_WITHOUT_SUBTABLE"}, "protocol": {"GB32960"}, "limit": {"20"}, "offset": {"40"}} + statement := "SELECT COUNT" + if skipCount { + query.Set("skipCount", "1") + statement = `SELECT CAST\(ts` + } + mock.ExpectQuery(statement).WillReturnError(errors.New("[0x2603] Fail to get table info, error: Table does not exist")) + store := &ProductionStore{tdengine: tdengine, tdDatabase: "vehicle_test"} + page, err := store.HistoryLocationsFromTDengine(context.Background(), query) + if err != nil { + t.Fatal(err) + } + if page.Items == nil || len(page.Items) != 0 || page.Total != 0 || page.Limit != 20 || page.Offset != 40 { + t.Fatalf("missing subtable should preserve empty page and pagination: %+v", page) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/mileage_vehicles.go b/vehicle-data-platform/apps/api/internal/platform/mileage_vehicles.go new file mode 100644 index 00000000..01f4271c --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/mileage_vehicles.go @@ -0,0 +1,47 @@ +package platform + +import ( + "encoding/json" + "lingniu/vehicle-data-platform/apps/api/internal/httpx" + "net/http" +) + +// Mileage pickers expose only identifiers and export labels. Their service calls +// retain the caller's existing VIN scope; statistics access grants no telemetry. +type mileageVehicleOption struct { + VIN string `json:"vin"` + Plate string `json:"plate"` + BrandName string `json:"brandName,omitempty"` + ModelName string `json:"modelName,omitempty"` +} + +func (h *Handler) handleMileageVehicleOptions(w http.ResponseWriter, r *http.Request) { + data, err := h.service.VehicleCoverage(r.Context(), r.URL.Query()) + result := Page[mileageVehicleOption]{Items: []mileageVehicleOption{}, Total: data.Total, Limit: data.Limit, Offset: data.Offset} + for _, row := range data.Items { + result.Items = append(result.Items, mileageVehicleOption{VIN: row.VIN, Plate: row.Plate, BrandName: row.BrandName, ModelName: row.ModelName}) + } + h.write(w, r, result, err) +} +func (h *Handler) handleMileageVehicles(w http.ResponseWriter, r *http.Request) { + data, err := h.service.Vehicles(r.Context(), r.URL.Query()) + result := Page[mileageVehicleOption]{Items: []mileageVehicleOption{}, Total: data.Total, Limit: data.Limit, Offset: data.Offset} + for _, row := range data.Items { + result.Items = append(result.Items, mileageVehicleOption{VIN: row.VIN, Plate: row.Plate}) + } + h.write(w, r, result, err) +} +func (h *Handler) handleMileageVehicleResolve(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var query VehicleOverviewBatchQuery + if err := json.NewDecoder(r.Body).Decode(&query); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", err.Error(), traceID(r)) + return + } + data, err := h.service.VehicleServiceOverviews(r.Context(), query) + result := Page[mileageVehicleOption]{Items: []mileageVehicleOption{}, Total: data.Total, Limit: data.Limit, Offset: data.Offset} + for _, row := range data.Items { + result.Items = append(result.Items, mileageVehicleOption{VIN: row.VIN, Plate: row.Plate}) + } + h.write(w, r, result, err) +} diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 25ab54da..bfcfb0c3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -180,6 +180,15 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation defer m.reconciliationMu.RUnlock() items := make([]ReconciliationIssue, 0, len(m.reconciliationIssues)) for _, item := range m.reconciliationIssues { + if query.AllowedVINs != nil { + allowed := false + for _, vin := range query.AllowedVINs { + allowed = allowed || item.VIN == vin + } + if !allowed { + continue + } + } if query.Scope == "archived" && item.ArchivedAt == "" { continue } @@ -1089,13 +1098,13 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V batch := strings.TrimSpace(query.Get("keywords")) != "" if batch { for _, keyword := range keywords { - if strings.EqualFold(vin, keyword) || strings.EqualFold(plate, keyword) { + if strings.EqualFold(vin, keyword) || strings.EqualFold(plate, keyword) || strings.EqualFold(vehicleNumber(vin), keyword) { return true } } return false } - haystack := strings.ToLower(vin + plate + phone + oem) + haystack := strings.ToLower(vin + " " + plate + " " + phone + " " + oem + " " + vehicleNumber(vin)) for _, keyword := range keywords { if strings.Contains(haystack, strings.ToLower(keyword)) { return true @@ -1187,6 +1196,7 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V } items := make([]VehicleRealtimeRow, 0, len(byVIN)) for _, row := range byVIN { + row.VehicleNumber = vehicleNumber(row.VIN) sort.Strings(row.Protocols) onlineProtocols := make([]string, 0, row.OnlineSourceCount) for _, location := range rows { @@ -1216,6 +1226,9 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V } items = append(items, *row) } + if sortMonitorEnergyRows(items, query) { + return page(items, query), nil + } if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") { sort.Slice(items, func(i, j int) bool { left := strings.ToUpper(firstNonEmpty(items[i].Plate, items[i].VIN)) diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index bd7e2e79..3ef001b5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -13,20 +13,21 @@ type Page[T any] struct { } type MonitorSummary struct { - TotalVehicles int `json:"totalVehicles"` - LocationVehicles int `json:"locationVehicles"` - NoLocationVehicles int `json:"noLocationVehicles"` - OnlineVehicles int `json:"onlineVehicles"` - OfflineVehicles int `json:"offlineVehicles"` - DrivingVehicles int `json:"drivingVehicles"` - IdleVehicles int `json:"idleVehicles"` - AlertVehicles int `json:"alertVehicles"` - UnknownVehicles int `json:"unknownVehicles"` - ActiveToday int `json:"activeToday"` - FrameToday int `json:"frameToday"` - AlertDataAvailable bool `json:"alertDataAvailable"` - Truncated bool `json:"truncated"` - AsOf string `json:"asOf"` + TotalVehicles int `json:"totalVehicles"` + LocationVehicles int `json:"locationVehicles"` + NoLocationVehicles int `json:"noLocationVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + OfflineVehicles int `json:"offlineVehicles"` + DrivingVehicles int `json:"drivingVehicles"` + IdleVehicles int `json:"idleVehicles"` + AlertVehicles int `json:"alertVehicles"` + UnknownVehicles int `json:"unknownVehicles"` + ActiveToday int `json:"activeToday,omitempty"` + FrameToday int `json:"frameToday,omitempty"` + ActivityDataAvailable bool `json:"activityDataAvailable"` + AlertDataAvailable bool `json:"alertDataAvailable"` + Truncated bool `json:"truncated"` + AsOf string `json:"asOf"` } type MonitorMapResponse struct { @@ -834,15 +835,16 @@ type AccessThresholdUpdate struct { } type AlertQuery struct { - Keyword string `json:"keyword"` - Severity string `json:"severity"` - Status string `json:"status"` - RuleID string `json:"ruleId"` - Protocol string `json:"protocol"` - DateFrom string `json:"dateFrom"` - DateTo string `json:"dateTo"` - Limit int `json:"limit"` - Offset int `json:"offset"` + AllowedVINs []string `json:"-"` + Keyword string `json:"keyword"` + Severity string `json:"severity"` + Status string `json:"status"` + RuleID string `json:"ruleId"` + Protocol string `json:"protocol"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + Limit int `json:"limit"` + Offset int `json:"offset"` } type AlertSummary struct { @@ -1145,16 +1147,17 @@ type AlertNotificationRetryResult struct { } type ReconciliationQuery struct { - Keyword string `json:"keyword"` - Scope string `json:"scope"` - RuleCode string `json:"ruleCode"` - Category string `json:"category"` - Severity string `json:"severity"` - Status string `json:"status"` - Owner string `json:"owner"` - SLA string `json:"sla"` - Limit int `json:"limit"` - Offset int `json:"offset"` + AllowedVINs []string `json:"-"` + Keyword string `json:"keyword"` + Scope string `json:"scope"` + RuleCode string `json:"ruleCode"` + Category string `json:"category"` + Severity string `json:"severity"` + Status string `json:"status"` + Owner string `json:"owner"` + SLA string `json:"sla"` + Limit int `json:"limit"` + Offset int `json:"offset"` } type ReconciliationAssignee struct { @@ -1410,6 +1413,7 @@ type VehicleIdentityResolution struct { } type VehicleDetail struct { + HistoryIncluded bool `json:"historyIncluded"` VIN string `json:"vin"` LookupKey string `json:"lookupKey"` LookupResolved bool `json:"lookupResolved"` @@ -1780,34 +1784,39 @@ type RealtimeLocationRow struct { } type VehicleRealtimeRow struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - Phone string `json:"phone"` - OEM string `json:"oem"` - Protocols []string `json:"protocols"` - SourceStatus []VehicleSourceStatus `json:"sourceStatus"` - SourceCount int `json:"sourceCount"` - OnlineSourceCount int `json:"onlineSourceCount"` - Online bool `json:"online"` - BindingStatus string `json:"bindingStatus"` - ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` - PrimaryProtocol string `json:"primaryProtocol"` - LocationSource string `json:"locationSource"` - LocationConflict bool `json:"locationConflict"` - ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"` - LocationAvailable bool `json:"locationAvailable"` - Longitude float64 `json:"longitude"` - Latitude float64 `json:"latitude"` - SpeedAvailable bool `json:"speedAvailable"` - SpeedKmh float64 `json:"speedKmh"` - SOCAvailable bool `json:"socAvailable"` - SOCPercent float64 `json:"socPercent"` - MileageAvailable bool `json:"mileageAvailable"` - TotalMileageKm float64 `json:"totalMileageKm"` - TodayMileageAvailable bool `json:"todayMileageAvailable"` - TodayMileageKm float64 `json:"todayMileageKm"` - LastSeen string `json:"lastSeen"` - ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"` + VehicleNumber string `json:"vehicleNumber,omitempty"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocols []string `json:"protocols"` + SourceStatus []VehicleSourceStatus `json:"sourceStatus"` + SourceCount int `json:"sourceCount"` + OnlineSourceCount int `json:"onlineSourceCount"` + Online bool `json:"online"` + BindingStatus string `json:"bindingStatus"` + ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` + PrimaryProtocol string `json:"primaryProtocol"` + LocationSource string `json:"locationSource"` + LocationConflict bool `json:"locationConflict"` + ConflictDistanceM *float64 `json:"conflictDistanceM,omitempty"` + LocationAvailable bool `json:"locationAvailable"` + Longitude float64 `json:"longitude"` + Latitude float64 `json:"latitude"` + SpeedAvailable bool `json:"speedAvailable"` + SpeedKmh float64 `json:"speedKmh"` + SOCAvailable bool `json:"socAvailable"` + SOCPercent float64 `json:"socPercent"` + HydrogenPressureMPa *float64 `json:"hydrogenPressureMpa,omitempty"` + HydrogenMassSource string `json:"hydrogenMassSource,omitempty"` + HydrogenMassKg *float64 `json:"hydrogenMassKg,omitempty"` + HydrogenConsumptionKgPer100Km *float64 `json:"hydrogenConsumptionKgPer100Km,omitempty"` + MileageAvailable bool `json:"mileageAvailable"` + TotalMileageKm float64 `json:"totalMileageKm"` + TodayMileageAvailable bool `json:"todayMileageAvailable"` + TodayMileageKm float64 `json:"todayMileageKm"` + LastSeen string `json:"lastSeen"` + ReportIntervalMs *int64 `json:"reportIntervalMs,omitempty"` } type HistoryLocationRow struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/monitor_activity_test.go b/vehicle-data-platform/apps/api/internal/platform/monitor_activity_test.go index 41c11570..0ca607b6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/monitor_activity_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/monitor_activity_test.go @@ -2,8 +2,11 @@ package platform import ( "context" + "encoding/json" "net/url" + "strings" "testing" + "time" ) type monitorActivityTestStore struct { @@ -31,3 +34,40 @@ func TestMonitorWorkspaceUsesOnlyRequiredActivityCounters(t *testing.T) { t.Fatal("lost fleet data") } } + +func TestScopedMonitorDoesNotReadOrExposeGlobalActivity(t *testing.T) { + store := &monitorActivityTestStore{MockStore: NewMockStore()} + service := NewService(store) + for _, vin := range []string{"LB9A32A24R0LS1426", "LB9A32A24R0LS1427"} { + ctx := WithPrincipal(context.Background(), Principal{UserType: "customer", AuthProvider: "oneos", VehicleVINs: []string{vin}}) + summary, err := service.buildMonitorSummary(ctx, url.Values{}, Page[VehicleRealtimeRow]{Total: 1, Items: []VehicleRealtimeRow{{VIN: vin, Online: true, LastSeen: "2026-09-23T00:00:00Z"}}}) + if err != nil { + t.Fatal(err) + } + if summary.TotalVehicles != 1 || summary.ActivityDataAvailable || summary.ActiveToday != 0 || summary.FrameToday != 0 { + t.Fatalf("scope leaked global metrics: %+v", summary) + } + raw, _ := json.Marshal(summary) + if strings.Contains(string(raw), "frameToday") || strings.Contains(string(raw), "activeToday") { + t.Fatal("unavailable metrics serialized as values", string(raw)) + } + } + if store.activityCalls != 0 { + t.Fatal("read fleet-wide counters for scoped users") + } +} +func TestOneOSGrantExpirationRecheckedBeforeHistoryAndVehicleQuery(t *testing.T) { + expired := time.Now().Add(-time.Second) + p := Principal{UserType: "customer", AuthProvider: "oneos", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: expired.Add(-time.Hour), ValidTo: &expired}}} + ctx := WithPrincipal(context.Background(), p) + if _, err := applyPrincipalHistoryTimeScope(ctx, "LB9A32A24R0LS1426", url.Values{}); err == nil { + t.Fatal("expired grant can read history") + } + scoped, err := applyPrincipalVehicleScope(ctx, url.Values{}) + if err != nil { + t.Fatal(err) + } + if scoped.Get("scopeVins") != "__NO_VEHICLE_SCOPE__" { + t.Fatal("expired VIN remained in query") + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/monitor_energy.go b/vehicle-data-platform/apps/api/internal/platform/monitor_energy.go new file mode 100644 index 00000000..abf916d0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/monitor_energy.go @@ -0,0 +1,125 @@ +package platform + +import ( + "context" + "math" + "regexp" + "strconv" + "strings" + + "lingniu/vehicle-data-platform/apps/api/internal/hydrogen" +) + +const monitorEnergyNumberPattern = `^[+-]?([0-9]+([.][0-9]*)?|[.][0-9]+)([eE][+-]?[0-9]+)?$` + +var monitorEnergyNumericString = regexp.MustCompile(monitorEnergyNumberPattern) + +// Read only the current authorized page, in bounded batches, without querying +// historical storage or issuing a telemetry request for each vehicle. +func (s *ProductionStore) populateMonitorEnergy(ctx context.Context, items []VehicleRealtimeRow) error { + byVIN := make(map[string]*VehicleRealtimeRow, len(items)) + vins := make([]string, 0, len(items)) + for i := range items { + if !containsString(items[i].Protocols, "GB32960") { + continue + } + byVIN[items[i].VIN] = &items[i] + vins = append(vins, items[i].VIN) + } + for start := 0; start < len(vins); start += 200 { + batch := vins[start:min(start+200, len(vins))] + args := make([]any, len(batch)) + for i, vin := range batch { + args[i] = vin + } + rows, err := s.db.QueryContext(ctx, `SELECT s.vin, COALESCE(s.parsed_json, ''), COALESCE(c.tank_capacity_l, 0) FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_hydrogen_tank_capacity c ON BINARY c.vin = BINARY s.vin AND c.active = 1 WHERE s.protocol = 'GB32960' AND s.vin IN (`+strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")+`)`, args...) + if err != nil { + return err + } + for rows.Next() { + var vin, payload string + var capacity float64 + if err := rows.Scan(&vin, &payload, &capacity); err != nil { + rows.Close() + return err + } + if row := byVIN[vin]; row != nil { + applyMonitorEnergyWithCapacity(row, parsedFieldsFromString(payload), capacity) + } + } + err = rows.Err() + closeErr := rows.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + } + return nil +} + +func applyMonitorEnergy(row *VehicleRealtimeRow, fields map[string]any) { + // The pressure is the reported maximum hydrogen pressure probe reading. + // Mass always comes from pressure, temperature and configured volume, never reported kg. + row.HydrogenPressureMPa = monitorEnergyNumber(fields, "gb32960.fuel_cell.max_hydrogen_pressure_mpa", "fuel_cell_max_hydrogen_pressure_mpa") + row.HydrogenMassKg = nil + row.HydrogenMassSource = "" + row.HydrogenConsumptionKgPer100Km = monitorEnergyNumber(fields, "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km", "fuel_cell_hydrogen_consumption_kg_per_100km") +} + +func monitorEnergyNumber(fields map[string]any, keys ...string) *float64 { + for _, key := range keys { + var value float64 + switch raw := fields[key].(type) { + case float64: + value = raw + case string: + normalized := strings.TrimSpace(raw) + if !monitorEnergyNumericString.MatchString(normalized) { + continue + } + parsed, err := strconv.ParseFloat(normalized, 64) + if err != nil { + continue + } + value = parsed + default: + continue + } + if !math.IsNaN(value) && !math.IsInf(value, 0) && value >= 0 { + return &value + } + } + return nil +} + +func applyMonitorEnergyWithCapacity(row *VehicleRealtimeRow, fields map[string]any, capacity float64) { + applyMonitorEnergy(row, fields) + var temperature *float64 + for _, key := range []string{"gb32960.fuel_cell.max_hydrogen_temperature_c", "fuel_cell_max_hydrogen_temperature_c"} { + // Temperature can legitimately be below zero. + raw, exists := fields[key] + if !exists { + continue + } + switch v := raw.(type) { + case float64: + temperature = &v + case string: + if monitorEnergyNumericString.MatchString(strings.TrimSpace(v)) { + if n, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { + temperature = &n + } + } + } + break + } + if row.HydrogenPressureMPa == nil || temperature == nil || *temperature < -40 { + return + } + if mass, ok := hydrogen.PressureMassKg(*row.HydrogenPressureMPa, *temperature, capacity); ok { + row.HydrogenMassKg = &mass + row.HydrogenMassSource = "ESTIMATED" + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/monitor_energy_test.go b/vehicle-data-platform/apps/api/internal/platform/monitor_energy_test.go new file mode 100644 index 00000000..8ea61ce7 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/monitor_energy_test.go @@ -0,0 +1,191 @@ +package platform + +import ( + "context" + "database/sql/driver" + "errors" + "math" + "net/url" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "lingniu/vehicle-data-platform/apps/api/internal/hydrogen" +) + +func TestVehicleRealtimeLoadsEnergyOnlyWhenRequested(t *testing.T) { + for _, mode := range []string{"map", "list", "pressure"} { + includeEnergy := mode != "map" + t.Run(mode, func(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + query := url.Values{"limit": {"20"}} + if includeEnergy { + query.Set("includeEnergy", "1") + } + mock.ExpectQuery(`SELECT COUNT\(\*\)`).WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) + if mode == "pressure" { + query.Set("sort", "hydrogenPressureMpa") + } + columns := []string{ + "vin", "plate", "phone", "oem", "protocols", "online_protocols", "source_count", "online_source_count", "online", "binding_status", + "primary_protocol", "location_source", "location_conflict", "conflict_distance_m", "location_available", "longitude", "latitude", "speed_available", "speed_kmh", + "soc_available", "soc_percent", "mileage_available", "total_mileage_km", "today_mileage_available", "today_mileage_km", "last_seen", "report_interval_ms", + } + values := []driver.Value{"VIN-1", "测试车", "", "", "GB32960", "GB32960", 1, 1, 1, "bound", "GB32960", "GB32960:canonical", 0, nil, 1, "113", "23", 1, "42", 1, "65.4", 1, "12345", 1, "128.6", "2026-10-26 15:00:00", 10000} + selectPattern := `SELECT v.vin,` + if mode == "pressure" { + columns = append(columns, "parsed_json", "tank_capacity_l") + values = append(values, `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99,"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15}`, 520) + selectPattern = `SELECT r.\*,` + } + mock.ExpectQuery(selectPattern).WillReturnRows(sqlmock.NewRows(columns).AddRow(values...)) + if includeEnergy && mode != "pressure" { + mock.ExpectQuery(`SELECT s.vin,`).WithArgs("VIN-1").WillReturnRows(sqlmock.NewRows([]string{"vin", "parsed_json", "tank_capacity_l"}).AddRow("VIN-1", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99,"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15}`, 520)) + } + result, err := (&ProductionStore{db: db}).VehicleRealtime(context.Background(), query) + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 1 || result.Items[0].SOCPercent != 65.4 || (result.Items[0].HydrogenMassKg != nil) != includeEnergy { + t.Fatalf("unexpected realtime energy result: %+v", result) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestMonitorEnergyReadsOnlyGBVehiclesOnCurrentPage(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + items := []VehicleRealtimeRow{ + {VIN: "VIN-1", Protocols: []string{"GB32960", "JT808"}}, + {VIN: "VIN-2", Protocols: []string{"JT808"}}, + {VIN: "VIN-3", Protocols: []string{"GB32960"}}, + } + mock.ExpectQuery(`SELECT s.vin,.*WHERE s.protocol = 'GB32960' AND s.vin IN \(\?,\?\)`). + WithArgs("VIN-1", "VIN-3"). + WillReturnRows(sqlmock.NewRows([]string{"vin", "parsed_json", "tank_capacity_l"}). + AddRow("VIN-1", `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":32.5,"gb32960.fuel_cell.max_hydrogen_temperature_c":15,"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99,"gb32960.fuel_cell.hydrogen_consumption_kg_per_100km":8.76}`, 520). + AddRow("VIN-3", `{"fuel_cell_max_hydrogen_pressure_mpa":0,"fuel_cell_max_hydrogen_temperature_c":15,"gd_fc_vehicle_hydrogen_mass_kg":99,"fuel_cell_hydrogen_consumption_kg_per_100km":0}`, 520)) + if err := (&ProductionStore{db: db}).populateMonitorEnergy(context.Background(), items); err != nil { + t.Fatal(err) + } + if items[0].HydrogenPressureMPa == nil || *items[0].HydrogenPressureMPa != 32.5 || items[0].HydrogenMassKg == nil || *items[0].HydrogenMassKg <= 0 || *items[0].HydrogenMassKg >= 99 || items[0].HydrogenMassSource != "ESTIMATED" || items[0].HydrogenConsumptionKgPer100Km == nil || *items[0].HydrogenConsumptionKgPer100Km != 8.76 { + t.Fatalf("missing reported energy values: %+v", items[0]) + } + if items[1].HydrogenMassKg != nil || items[2].HydrogenMassKg == nil || *items[2].HydrogenMassKg != 0 || items[2].HydrogenPressureMPa == nil || *items[2].HydrogenPressureMPa != 0 || items[2].HydrogenConsumptionKgPer100Km == nil || *items[2].HydrogenConsumptionKgPer100Km != 0 { + t.Fatal("missing data and reported zero must remain distinct") + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestMonitorEnergyDoesNotInventMissingMassOrConsumption(t *testing.T) { + for _, payload := range []string{ + `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":32.5}`, + `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gb32960.fuel_cell.hydrogen_consumption_kg_per_100km":-1}`, + `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"invalid"}`, + `invalid json`, + } { + row := VehicleRealtimeRow{} + applyMonitorEnergy(&row, parsedFieldsFromString(payload)) + if row.HydrogenMassKg != nil || row.HydrogenConsumptionKgPer100Km != nil { + t.Fatalf("invalid/missing values became usable energy data: %+v", row) + } + } +} + +func TestMonitorEnergyAcceptsNumericStringsFromDurableSnapshots(t *testing.T) { + row := VehicleRealtimeRow{} + applyMonitorEnergy(&row, parsedFieldsFromString(`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":" 13.6 ","gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"9.2","gb32960.fuel_cell.hydrogen_consumption_kg_per_100km":"1.66"}`)) + if row.HydrogenPressureMPa == nil || *row.HydrogenPressureMPa != 13.6 || row.HydrogenMassKg != nil || row.HydrogenConsumptionKgPer100Km == nil || *row.HydrogenConsumptionKgPer100Km != 1.66 { + t.Fatalf("numeric string telemetry was lost: %+v", row) + } + for _, invalid := range []any{"", "invalid", "NaN", "Infinity", "0x1p2", "12 MPa", "-1", true, nil} { + if got := monitorEnergyNumber(map[string]any{"pressure": invalid}, "pressure"); got != nil { + t.Fatalf("invalid value %v was accepted: %v", invalid, *got) + } + } +} + +func TestMonitorEnergySurfacesStorageFailure(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + want := errors.New("snapshot unavailable") + mock.ExpectQuery(`SELECT s.vin,`).WithArgs("VIN-1").WillReturnError(want) + if err := (&ProductionStore{db: db}).populateMonitorEnergy(context.Background(), []VehicleRealtimeRow{{VIN: "VIN-1", Protocols: []string{"GB32960"}}}); !errors.Is(err, want) { + t.Fatalf("storage error = %v, want %v", err, want) + } +} + +func TestMonitorHydrogenMassEstimateUsesConfiguredVolumeAndTemperature(t *testing.T) { + for _, tc := range []struct { + name, payload string + capacity float64 + estimated bool + }{ + {"feichi numeric strings", `{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"35","gb32960.fuel_cell.max_hydrogen_temperature_c":"15"}`, 520, true}, + {"cold tank", `{"fuel_cell_max_hydrogen_pressure_mpa":"20","fuel_cell_max_hydrogen_temperature_c":"-20"}`, 520, true}, + {"empty tank", `{"fuel_cell_max_hydrogen_pressure_mpa":0,"fuel_cell_max_hydrogen_temperature_c":15}`, 520, true}, + {"missing capacity", `{"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15}`, 0, false}, + {"missing temperature", `{"fuel_cell_max_hydrogen_pressure_mpa":35}`, 520, false}, + {"bad pressure", `{"fuel_cell_max_hydrogen_pressure_mpa":80,"fuel_cell_max_hydrogen_temperature_c":15}`, 520, false}, + {"bad temperature", `{"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":"NaN"}`, 520, false}, + {"reported invalid mass does not affect calculation", `{"fuel_cell_max_hydrogen_pressure_mpa":35,"fuel_cell_max_hydrogen_temperature_c":15,"gd_fc_vehicle_hydrogen_mass_kg":-1}`, 520, true}, + } { + t.Run(tc.name, func(t *testing.T) { + row := VehicleRealtimeRow{} + fields := parsedFieldsFromString(tc.payload) + applyMonitorEnergyWithCapacity(&row, fields, tc.capacity) + if (row.HydrogenMassKg != nil) != tc.estimated { + t.Fatalf("unexpected mass: %+v", row) + } + if tc.estimated { + if row.HydrogenMassSource != "ESTIMATED" { + t.Fatal("estimate must be identified") + } + twice := VehicleRealtimeRow{} + applyMonitorEnergyWithCapacity(&twice, fields, tc.capacity*2) + if twice.HydrogenMassKg == nil || math.Abs(*twice.HydrogenMassKg-2**row.HydrogenMassKg) > 1e-8 { + t.Fatal("mass must scale with configured volume") + } + } + }) + } +} + +func TestMonitorMassAlwaysCalculatedRegardlessOfReportedMass(t *testing.T) { + for _, key := range []string{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg", "gb32960.gd_fc_vehicle.hydrogen_mass_kg", "gb32960.gd_fc_vehicle_info.gd_fc_vehicle_hydrogen_mass_kg", "gd_fc_vehicle_hydrogen_mass_kg"} { + for _, reported := range []any{0.0, 99.0, -1.0, "invalid", "12.3", nil} { + fields := map[string]any{"fuel_cell_max_hydrogen_pressure_mpa": 35.0, "fuel_cell_max_hydrogen_temperature_c": 15.0, key: reported} + row := VehicleRealtimeRow{} + applyMonitorEnergyWithCapacity(&row, fields, 520) + want, _ := hydrogen.PressureMassKg(35, 15, 520) + if row.HydrogenMassKg == nil || math.Abs(*row.HydrogenMassKg-want) > 1e-10 || row.HydrogenMassSource != "ESTIMATED" { + t.Fatalf("reported value %v affected calculation: %+v", reported, row) + } + // A subsequent incomplete snapshot must clear any previous or reported kg. + applyMonitorEnergyWithCapacity(&row, fields, 0) + if row.HydrogenMassKg != nil || row.HydrogenMassSource != "" { + t.Fatal("missing capacity must not fall back to reported or previous mass") + } + delete(fields, "fuel_cell_max_hydrogen_temperature_c") + applyMonitorEnergyWithCapacity(&row, fields, 520) + if row.HydrogenMassKg != nil { + t.Fatal("missing temperature must not fall back to reported mass") + } + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/monitor_sort.go b/vehicle-data-platform/apps/api/internal/platform/monitor_sort.go new file mode 100644 index 00000000..ef83425f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/monitor_sort.go @@ -0,0 +1,118 @@ +package platform + +import ( + "math" + "net/url" + "sort" + "strings" + "time" +) + +// Sort the complete authorized and filtered result before applying pagination. +// The outer projection keeps the existing realtime response columns unchanged. +func monitorEnergySortSQL(selectSQL string, query url.Values) (string, bool) { + join, value := "", "" + switch query.Get("sort") { + case "socPercent": + soc := `CAST(NULLIF(r.soc_percent, '') AS DECIMAL(18,6))` + value = `CASE WHEN r.soc_available = 1 AND ` + soc + ` BETWEEN 0 AND 100 THEN ` + soc + ` END` + case "lastSeen": + // last_seen is projected as fixed-width YYYY-MM-DD HH:mm:ss in one timezone. + // Its lexical order is chronological; avoid MySQL's CAST(NULLIF(...)) datetime coercion. + value = `NULLIF(r.last_seen, '')` + case "speedKmh", "todayMileageKm", "totalMileageKm": + column, available := "today_mileage_km", "today_mileage_available" + if query.Get("sort") == "speedKmh" { + column, available = "speed_kmh", "speed_available" + } else if query.Get("sort") == "totalMileageKm" { + column, available = "total_mileage_km", "mileage_available" + } + number := `CAST(NULLIF(r.` + column + `, '') AS DECIMAL(18,6))` + value = `CASE WHEN r.` + available + ` = 1 AND ` + number + ` >= 0 THEN ` + number + ` END` + case "hydrogenPressureMpa": + join = ` LEFT JOIN vehicle_realtime_snapshot energy_s ON energy_s.vin = r.vin AND energy_s.protocol = 'GB32960' AND FIND_IN_SET('GB32960', r.protocols) > 0` + value = monitorPressureSortSQL() + default: + return "", false + } + projection := `r.*` + if query.Get("sort") == "hydrogenPressureMpa" && query.Get("includeEnergy") == "1" { + // Return the exact snapshot used by ORDER BY, avoiding a second read after live updates. + projection += `, COALESCE(energy_s.parsed_json, ''), COALESCE(energy_c.tank_capacity_l, 0)` + join += ` LEFT JOIN vehicle_hydrogen_tank_capacity energy_c ON BINARY energy_c.vin = BINARY r.vin AND energy_c.active = 1` + } + direction := "ASC" + if strings.EqualFold(query.Get("sortOrder"), "desc") { + direction = "DESC" + } + return `SELECT ` + projection + ` FROM (` + selectSQL + `) r` + join + ` ORDER BY (` + value + `) IS NULL ASC, ` + value + ` ` + direction + `, r.vin ASC LIMIT ? OFFSET ?`, true +} + +func monitorPressureSortSQL() string { + // Match applyMonitorEnergy: only numeric, nonnegative reported pressure is + // usable; a missing/invalid canonical field falls back to the legacy field. + document := `CASE WHEN JSON_VALID(energy_s.parsed_json) THEN energy_s.parsed_json ELSE '{}' END` + validNumber := func(key string) string { + field := `JSON_EXTRACT(` + document + `, '$."` + key + `"')` + number := `CAST(JSON_UNQUOTE(` + field + `) AS DECIMAL(18,6))` + numeric := `(JSON_TYPE(` + field + `) IN ('INTEGER', 'DOUBLE') OR (JSON_TYPE(` + field + `) = 'STRING' AND TRIM(JSON_UNQUOTE(` + field + `)) REGEXP '` + monitorEnergyNumberPattern + `'))` + return `CASE WHEN ` + numeric + ` AND ` + number + ` >= 0 THEN ` + number + ` END` + } + return `COALESCE(` + validNumber("gb32960.fuel_cell.max_hydrogen_pressure_mpa") + `, ` + validNumber("fuel_cell_max_hydrogen_pressure_mpa") + `)` +} + +func sortMonitorEnergyRows(items []VehicleRealtimeRow, query url.Values) bool { + key := query.Get("sort") + if key != "speedKmh" && key != "lastSeen" && key != "socPercent" && key != "hydrogenPressureMpa" && key != "todayMileageKm" && key != "totalMileageKm" { + return false + } + value := func(row VehicleRealtimeRow) *float64 { + var result *float64 + if key == "lastSeen" { + timestamp, err := time.Parse(time.RFC3339, normalizeTDengineTime(row.LastSeen)) + if err != nil { + return nil + } + seconds := float64(timestamp.UnixNano()) / 1e9 + return &seconds + } else if key == "speedKmh" { + if row.SpeedAvailable { + result = &row.SpeedKmh + } + } else if key == "socPercent" { + if row.SOCAvailable && row.SOCPercent <= 100 { + result = &row.SOCPercent + } + } else if key == "todayMileageKm" { + if row.TodayMileageAvailable { + result = &row.TodayMileageKm + } + } else if key == "totalMileageKm" { + if row.MileageAvailable { + result = &row.TotalMileageKm + } + } else { + result = row.HydrogenPressureMPa + } + if result == nil || math.IsNaN(*result) || math.IsInf(*result, 0) || *result < 0 { + return nil + } + return result + } + descending := strings.EqualFold(query.Get("sortOrder"), "desc") + sort.Slice(items, func(i, j int) bool { + left, right := value(items[i]), value(items[j]) + if left == nil || right == nil { + if (left == nil) != (right == nil) { + return left != nil + } + } else if *left != *right { + if descending { + return *left > *right + } + return *left < *right + } + return items[i].VIN < items[j].VIN + }) + return true +} diff --git a/vehicle-data-platform/apps/api/internal/platform/monitor_sort_test.go b/vehicle-data-platform/apps/api/internal/platform/monitor_sort_test.go new file mode 100644 index 00000000..6eed3f4e --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/monitor_sort_test.go @@ -0,0 +1,121 @@ +package platform + +import ( + "net/url" + "reflect" + "strings" + "testing" +) + +func TestMonitorEnergySortSQLPreservesScopeAndPaginatesAfterSorting(t *testing.T) { + for _, key := range []string{"speedKmh", "lastSeen", "socPercent", "hydrogenPressureMpa", "todayMileageKm", "totalMileageKm"} { + for _, order := range []string{"asc", "desc"} { + query := url.Values{"sort": {key}, "sortOrder": {order}, "scopeVins": {"VIN-1,VIN-2"}, "protocol": {"GB32960"}, "limit": {"1"}, "offset": {"1"}} + built := buildVehicleRealtimeSQL(query) + if !strings.HasPrefix(built.Text, "SELECT r.* FROM (SELECT v.vin,") || strings.Count(built.Text, "LIMIT ? OFFSET ?") != 1 || !strings.HasSuffix(built.Text, strings.ToUpper(order)+", r.vin ASC LIMIT ? OFFSET ?") || !strings.Contains(built.Text, ") IS NULL ASC,") { + t.Fatalf("sort is not stable, null-last, and before pagination: %s", built.Text) + } + if !strings.Contains(built.Text, "v.vin IN (?,?)") || !strings.Contains(built.CountText, "v.vin IN (?,?)") || !strings.Contains(built.Text, "l.protocol = ?") || !strings.Contains(built.CountText, "l.protocol = ?") { + t.Fatal("sorting changed the authorized filter scope") + } + if !reflect.DeepEqual(built.Args, []any{"VIN-1", "VIN-2", "GB32960", 1, 1}) || !reflect.DeepEqual(built.CountArgs, []any{"VIN-1", "VIN-2", "GB32960"}) { + t.Fatalf("sort changed filter/pagination arguments: %#v / %#v", built.Args, built.CountArgs) + } + if key == "hydrogenPressureMpa" { + for _, required := range []string{"JSON_VALID", "JSON_TYPE", "'INTEGER', 'DOUBLE'", "gb32960.fuel_cell.max_hydrogen_pressure_mpa", "fuel_cell_max_hydrogen_pressure_mpa", "FIND_IN_SET('GB32960', r.protocols)"} { + if !strings.Contains(built.Text, required) { + t.Fatalf("pressure sort missing %q", required) + } + } + if strings.Contains(built.Text, "hydrogen_mass_kg") || strings.Contains(built.CountText, "JSON_EXTRACT") { + t.Fatal("pressure ordering must not use kg or change the count query") + } + } + } + } +} + +func TestMonitorEnergySortRejectsUnlistedOrderExpressions(t *testing.T) { + query := url.Values{"sort": {"socPercent"}, "sortOrder": {"DESC; DROP TABLE vehicle_identity_binding"}} + built := buildVehicleRealtimeSQL(query) + if strings.Contains(built.Text, "DROP") || !strings.HasSuffix(built.Text, "ASC, r.vin ASC LIMIT ? OFFSET ?") { + t.Fatal("untrusted sort direction reached SQL") + } + query.Set("sort", "hydrogen_mass_kg DESC") + if strings.HasPrefix(buildVehicleRealtimeSQL(query).Text, "SELECT r.*") { + t.Fatal("unlisted sort field was accepted") + } +} + +func TestMonitorEnergySortOrdersNumericValuesWithNullsLast(t *testing.T) { + for _, key := range []string{"speedKmh", "socPercent", "hydrogenPressureMpa", "todayMileageKm", "totalMileageKm"} { + for _, order := range []string{"asc", "desc"} { + items := []VehicleRealtimeRow{ + {VIN: "missing"}, + {VIN: "ten-b", SpeedAvailable: true, SpeedKmh: 10, MileageAvailable: true, TotalMileageKm: 10, TodayMileageAvailable: true, TodayMileageKm: 10, SOCAvailable: true, SOCPercent: 10, HydrogenPressureMPa: energySortTestPointer(10), HydrogenMassKg: energySortTestPointer(1)}, + {VIN: "two", SpeedAvailable: true, SpeedKmh: 2, MileageAvailable: true, TotalMileageKm: 2, TodayMileageAvailable: true, TodayMileageKm: 2, SOCAvailable: true, SOCPercent: 2, HydrogenPressureMPa: energySortTestPointer(2), HydrogenMassKg: energySortTestPointer(100)}, + {VIN: "zero", SpeedAvailable: true, SpeedKmh: 0, MileageAvailable: true, TotalMileageKm: 0, TodayMileageAvailable: true, TodayMileageKm: 0, SOCAvailable: true, SOCPercent: 0, HydrogenPressureMPa: energySortTestPointer(0)}, + {VIN: "ten-a", SpeedAvailable: true, SpeedKmh: 10, MileageAvailable: true, TotalMileageKm: 10, TodayMileageAvailable: true, TodayMileageKm: 10, SOCAvailable: true, SOCPercent: 10, HydrogenPressureMPa: energySortTestPointer(10)}, + } + query := url.Values{"sort": {key}, "sortOrder": {order}, "limit": {"2"}, "offset": {"1"}} + if !sortMonitorEnergyRows(items, query) { + t.Fatal("energy sort not accepted") + } + got := make([]string, len(items)) + for i := range items { + got[i] = items[i].VIN + } + want := []string{"zero", "two", "ten-a", "ten-b", "missing"} + wantPage := []string{"two", "ten-a"} + if order == "desc" { + want = []string{"ten-a", "ten-b", "two", "zero", "missing"} + wantPage = []string{"ten-b", "two"} + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("%s %s: got %v want %v", key, order, got, want) + } + result := page(items, query) + if result.Items[0].VIN != wantPage[0] || result.Items[1].VIN != wantPage[1] { + t.Fatal("sorting must happen before slicing a page") + } + } + } +} + +func energySortTestPointer(value float64) *float64 { return &value } + +func TestMonitorPressureSortReturnsSameSnapshotItOrders(t *testing.T) { + q := buildVehicleRealtimeSQL(url.Values{"sort": {"hydrogenPressureMpa"}, "includeEnergy": {"1"}}) + if !strings.HasPrefix(q.Text, "SELECT r.*, COALESCE(energy_s.parsed_json, ''), COALESCE(energy_c.tank_capacity_l, 0) FROM") { + t.Fatal("pressure sorting and displayed snapshot must be atomic") + } +} + +func TestMonitorReportTimeSortChronologicalWithMissingLast(t *testing.T) { + for _, order := range []string{"asc", "desc"} { + items := []VehicleRealtimeRow{ + {VIN: "missing"}, + {VIN: "invalid", LastSeen: "bad-date"}, + {VIN: "new-b", LastSeen: "2026-10-26 00:00:00"}, + {VIN: "old", LastSeen: "2026-09-30 23:59:59"}, + {VIN: "new-a", LastSeen: "2026-10-25T16:00:00Z"}, + } + query := url.Values{"sort": {"lastSeen"}, "sortOrder": {order}, "limit": {"2"}, "offset": {"1"}} + if !sortMonitorEnergyRows(items, query) { + t.Fatal("report time sort rejected") + } + want := []string{"old", "new-a", "new-b", "invalid", "missing"} + if order == "desc" { + want = []string{"new-a", "new-b", "old", "invalid", "missing"} + } + for i, row := range items { + if row.VIN != want[i] { + t.Fatalf("%s: got %v want %v", order, items, want) + } + } + result := page(items, query) + if result.Items[0].VIN != want[1] || result.Items[1].VIN != want[2] { + t.Fatal("pagination preceded sorting") + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index b8dc10df..367b6d25 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -394,6 +394,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { where = append(where, "(v.vin LIKE ? OR l.plate LIKE ? OR v.plate LIKE ? OR v.phone LIKE ? OR v.oem LIKE ?)") args = append(args, like, like, like, like, like) } + // Extend only the identity predicate; the authorized VIN scope remains ANDed. + if vins := vehicleNumberVINs(keywords, strings.TrimSpace(query.Get("keywords")) != ""); len(vins) > 0 { + last := len(where) - 1 + where[last] = "(" + where[last] + " OR v.vin IN (" + strings.TrimSuffix(strings.Repeat("?,", len(vins)), ",") + "))" + for _, vin := range vins { + args = append(args, vin) + } + } } switch strings.TrimSpace(query.Get("online")) { case "online": @@ -486,35 +494,39 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") { vehicleOrderSQL = `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC` } + selectSQL := `SELECT v.vin, ` + + `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` + + `COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` + + `COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` + + `COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` + + `COUNT(DISTINCT l.protocol) AS source_count, ` + + `COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` + + `CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` + + `v.binding_status, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` + + `SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` + + primaryLocationAvailable + ` AS location_available, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` + + `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + todayMileageOrderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` + + `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + todayMileageOrderExpr + `), ',', 1), '') AS today_mileage_km, ` + + `COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + + `CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` + + groupSQL + text := selectSQL + `ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?` + if sorted, ok := monitorEnergySortSQL(selectSQL, query); ok { + text = sorted + } return SQLQuery{ - Text: `SELECT v.vin, ` + - `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` + - `COALESCE(v.phone, '') AS phone, COALESCE(v.oem, '') AS oem, ` + - `COALESCE(GROUP_CONCAT(DISTINCT l.protocol ORDER BY l.protocol SEPARATOR ','), '') AS protocols, ` + - `COALESCE(GROUP_CONCAT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END ORDER BY l.protocol SEPARATOR ','), '') AS online_protocols, ` + - `COUNT(DISTINCT l.protocol) AS source_count, ` + - `COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) AS online_source_count, ` + - `CASE WHEN COUNT(DISTINCT CASE WHEN l.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN l.protocol END) > 0 THEN 1 ELSE 0 END AS online, ` + - `v.binding_status, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(l.protocol ORDER BY ` + orderExpr + `), ',', 1), '') AS primary_protocol, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')) ORDER BY ` + orderExpr + `), ',', 1), '') AS location_source, ` + - `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(l.location_conflict, 0) ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS location_conflict, ` + - `SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(CAST(l.location_conflict_distance_m AS CHAR), '-1') ORDER BY ` + orderExpr + `), ',', 1) AS conflict_distance_m, ` + - primaryLocationAvailable + ` AS location_available, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.longitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS longitude, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.latitude AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS latitude, ` + - `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.speed_kmh IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS speed_available, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.speed_kmh AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS speed_kmh, ` + - `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.protocol IN ('GB32960','YUTONG_MQTT') AND l.soc_percent IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS soc_available, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.soc_percent AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS soc_percent, ` + - `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN l.total_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + orderExpr + `), ',', 1) AS UNSIGNED), 0) AS mileage_available, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ORDER BY ` + orderExpr + `), ',', 1), '') AS total_mileage_km, ` + - `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CASE WHEN m.daily_mileage_km IS NOT NULL THEN 1 ELSE 0 END ORDER BY ` + todayMileageOrderExpr + `), ',', 1) AS UNSIGNED), 0) AS today_mileage_available, ` + - `COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.daily_mileage_km AS CHAR) ORDER BY ` + todayMileageOrderExpr + `), ',', 1), '') AS today_mileage_km, ` + - `COALESCE(DATE_FORMAT(MAX(l.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + - `CAST(SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.access_report_interval_ms, -1) ORDER BY ` + orderExpr + `), ',', 1) AS SIGNED) AS report_interval_ms ` + - groupSQL + - `ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`, + Text: text, Args: args, CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`, CountArgs: countArgs, diff --git a/vehicle-data-platform/apps/api/internal/platform/oneos_query_scope_test.go b/vehicle-data-platform/apps/api/internal/platform/oneos_query_scope_test.go new file mode 100644 index 00000000..f94f623a --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/oneos_query_scope_test.go @@ -0,0 +1,73 @@ +package platform + +import ( + "context" + "net/url" + "strings" + "testing" +) + +func TestOneOSQueryScopeRestrictsAdministratorReadsAndWrites(t *testing.T) { + allowed := "LB9A32A24R0LS1426" + denied := "LNXNEGRR7SR318212" + p := Principal{Role: "admin", UserType: "admin", AuthProvider: "oneos", VehicleScopeSource: "oneos-vehicle-query", VehicleVINs: []string{allowed}} + ctx := WithPrincipal(context.Background(), p) + if !p.CanVIN(allowed) || p.CanVIN(denied) { + t.Fatal("administrator bypassed OneOS vehicle scope") + } + scopedQuery, err := applyPrincipalVehicleScope(ctx, url.Values{"scopeVehicleFrom": {"forged"}}) + if err != nil || scopedQuery.Get("scopeVehicleFrom") != "" { + t.Fatal("query scope must not inject fictitious date boundaries") + } + s := NewService(NewMockStore()) + page, err := s.VehicleRealtime(ctx, url.Values{"scopeVins": {denied}}) + if err != nil { + t.Fatal(err) + } + for _, v := range page.Items { + if v.VIN != allowed { + t.Fatal("forged query widened scope") + } + } + if _, err = s.SaveVehicleProfile(ctx, denied, VehicleProfileInput{}); err == nil { + t.Fatal("cross-scope write accepted") + } + if _, err = s.UpdateVehicleSourcePolicy(ctx, denied, VehicleSourcePolicyUpdate{}); err == nil { + t.Fatal("cross-scope policy write accepted") + } + if _, err = applyPrincipalHistoryTimeScope(ctx, denied, url.Values{}); err == nil { + t.Fatal("cross-scope history accepted") + } + q, err := applyPrincipalHistoryTimeScope(ctx, allowed, url.Values{"dateFrom": {"2020-01-01T00:00:00+08:00"}}) + if err != nil || q.Get("dateFrom") != "2020-01-01T00:00:00+08:00" { + t.Fatal("vehicle query must not invent historical grant timestamps") + } + local := WithPrincipal(context.Background(), Principal{Role: "customer", UserType: "customer", AuthProvider: "local", VehicleVINs: []string{allowed}}) + if _, err = applyPrincipalHistoryTimeScope(local, allowed, url.Values{}); err == nil { + t.Fatal("local history grant rules changed") + } + empty := WithPrincipal(context.Background(), Principal{Role: "admin", UserType: "admin", VehicleScopeSource: "oneos-vehicle-query"}) + page, err = s.VehicleRealtime(empty, url.Values{}) + if err != nil || len(page.Items) != 0 { + t.Fatal("empty scope exposed vehicles") + } +} + +func TestVehicleQueryScopeAlsoLimitsAlertAndReconciliationSQL(t *testing.T) { + for _, vins := range [][]string{{"LNXNEGRR3SR321379"}, {}} { + where, args := buildAlertWhere(AlertQuery{AllowedVINs: vins}) + if len(vins) == 0 && !strings.Contains(where, "1=0") { + t.Fatal("empty alert scope widened") + } + if len(vins) > 0 && (len(args) != 1 || args[0] != vins[0] || !strings.Contains(where, "e.vin")) { + t.Fatal("alert scope not bound") + } + where, args = buildReconciliationWhere(ReconciliationQuery{AllowedVINs: vins}) + if len(vins) == 0 && !strings.Contains(where, "1=0") { + t.Fatal("empty reconciliation scope widened") + } + if len(vins) > 0 && (len(args) != 1 || args[0] != vins[0] || !strings.Contains(where, "i.vin")) { + t.Fatal("reconciliation scope not bound") + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance.go new file mode 100644 index 00000000..85d5dfd7 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance.go @@ -0,0 +1,466 @@ +package platform + +import ( + "context" + "encoding/json" + "math/big" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +const operationComplianceMenu = "operation-compliance" + +var operationMileageDecimal = regexp.MustCompile(`^[0-9]{1,15}(\.[0-9]{1,3})?$`) + +type OperationMileageTotal struct { + VIN string + Meters *int64 + ValidDays int + InvalidDays int +} + +type OperationComplianceStore interface { + OperationVehicleRoster(context.Context, url.Values) ([]CustomerVehicleAssignment, error) + OperationAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) + OperationMileageTotals(context.Context, url.Values) ([]OperationMileageTotal, error) + OperationMileageDays(context.Context, url.Values) ([]OperationMileageDay, error) +} + +type OperationComplianceRow struct { + CustomerVehicleAssignment + MileageKm *float64 `json:"mileageKm"` + Status string `json:"status"` + ValidDays int `json:"validDays"` + ExpectedDays int `json:"expectedDays"` + AuthorizedDays int `json:"authorizedDays"` + InvalidDays int `json:"invalidDays"` + MissingDays int `json:"missingDays"` + Incomplete bool `json:"incomplete"` + Reasons []string `json:"reasons"` +} + +type OperationComplianceSummary struct { + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Insufficient int `json:"insufficient"` + PassRate *float64 `json:"passRate"` +} + +type OperationComplianceOptions struct { + Customers []string `json:"customers"` + Departments []string `json:"departments"` + ResponsiblePeople []string `json:"responsiblePeople"` +} + +type OperationComplianceResponse struct { + Items []OperationComplianceRow `json:"items"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Summary OperationComplianceSummary `json:"summary"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + TargetKm float64 `json:"targetKm"` + MileageSource string `json:"mileageSource"` + AssignmentSource string `json:"assignmentSource"` + AssignmentObservedAt string `json:"assignmentObservedAt"` + AssignmentStale bool `json:"assignmentStale"` + AssignmentMissingCount int `json:"assignmentMissingCount"` +} + +func operationMileageSource(q url.Values) (string, error) { + if len(q["mileageSource"]) > 1 { + return "", clientError{Code: "INVALID_MILEAGE_SOURCE", Message: "请选择一个里程数据源"} + } + source := q.Get("mileageSource") + if source == "" { + source = "gps" + } + if source != "gps" && source != "odometer" && source != "hydrogen" { + return "", clientError{Code: "INVALID_MILEAGE_SOURCE", Message: "里程数据源仅支持GPS、仪表或纯氢里程"} + } + return source, nil +} + +func operationSourceLabel(source string) string { + if source == "odometer" { + return "仪表" + } + if source == "hydrogen" { + return "纯氢" + } + return "GPS" +} + +func operationDates(q url.Values) ([]string, error) { + datesQuery := url.Values{"dateFrom": {q.Get("dateFrom")}, "dateTo": {q.Get("dateTo")}} + if datesQuery.Get("dateFrom") == "" && datesQuery.Get("dateTo") == "" { + now := time.Now().In(time.FixedZone("Asia/Shanghai", 8*3600)) + datesQuery.Set("dateFrom", now.AddDate(0, 0, -30).Format("2006-01-02")) + datesQuery.Set("dateTo", now.AddDate(0, 0, -1).Format("2006-01-02")) + } + return customerMileageDates(datesQuery, time.Now()) +} + +func operationComplianceAccess(ctx context.Context) (Principal, error) { + p, ok := PrincipalFromContext(ctx) + if !ok || p.AuthorizationStatus == "pending" || p.AuthorizationStatus == "loading" || !p.CanMenu(operationComplianceMenu) { + return p, clientError{Code: "MENU_PERMISSION_DENIED", Message: "当前账号无权查看车辆运营达标统计"} + } + return p, nil +} + +func parseOperationTarget(q url.Values) (int64, error) { + values, exists := q["targetKm"] + if !exists { + return 3000000, nil + } + if len(values) != 1 || !operationMileageDecimal.MatchString(values[0]) { + return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程必须为正数,最多保留三位小数"} + } + r, ok := new(big.Rat).SetString(values[0]) + if !ok { + return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程无效"} + } + r.Mul(r, big.NewRat(1000, 1)) + if !r.IsInt() || !r.Num().IsInt64() || r.Sign() <= 0 { + return 0, clientError{Code: "INVALID_OPERATION_TARGET", Message: "达标里程必须大于零且在有效数值范围内"} + } + return r.Num().Int64(), nil +} + +func operationSelections(q url.Values, key string) (map[string]bool, error) { + if len(q[key]) > 200 { + return nil, clientError{Code: "INVALID_FILTER", Message: "单项筛选最多选择200项"} + } + out := map[string]bool{} + for _, value := range q[key] { + value = strings.TrimSpace(value) + if len([]rune(value)) > 512 { + return nil, clientError{Code: "INVALID_FILTER", Message: "筛选项过长"} + } + if value != "" { + out[value] = true + } + } + return out, nil +} + +// The roster is authoritative: measurements and ownership are left joins, so +// unreported or unassigned authorized vehicles remain in the report. +func (s *Service) operationRoster(ctx context.Context) ([]CustomerVehicleAssignment, OperationComplianceResponse, error) { + meta := OperationComplianceResponse{Items: []OperationComplianceRow{}} + p, err := operationComplianceAccess(ctx) + if err != nil { + return nil, meta, err + } + store, ok := s.store.(OperationComplianceStore) + if !ok { + return nil, meta, clientError{Code: "OPERATION_COMPLIANCE_UNAVAILABLE", Message: "当前数据源尚未启用车辆运营达标统计"} + } + snapshot, _ := ctx.Value(customerAssignmentContextKey{}).(CustomerAssignmentSnapshot) + var roster, assignments []CustomerVehicleAssignment + observed := snapshot.ObservedAt + if snapshot.Live { + assignments, err = normalizeCustomerAssignments(snapshot.Rows) + roster = assignments + meta.AssignmentSource = "oneos-live" + } else { + tenant := p.TenantRef + if snapshot.Tenant != "" { + tenant = snapshot.Tenant + } + assignments, observed, err = store.OperationAssignments(ctx, tenant) + if err != nil { + return nil, meta, err + } + assignments, err = normalizeCustomerAssignments(assignments) + if err != nil { + return nil, meta, err + } + query, scopeErr := applyPrincipalVehicleScope(ctx, url.Values{}) + if scopeErr != nil { + return nil, meta, scopeErr + } + roster, err = store.OperationVehicleRoster(ctx, query) + meta.AssignmentSource = "oneos-synced" + } + if err != nil { + return nil, meta, err + } + meta.AssignmentStale = observed.IsZero() || time.Since(observed) > 24*time.Hour + if !observed.IsZero() { + meta.AssignmentObservedAt = observed.UTC().Format(time.RFC3339) + } + byVIN := map[string]CustomerVehicleAssignment{} + for _, row := range assignments { + byVIN[row.VIN] = row + } + result := []CustomerVehicleAssignment{} + seen := map[string]bool{} + for _, row := range roster { + row.VIN = strings.ToUpper(strings.TrimSpace(row.VIN)) + if !isLikelyVIN(row.VIN) || !p.CanVIN(row.VIN) || seen[row.VIN] { + continue + } + seen[row.VIN] = true + if assignment, exists := byVIN[row.VIN]; exists { + if assignment.Plate == "" { + assignment.Plate = row.Plate + } + row = assignment + } else { + meta.AssignmentMissingCount++ + } + result = append(result, row) + } + sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN }) + return result, meta, nil +} + +func (s *Service) OperationComplianceOptions(ctx context.Context) (OperationComplianceOptions, error) { + roster, _, err := s.operationRoster(ctx) + out := OperationComplianceOptions{Customers: []string{}, Departments: []string{}, ResponsiblePeople: []string{}} + if err != nil { + return out, err + } + customers, departments, people := map[string]bool{}, map[string]bool{}, map[string]bool{} + for _, r := range roster { + if r.Customer != "" { + customers[r.Customer] = true + } + if r.Department != "" { + departments[r.Department] = true + } + if r.ResponsiblePerson != "" { + people[r.ResponsiblePerson] = true + } + } + for value := range customers { + out.Customers = append(out.Customers, value) + } + for value := range departments { + out.Departments = append(out.Departments, value) + } + for value := range people { + out.ResponsiblePeople = append(out.ResponsiblePeople, value) + } + sort.Strings(out.Customers) + sort.Strings(out.Departments) + sort.Strings(out.ResponsiblePeople) + return out, nil +} + +func operationAuthorizedBounds(p Principal, vin, from, to string) (string, string, int) { + zone := time.FixedZone("Asia/Shanghai", 8*3600) + start, _ := time.ParseInLocation("2006-01-02", from, zone) + end, _ := time.ParseInLocation("2006-01-02", to, zone) + if p.HasHistoryTimeScope() { + grant, ok := p.VehicleGrant(vin) + if !ok || grant.ValidFrom.IsZero() { + return "", "", 0 + } + g := grant.ValidFrom.In(zone) + first := time.Date(g.Year(), g.Month(), g.Day(), 0, 0, 0, 0, zone) + if !first.Equal(g) { + first = first.AddDate(0, 0, 1) + } + if first.After(start) { + start = first + } + if grant.ValidTo != nil { + g = grant.ValidTo.In(zone) + last := time.Date(g.Year(), g.Month(), g.Day(), 0, 0, 0, 0, zone).AddDate(0, 0, -1) + if last.Before(end) { + end = last + } + } + } + if start.After(end) { + return "", "", 0 + } + return start.Format("2006-01-02"), end.Format("2006-01-02"), int(end.Sub(start)/(24*time.Hour)) + 1 +} + +func (s *Service) OperationCompliance(ctx context.Context, q url.Values) (OperationComplianceResponse, error) { + var empty OperationComplianceResponse + source, err := operationMileageSource(q) + if err != nil { + return empty, err + } + target, err := parseOperationTarget(q) + if err != nil { + return empty, err + } + dates, err := operationDates(q) + if err != nil { + return empty, err + } + filters := make([]map[string]bool, 3) + for i, key := range []string{"customer", "department", "responsiblePerson"} { + filters[i], err = operationSelections(q, key) + if err != nil { + return empty, err + } + } + status := q.Get("status") + if status != "" && status != "passed" && status != "failed" && status != "insufficient" { + return empty, clientError{Code: "INVALID_STATUS", Message: "达标状态无效"} + } + order := q.Get("sort") + if order == "" { + order = "mileage-desc" + } + if order != "mileage-desc" && order != "mileage-asc" { + return empty, clientError{Code: "INVALID_SORT", Message: "里程排序无效"} + } + limit, offset := 30, 0 + if q.Has("limit") { + limit, err = strconv.Atoi(q.Get("limit")) + if err != nil || limit < 1 || limit > 100 { + return empty, clientError{Code: "INVALID_PAGINATION", Message: "每页数量应为1至100"} + } + } + if q.Has("offset") { + offset, err = strconv.Atoi(q.Get("offset")) + if err != nil || offset < 0 { + return empty, clientError{Code: "INVALID_PAGINATION", Message: "分页位置无效"} + } + } + roster, response, err := s.operationRoster(ctx) + if err != nil { + return response, err + } + p, _ := PrincipalFromContext(ctx) + selected := []CustomerVehicleAssignment{} + query := url.Values{"dateFrom": {dates[0]}, "dateTo": {dates[len(dates)-1]}, "mileageSource": {source}} + starts, ends, authorized := map[string]string{}, map[string]string{}, map[string]int{} + vins := []string{} + for _, row := range roster { + if len(filters[0]) > 0 && !filters[0][row.Customer] || len(filters[1]) > 0 && !filters[1][row.Department] || len(filters[2]) > 0 && !filters[2][row.ResponsiblePerson] { + continue + } + selected = append(selected, row) + from, to, days := operationAuthorizedBounds(p, row.VIN, dates[0], dates[len(dates)-1]) + authorized[row.VIN] = days + if days > 0 { + vins = append(vins, row.VIN) + starts[row.VIN] = from + ends[row.VIN] = to + } + } + if len(selected)*len(dates) > 100000 { + return response, clientError{Code: "OPERATION_RANGE_TOO_LARGE", Message: "本次查询超过100,000个车辆日,请缩短日期或筛选车辆归属"} + } + totals := map[string]OperationMileageTotal{} + if len(vins) > 0 { + query.Set("vins", strings.Join(vins, ",")) + encoded, _ := json.Marshal(starts) + query.Set("scopeVehicleFrom", string(encoded)) + encoded, _ = json.Marshal(ends) + query.Set("operationVehicleTo", string(encoded)) + rows, queryErr := s.store.(OperationComplianceStore).OperationMileageTotals(ctx, query) + if queryErr != nil { + return response, queryErr + } + for _, row := range rows { + if _, exists := totals[row.VIN]; exists { + return response, clientError{Code: "OPERATION_DUPLICATE_TOTAL", Message: "同一车辆存在重复区间汇总"} + } + totals[row.VIN] = row + } + } + response.DateFrom, response.DateTo, response.TargetKm = dates[0], dates[len(dates)-1], float64(target)/1000 + response.MileageSource = source + response.Limit, response.Offset = limit, offset + rows := []OperationComplianceRow{} + for _, vehicle := range selected { + total := totals[vehicle.VIN] + row, rowErr := evaluateOperationRow(vehicle, total, authorized[vehicle.VIN], len(dates), target, source) + if rowErr != nil { + return response, rowErr + } + if status != "" && row.Status != status { + continue + } + rows = append(rows, row) + } + sort.Slice(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + if a.MileageKm == nil || b.MileageKm == nil { + if (a.MileageKm == nil) != (b.MileageKm == nil) { + return a.MileageKm != nil + } + return a.VIN < b.VIN + } + // Ordering uses the exact integer totals too; displayed decimals are presentation only. + am, bm := *totals[a.VIN].Meters, *totals[b.VIN].Meters + if am == bm { + return a.VIN < b.VIN + } + if order == "mileage-asc" { + return am < bm + } + return am > bm + }) + response.Total = len(rows) + response.Summary.Total = len(rows) + for _, row := range rows { + switch row.Status { + case "passed": + response.Summary.Passed++ + case "failed": + response.Summary.Failed++ + default: + response.Summary.Insufficient++ + } + } + if denominator := response.Summary.Passed + response.Summary.Failed; denominator > 0 { + rate := float64(response.Summary.Passed) / float64(denominator) * 100 + response.Summary.PassRate = &rate + } + if offset < len(rows) { + end := offset + limit + if end > len(rows) || end < offset { + end = len(rows) + } + response.Items = rows[offset:end] + } + return response, nil +} + +func evaluateOperationRow(vehicle CustomerVehicleAssignment, total OperationMileageTotal, authorized, expected int, target int64, source string) (OperationComplianceRow, error) { + row := OperationComplianceRow{CustomerVehicleAssignment: vehicle, ExpectedDays: expected, AuthorizedDays: authorized, ValidDays: total.ValidDays, InvalidDays: total.InvalidDays, Reasons: []string{}} + row.MissingDays = authorized - total.ValidDays - total.InvalidDays + if row.MissingDays < 0 || row.ValidDays < 0 || row.InvalidDays < 0 { + return row, clientError{Code: "OPERATION_INVALID_COVERAGE", Message: "里程覆盖计数异常"} + } + row.Incomplete = row.ValidDays != expected + if authorized < expected { + row.Reasons = append(row.Reasons, "部分日期不在完整自然日历史授权范围内") + } + if row.InvalidDays > 0 { + row.Reasons = append(row.Reasons, "存在异常或冲突日里程,已排除") + } + if row.MissingDays > 0 { + row.Reasons = append(row.Reasons, "部分授权日期缺少"+operationSourceLabel(source)+"日里程") + } + row.Status = "insufficient" + if total.Meters != nil && row.ValidDays > 0 { + value := float64(*total.Meters) / 1000 + row.MileageKm = &value + if *total.Meters >= target { + row.Status = "passed" + } else if !row.Incomplete { + row.Status = "failed" + } + } else if authorized > 0 { + row.Reasons = append(row.Reasons, "暂无有效"+operationSourceLabel(source)+"日里程") + } + return row, nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_detail.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_detail.go new file mode 100644 index 00000000..7229ecda --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_detail.go @@ -0,0 +1,117 @@ +package platform + +import ( + "context" + "encoding/json" + "net/url" + "strings" +) + +type OperationMileageDay struct { + VIN, Date, Protocol string + Meters *int64 +} + +type OperationComplianceDay struct { + Date string `json:"date"` + MileageKm *float64 `json:"mileageKm"` + Protocol string `json:"protocol"` + Status string `json:"status"` + Reason string `json:"reason"` +} + +type OperationComplianceDetail struct { + Vehicle OperationComplianceRow `json:"vehicle"` + DateFrom string `json:"dateFrom"` + DateTo string `json:"dateTo"` + TargetKm float64 `json:"targetKm"` + MileageSource string `json:"mileageSource"` + Days []OperationComplianceDay `json:"days"` +} + +func (s *Service) OperationComplianceDetail(ctx context.Context, q url.Values) (OperationComplianceDetail, error) { + out := OperationComplianceDetail{Days: []OperationComplianceDay{}} + if _, err := operationComplianceAccess(ctx); err != nil { + return out, err + } + source, err := operationMileageSource(q) + if err != nil { + return out, err + } + target, err := parseOperationTarget(q) + if err != nil { + return out, err + } + dates, err := operationDates(q) + if err != nil { + return out, err + } + vin := strings.ToUpper(strings.TrimSpace(q.Get("vin"))) + if len(q["vin"]) != 1 || !isLikelyVIN(vin) { + return out, clientError{Code: "INVALID_VIN", Message: "请选择一辆有效车辆"} + } + roster, _, err := s.operationRoster(ctx) + if err != nil { + return out, err + } + var vehicle CustomerVehicleAssignment + found := false + for _, row := range roster { + if row.VIN == vin { + vehicle, found = row, true + break + } + } + if !found { + return out, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"} + } + p, _ := PrincipalFromContext(ctx) + first, last, authorized := operationAuthorizedBounds(p, vin, dates[0], dates[len(dates)-1]) + byDate := map[string]OperationMileageDay{} + if authorized > 0 { + // Reconstruct scope from the caller; never forward browser-supplied VIN or + // historical authorization filters to the store. + starts, _ := json.Marshal(map[string]string{vin: first}) + ends, _ := json.Marshal(map[string]string{vin: last}) + query := url.Values{"vins": {vin}, "dateFrom": {first}, "dateTo": {last}, "scopeVehicleFrom": {string(starts)}, "operationVehicleTo": {string(ends)}, "mileageSource": {source}} + days, readErr := s.store.(OperationComplianceStore).OperationMileageDays(ctx, query) + if readErr != nil { + return out, readErr + } + for _, day := range days { + if _, exists := byDate[day.Date]; exists || day.VIN != vin || day.Date < first || day.Date > last { + return out, clientError{Code: "OPERATION_INVALID_DAY", Message: "车辆每日里程结果异常"} + } + byDate[day.Date] = day + } + } + total := OperationMileageTotal{VIN: vin} + var meters int64 + for _, date := range dates { + day := OperationComplianceDay{Date: date, Status: "missing", Reason: "暂无" + operationSourceLabel(source) + "日里程,不补为零"} + if authorized == 0 || date < first || date > last { + day.Status, day.Reason = "restricted", "不在完整自然日历史授权范围内" + } else if observation, ok := byDate[date]; ok { + day.Protocol = observation.Protocol + if observation.Meters == nil { + day.Status, day.Reason = "invalid", "日里程异常或冲突,未计入区间里程" + total.InvalidDays++ + } else { + value := float64(*observation.Meters) / 1000 + day.MileageKm, day.Status, day.Reason = &value, "valid", "已计入区间里程" + meters += *observation.Meters + total.ValidDays++ + } + } + out.Days = append(out.Days, day) + } + if total.ValidDays > 0 { + total.Meters = &meters + } + out.Vehicle, err = evaluateOperationRow(vehicle, total, authorized, len(dates), target, source) + if err != nil { + return out, err + } + out.DateFrom, out.DateTo, out.TargetKm, out.MileageSource = dates[0], dates[len(dates)-1], float64(target)/1000, source + return out, nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_handler.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_handler.go new file mode 100644 index 00000000..78e249fa --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_handler.go @@ -0,0 +1,19 @@ +package platform + +import "net/http" + +func (h *Handler) handleOperationCompliance(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + data, err := h.service.OperationCompliance(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} +func (h *Handler) handleOperationComplianceOptions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + data, err := h.service.OperationComplianceOptions(r.Context()) + h.write(w, r, data, err) +} +func (h *Handler) handleOperationComplianceDetail(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + data, err := h.service.OperationComplianceDetail(r.Context(), r.URL.Query()) + h.write(w, r, data, err) +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_hydrogen_integration_test.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_hydrogen_integration_test.go new file mode 100644 index 00000000..c146514d --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_hydrogen_integration_test.go @@ -0,0 +1,89 @@ +package platform + +import ( + "context" + "database/sql" + "net/url" + "os" + "strings" + "testing" + "time" +) + +// Exercise MySQL aggregation with SELECT-only fixtures, without writing source data. +func TestOperationComplianceMySQLHydrogenQualityAndScope(t *testing.T) { + dsn := os.Getenv("OPERATION_ACCEPTANCE_DSN") + if dsn == "" { + t.Skip("OPERATION_ACCEPTANCE_DSN not provided") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatal("acceptance database configuration invalid") + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + vin := "LNX00000000000001" + type sample struct { + account, vin, date string + km any + } + samples := []sample{ + {"guangdonglingniu", vin, "2026-09-01", 777}, + {"guangdonglingniu", vin, "2026-09-02", 0}, {"guangdonglingniu", vin, "2026-09-02", 0}, + {"guangdonglingniu", vin, "2026-09-03", "1500.125"}, {"guangdonglingniu", vin, "2026-09-03", "1500.125"}, + {"guangdonglingniu", vin, "2026-09-04", 1}, {"guangdonglingniu", vin, "2026-09-04", 2}, + {"guangdonglingniu", vin, "2026-09-05", nil}, + {"guangdonglingniu", vin, "2026-09-06", -1}, + {"guangdonglingniu", vin, "2026-09-07", "3000.001"}, + {"guangdonglingniu", vin, "2026-09-08", 3000}, + {"guangdonglingniu", vin, "2026-09-09", 777}, + {"another-account", vin, "2026-09-03", 999}, + {"guangdonglingniu", "LNX00000000000002", "2026-09-03", 999}, + } + selects, args := []string{}, []any{} + for _, s := range samples { + selects = append(selects, "SELECT ? AS account,? AS vin,CAST(? AS DATE) AS stat_date,CAST(? AS DECIMAL(18,3)) AS hydrogen_km") + args = append(args, s.account, s.vin, s.date, s.km) + } + q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-10"}, "mileageSource": {"hydrogen"}, "vins": {vin}, "scopeVehicleFrom": {`{"LNX00000000000001":"2026-09-02"}`}, "operationVehicleTo": {`{"LNX00000000000001":"2026-09-08"}`}} + fixtureSQL := "FROM (" + strings.Join(selects, " UNION ALL ") + ") m WHERE" + built, err := buildOperationMileageDaysSQL(q) + if err != nil { + t.Fatal(err) + } + relation := strings.Replace(built.Text, "FROM gzev_daily_mileage m WHERE", fixtureSQL, 1) + rows, err := db.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),protocol,km FROM (`+relation+`) days ORDER BY stat_date`, append(args, built.Args...)...) + if err != nil { + t.Fatal(err) + } + wants := []sql.NullFloat64{{Float64: 0, Valid: true}, {Float64: 1500.125, Valid: true}, {}, {}, {}, {}, {Float64: 3000, Valid: true}} + count := 0 + for rows.Next() { + var date, protocol string + var km sql.NullFloat64 + if err = rows.Scan(&date, &protocol, &km); err != nil { + t.Fatal(err) + } + if count >= len(wants) || date != time.Date(2026, 9, 2+count, 0, 0, 0, 0, time.UTC).Format("2006-01-02") || protocol != "GZEV" || km != wants[count] { + t.Fatalf("unexpected hydrogen day: %s %s %+v", date, protocol, km) + } + count++ + } + err = rows.Err() + rows.Close() + if err != nil || count != len(wants) { + t.Fatalf("hydrogen day count=%d error=%v", count, err) + } + totals, err := buildOperationMileageSQL(q) + if err != nil { + t.Fatal(err) + } + var selectedVIN string + var meters int64 + var valid, invalid int + err = db.QueryRowContext(ctx, strings.Replace(totals.Text, "FROM gzev_daily_mileage m WHERE", fixtureSQL, 1), append(args, totals.Args...)...).Scan(&selectedVIN, &meters, &valid, &invalid) + if err != nil || selectedVIN != vin || meters != 4500125 || valid != 3 || invalid != 4 { + t.Fatalf("hydrogen totals: vin=%s meters=%d valid=%d invalid=%d error=%v", selectedVIN, meters, valid, invalid, err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_integration_test.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_integration_test.go new file mode 100644 index 00000000..675af1e5 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_integration_test.go @@ -0,0 +1,158 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +// Opt-in acceptance against the existing read-only data path. The synthetic +// cases are SELECT-derived relations; no database table or business row is changed. +func TestOperationComplianceMySQLAcceptance(t *testing.T) { + dsn := os.Getenv("OPERATION_ACCEPTANCE_DSN") + if dsn == "" { + t.Skip("OPERATION_ACCEPTANCE_DSN not provided") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatal("acceptance database configuration invalid") + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + t.Fatal("acceptance database unavailable") + } + vin := func(n int) string { return fmt.Sprintf("LNX%014d", n) } + type sample struct { + n int + date, protocol string + km any + } + samples := []sample{ + {1, "2026-09-01", "JT808", "1000"}, {1, "2026-09-01", "JT808", "1000"}, {1, "2026-09-02", "JT808", "1500"}, {1, "2026-09-03", "JT808", "500"}, {1, "2026-09-01", "GB32960", "9000"}, + {2, "2026-09-01", "JT808", "2999.999"}, {2, "2026-09-02", "JT808", "0"}, {2, "2026-09-03", "JT808", "0"}, + {3, "2026-09-01", "JT808", "0"}, {3, "2026-09-02", "JT808", "0"}, {3, "2026-09-03", "JT808", "0"}, + {4, "2026-09-01", "JT808", "100"}, {4, "2026-09-01", "JT808", "200"}, {4, "2026-09-02", "JT808", "-1"}, {4, "2026-09-03", "JT808", "3001"}, + {5, "2026-09-01", "JT808", "1000"}, {5, "2026-09-02", "JT808", "2000"}, + {6, "2026-09-01", "JT808", nil}, + } + selects, args := []string{}, []any{} + for _, s := range samples { + selects = append(selects, "SELECT ? AS vin,CAST(? AS DATE) AS stat_date,? AS protocol,CAST(? AS DECIMAL(18,3)) AS daily_mileage_km") + args = append(args, vin(s.n), s.date, s.protocol, s.km) + } + q := operationQuery() + vins := []string{} + starts, ends := map[string]string{}, map[string]string{} + for n := 1; n <= 6; n++ { + v := vin(n) + vins = append(vins, v) + starts[v] = "2026-09-01" + ends[v] = "2026-09-03" + } + q.Set("vins", strings.Join(vins, ",")) + encoded, _ := json.Marshal(starts) + q.Set("scopeVehicleFrom", string(encoded)) + encoded, _ = json.Marshal(ends) + q.Set("operationVehicleTo", string(encoded)) + built, err := buildOperationMileageSQL(q) + if err != nil { + t.Fatal(err) + } + query := strings.Replace(built.Text, "FROM vehicle_daily_mileage m WHERE", "FROM ("+strings.Join(selects, " UNION ALL ")+") m WHERE", 1) + args = append(args, built.Args...) + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + t.Fatal(err) + } + actual := map[string]OperationMileageTotal{} + for rows.Next() { + var r OperationMileageTotal + var meters sql.NullInt64 + if err = rows.Scan(&r.VIN, &meters, &r.ValidDays, &r.InvalidDays); err != nil { + t.Fatal(err) + } + if meters.Valid { + v := meters.Int64 + r.Meters = &v + } + actual[r.VIN] = r + } + err = rows.Err() + rows.Close() + if err != nil { + t.Fatal(err) + } + for _, want := range []OperationMileageTotal{{VIN: vin(1), Meters: operationMeters(3000000), ValidDays: 3}, {VIN: vin(2), Meters: operationMeters(2999999), ValidDays: 3}, {VIN: vin(3), Meters: operationMeters(0), ValidDays: 3}, {VIN: vin(4), InvalidDays: 3}, {VIN: vin(5), Meters: operationMeters(3000000), ValidDays: 2}, {VIN: vin(6), InvalidDays: 1}} { + got := actual[want.VIN] + if got.ValidDays != want.ValidDays || got.InvalidDays != want.InvalidDays || (got.Meters == nil) != (want.Meters == nil) || got.Meters != nil && *got.Meters != *want.Meters { + t.Fatalf("SQL aggregation mismatch for %s", want.VIN) + } + } + + tenant := os.Getenv("OPERATION_ACCEPTANCE_TENANT") + if tenant == "" { + tenant = "000000" + } + p := Principal{Role: "admin", UserType: "admin", AuthProvider: "local", TenantRef: tenant, ReadOnly: true, MenuKeys: []string{operationComplianceMenu}} + request := httptest.NewRequest("GET", "/api/v2/operation-compliance?dateFrom=2026-09-01&dateTo=2026-09-30&limit=100", nil).WithContext(WithPrincipal(ctx, p)) + w := httptest.NewRecorder() + NewHandler(NewService(NewProductionStore(db, nil, ""))).ServeHTTP(w, request) + if w.Code != 200 { + t.Fatalf("real-data handler failed: %d %s", w.Code, w.Body.String()) + } + var envelope struct { + Data OperationComplianceResponse `json:"data"` + } + if err = json.Unmarshal(w.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + result := envelope.Data + if len(result.Items) == 0 { + t.Fatal("real acceptance found no authorized vehicles") + } + checked := 0 + realSamples := []map[string]any{} + for _, r := range result.Items { + if checked == 5 { + break + } + var meters sql.NullInt64 + var valid, invalid int + err = db.QueryRowContext(ctx, `SELECT SUM(CASE WHEN daily_mileage_km BETWEEN 0 AND 3000 THEN CAST(ROUND(daily_mileage_km*1000,0) AS SIGNED) END),COALESCE(SUM(daily_mileage_km BETWEEN 0 AND 3000),0),COALESCE(SUM(daily_mileage_km<0 OR daily_mileage_km>3000),0) FROM vehicle_daily_mileage WHERE vin=? AND protocol='JT808' AND stat_date BETWEEN ? AND ?`, r.VIN, result.DateFrom, result.DateTo).Scan(&meters, &valid, &invalid) + if err != nil { + t.Fatal(err) + } + if valid != r.ValidDays || invalid != r.InvalidDays || meters.Valid != (r.MileageKm != nil) || meters.Valid && float64(meters.Int64)/1000 != *r.MileageKm { + t.Fatalf("independent real-data comparison failed for %s", r.VIN) + } + want := "insufficient" + if meters.Valid && meters.Int64 >= 3000000 { + want = "passed" + } else if valid == 30 { + want = "failed" + } + if r.Status != want { + t.Fatalf("real status mismatch for %s", r.VIN) + } + realSamples = append(realSamples, map[string]any{"vin": r.VIN, "plate": r.Plate, "apiMileageKm": r.MileageKm, "sqlMeters": meters, "validDays": valid, "invalidDays": invalid, "status": r.Status, "matched": true}) + checked++ + } + report := map[string]any{"syntheticSqlCases": 6, "realSamplesChecked": checked, "realSamples": realSamples, "realDateFrom": result.DateFrom, "realDateTo": result.DateTo, "summary": result.Summary, "assignmentMissingCount": result.AssignmentMissingCount, "syntheticWrites": 0, "businessWrites": 0, "authentication": "in-process read-only principal; middleware isolation verified separately"} + if path := os.Getenv("OPERATION_ACCEPTANCE_REPORT"); path != "" { + raw, _ := json.MarshalIndent(report, "", " ") + if err = os.WriteFile(path, append(raw, '\n'), 0600); err != nil { + t.Fatal(err) + } + } + t.Logf("synthetic SQL cases=6; independently checked real vehicles=%d; total=%d", checked, result.Total) +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_sources_integration_test.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_sources_integration_test.go new file mode 100644 index 00000000..f0b01efd --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_sources_integration_test.go @@ -0,0 +1,146 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "math" + "net/url" + "os" + "strings" + "testing" + "time" +) + +// All synthetic cases use SELECT relations; acceptance never inserts test rows. +func TestOperationComplianceMySQLSourcesAndDailyReconciliation(t *testing.T) { + dsn := os.Getenv("OPERATION_ACCEPTANCE_DSN") + if dsn == "" { + t.Skip("OPERATION_ACCEPTANCE_DSN not provided") + } + db, err := sql.Open("mysql", dsn) + if err != nil { + t.Fatal("acceptance database configuration invalid") + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + vin := "LNX00000000000001" + type sample struct { + date, protocol string + km any + } + samples := []sample{ + {"2026-09-01", "GB32960", 100}, {"2026-09-01", "YUTONG_MQTT", 200}, + {"2026-09-02", "GB32960", 0}, {"2026-09-02", "YUTONG_MQTT", 200}, + {"2026-09-03", "GB32960", -1}, {"2026-09-03", "YUTONG_MQTT", 0}, + {"2026-09-04", "GB32960", nil}, {"2026-09-04", "YUTONG_MQTT", 3001}, + {"2026-09-05", "GB32960", 100}, {"2026-09-05", "GB32960", 200}, {"2026-09-05", "YUTONG_MQTT", 50}, + {"2026-09-06", "JT808", 999}, + {"2026-09-07", "GB32960", 0}, {"2026-09-07", "YUTONG_MQTT", 0}, + } + selects, args := []string{}, []any{} + for _, s := range samples { + selects = append(selects, "SELECT ? AS vin,CAST(? AS DATE) AS stat_date,? AS protocol,CAST(? AS DECIMAL(18,3)) AS daily_mileage_km") + args = append(args, vin, s.date, s.protocol, s.km) + } + q := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-07"}, "mileageSource": {"odometer"}, "vins": {vin}, "operationVehicleTo": {`{}`}} + built, err := buildOperationMileageDaysSQL(q) + if err != nil { + t.Fatal(err) + } + relation := strings.Replace(built.Text, "FROM vehicle_daily_mileage m WHERE", "FROM ("+strings.Join(selects, " UNION ALL ")+") m WHERE", 1) + rows, err := db.QueryContext(ctx, `SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),protocol,km FROM (`+relation+`) days ORDER BY stat_date`, append(args, built.Args...)...) + if err != nil { + t.Fatal(err) + } + type selected struct { + date, protocol string + km sql.NullFloat64 + } + var got []selected + for rows.Next() { + var r selected + if err = rows.Scan(&r.date, &r.protocol, &r.km); err != nil { + t.Fatal(err) + } + got = append(got, r) + } + err = rows.Err() + rows.Close() + if err != nil { + t.Fatal(err) + } + wants := []selected{{"2026-09-01", "GB32960", sql.NullFloat64{Float64: 100, Valid: true}}, {"2026-09-02", "YUTONG_MQTT", sql.NullFloat64{Float64: 200, Valid: true}}, {"2026-09-03", "YUTONG_MQTT", sql.NullFloat64{Float64: 0, Valid: true}}, {"2026-09-04", "GB32960", sql.NullFloat64{}}, {"2026-09-05", "YUTONG_MQTT", sql.NullFloat64{Float64: 50, Valid: true}}, {"2026-09-07", "GB32960", sql.NullFloat64{Float64: 0, Valid: true}}} + if len(got) != len(wants) { + t.Fatalf("selected %d days, want %d", len(got), len(wants)) + } + for i, want := range wants { + if got[i] != want { + t.Fatalf("day %d got %+v want %+v", i, got[i], want) + } + } + // Read actual historical data with the same permission-limited service and + // prove each returned day reconciles with the list's integer-meter total. + tenant := os.Getenv("OPERATION_ACCEPTANCE_TENANT") + if tenant == "" { + tenant = "000000" + } + p := Principal{Role: "admin", UserType: "admin", AuthProvider: "local", TenantRef: tenant, ReadOnly: true, MenuKeys: []string{operationComplianceMenu}} + ctx = WithPrincipal(ctx, p) + svc := NewService(NewProductionStore(db, nil, "")) + type audit struct { + Source string `json:"source"` + Summary OperationComplianceSummary `json:"summary"` + CheckedVINs []string `json:"checkedVINs"` + } + report := []audit{} + for _, source := range []string{"gps", "odometer", "hydrogen"} { + query := url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "mileageSource": {source}, "limit": {"5"}} + list, err := svc.OperationCompliance(ctx, query) + if err != nil { + t.Fatal(err) + } + a := audit{Source: source, Summary: list.Summary, CheckedVINs: []string{}} + for _, vehicle := range list.Items { + query.Set("vin", vehicle.VIN) + detail, err := svc.OperationComplianceDetail(ctx, query) + if err != nil { + t.Fatal(err) + } + if detail.MileageSource != source || detail.Vehicle.Status != vehicle.Status || detail.Vehicle.ValidDays != vehicle.ValidDays || detail.Vehicle.InvalidDays != vehicle.InvalidDays || detail.Vehicle.MissingDays != vehicle.MissingDays || (detail.Vehicle.MileageKm == nil) != (vehicle.MileageKm == nil) || vehicle.MileageKm != nil && *detail.Vehicle.MileageKm != *vehicle.MileageKm { + t.Fatalf("list/detail mismatch source=%s vin=%s", source, vehicle.VIN) + } + if len(detail.Days) != 30 { + t.Fatal("missing calendar dates") + } + if source == "hydrogen" { + observations, readErr := svc.store.(*ProductionStore).CustomerMileageObservations(ctx, url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-30"}, "vins": {vehicle.VIN}}) + if readErr != nil { + t.Fatal(readErr) + } + var expectedMeters int64 + validDays := 0 + for _, day := range observations { + if day.HydrogenKm != nil { + expectedMeters += int64(math.Round(*day.HydrogenKm * 1000)) + validDays++ + } + } + if vehicle.ValidDays != validDays || (vehicle.MileageKm == nil) != (validDays == 0) || vehicle.MileageKm != nil && int64(math.Round(*vehicle.MileageKm*1000)) != expectedMeters { + t.Fatalf("customer operations hydrogen mismatch vin=%s", vehicle.VIN) + } + } + a.CheckedVINs = append(a.CheckedVINs, vehicle.VIN) + } + report = append(report, a) + t.Logf("source=%s vehicles=%d passed=%d failed=%d insufficient=%d checked=%d", source, list.Total, list.Summary.Passed, list.Summary.Failed, list.Summary.Insufficient, len(a.CheckedVINs)) + } + if path := os.Getenv("OPERATION_SOURCE_REPORT"); path != "" { + encoded, _ := json.MarshalIndent(report, "", " ") + if err = os.WriteFile(path, encoded, 0600); err != nil { + t.Fatal(fmt.Errorf("write acceptance report: %w", err)) + } + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_store.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_store.go new file mode 100644 index 00000000..50e9fc7f --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_store.go @@ -0,0 +1,168 @@ +package platform + +import ( + "context" + "database/sql" + "encoding/json" + "net/url" + "sort" + "strings" + "time" +) + +func (s *ProductionStore) OperationAssignments(ctx context.Context, tenant string) ([]CustomerVehicleAssignment, time.Time, error) { + return s.CustomerAssignments(ctx, tenant) +} + +func (s *ProductionStore) OperationVehicleRoster(ctx context.Context, q url.Values) ([]CustomerVehicleAssignment, error) { + set, args := buildVehicleCoverageSetSQL(q.Get("scopeVins")) + rows, err := s.db.QueryContext(ctx, `SELECT v.vin,COALESCE(NULLIF(MAX(s.plate),''),MAX(b.plate),'') FROM (`+set+`) v LEFT JOIN vehicle_identity_binding b ON b.vin=v.vin LEFT JOIN vehicle_realtime_snapshot s ON s.vin=v.vin GROUP BY v.vin ORDER BY v.vin`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []CustomerVehicleAssignment{} + for rows.Next() { + var r CustomerVehicleAssignment + if err := rows.Scan(&r.VIN, &r.Plate); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// Both interval totals and daily detail read this exact selection. GPS reads the +// existing JT808 projection (already selected across GPS platforms). Odometer +// protocols are alternatives for one day, never distances to add together. +// Hydrogen uses the same GZEV account/day projection as customer operations. +func buildOperationMileageDaysSQL(q url.Values) (SQLQuery, error) { + source, err := operationMileageSource(q) + if err != nil { + return SQLQuery{}, err + } + protocolFilter := "m.protocol = 'JT808'" + from, mileage, protocol := "vehicle_daily_mileage m", "m.daily_mileage_km", "m.protocol" + groupBy := "m.vin,m.stat_date,m.protocol" + if source == "odometer" { + protocolFilter = "m.protocol IN ('GB32960','YUTONG_MQTT')" + } else if source == "hydrogen" { + from, mileage, protocol = "gzev_daily_mileage m", "m.hydrogen_km", "'GZEV'" + protocolFilter = "m.account = 'guangdonglingniu'" + groupBy = "m.vin,m.stat_date" + } + where := []string{protocolFilter, "m.stat_date >= ?", "m.stat_date <= ?"} + args := []any{q.Get("dateFrom"), q.Get("dateTo")} + // A nonempty, server-created VIN list is mandatory, even for administrators. + if q.Get("vins") == "" { + where = append(where, "1=0") + } + where, args = appendVINListFilter(where, args, "m.vin", q.Get("vins")) + where, args = appendVehicleDateScope(where, args, "m.vin", "m.stat_date", q.Get("scopeVehicleFrom")) + ends := map[string]string{} + if err := json.Unmarshal([]byte(q.Get("operationVehicleTo")), &ends); err != nil { + return SQLQuery{}, err + } + vins := make([]string, 0, len(ends)) + for vin := range ends { + vins = append(vins, vin) + } + sort.Strings(vins) + for _, vin := range vins { + where = append(where, "(m.vin <> ? OR m.stat_date <= ?)") + args = append(args, vin, ends[vin]) + } + // Identical duplicates collapse to one day. Conflicting values become an + // invalid day, never an arbitrary maximum or the sum of duplicate reports. + days := `SELECT m.vin,m.stat_date,` + protocol + ` AS protocol,CASE WHEN COUNT(` + mileage + `)=COUNT(*) AND COUNT(DISTINCT ` + mileage + `)=1 AND MIN(` + mileage + `)>=0 AND MAX(` + mileage + `)<=3000 THEN MIN(` + mileage + `) ELSE NULL END AS km FROM ` + from + ` WHERE ` + strings.Join(where, " AND ") + ` GROUP BY ` + groupBy + if source == "odometer" { + order := `CASE WHEN km IS NULL THEN 1 ELSE 0 END, ` + mileageDailySelectionOrder("km", "protocol", []string{"GB32960", "YUTONG_MQTT"}) + days = `SELECT vin,stat_date,protocol,km FROM (SELECT candidates.*,ROW_NUMBER() OVER (PARTITION BY vin,stat_date ORDER BY ` + order + `) AS source_rank FROM (` + days + `) candidates) ranked WHERE source_rank=1` + } + return SQLQuery{Text: days, Args: args}, nil +} + +func buildOperationMileageSQL(q url.Values) (SQLQuery, error) { + days, err := buildOperationMileageDaysSQL(q) + if err != nil { + return SQLQuery{}, err + } + return SQLQuery{Text: `SELECT vin,SUM(CAST(ROUND(km*1000,0) AS SIGNED)),COUNT(km),SUM(CASE WHEN km IS NULL THEN 1 ELSE 0 END) FROM (` + days.Text + `) operation_days GROUP BY vin`, Args: days.Args}, nil +} + +func (s *ProductionStore) OperationMileageTotals(ctx context.Context, q url.Values) ([]OperationMileageTotal, error) { + built, err := buildOperationMileageSQL(q) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, built.Text, built.Args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []OperationMileageTotal{} + for rows.Next() { + var r OperationMileageTotal + var meters sql.NullInt64 + if err := rows.Scan(&r.VIN, &meters, &r.ValidDays, &r.InvalidDays); err != nil { + return nil, err + } + if meters.Valid { + value := meters.Int64 + r.Meters = &value + } + out = append(out, r) + } + return out, rows.Err() +} + +func (s *ProductionStore) OperationMileageDays(ctx context.Context, q url.Values) ([]OperationMileageDay, error) { + built, err := buildOperationMileageDaysSQL(q) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, `SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),protocol,CAST(ROUND(km*1000,0) AS SIGNED) FROM (`+built.Text+`) operation_days ORDER BY stat_date`, built.Args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []OperationMileageDay{} + for rows.Next() { + var day OperationMileageDay + var meters sql.NullInt64 + if err := rows.Scan(&day.VIN, &day.Date, &day.Protocol, &meters); err != nil { + return nil, err + } + if meters.Valid { + day.Meters = &meters.Int64 + } + out = append(out, day) + } + return out, rows.Err() +} + +func (s *MockStore) OperationVehicleRoster(_ context.Context, q url.Values) ([]CustomerVehicleAssignment, error) { + out := []CustomerVehicleAssignment{} + seen := map[string]bool{} + for _, r := range s.vehicles { + if seen[r.VIN] || q.Get("scopeVins") != "" && !containsString(splitCSV(q.Get("scopeVins")), r.VIN) { + continue + } + seen[r.VIN] = true + out = append(out, CustomerVehicleAssignment{VIN: r.VIN, Plate: r.Plate}) + } + return out, nil +} +func (s *MockStore) OperationAssignments(_ context.Context, _ string) ([]CustomerVehicleAssignment, time.Time, error) { + out := []CustomerVehicleAssignment{} + for vin, r := range s.businessRelations { + out = append(out, CustomerVehicleAssignment{VIN: vin, Plate: r.PlateNumber, Customer: r.CustomerName, Department: r.DepartmentName, ResponsiblePerson: r.ResponsibleUserName}) + } + return out, time.Now(), nil +} +func (s *MockStore) OperationMileageTotals(_ context.Context, _ url.Values) ([]OperationMileageTotal, error) { + return []OperationMileageTotal{}, nil +} +func (s *MockStore) OperationMileageDays(_ context.Context, _ url.Values) ([]OperationMileageDay, error) { + return []OperationMileageDay{}, nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/operation_compliance_test.go b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_test.go new file mode 100644 index 00000000..bec18c25 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/operation_compliance_test.go @@ -0,0 +1,321 @@ +package platform + +import ( + "context" + "encoding/json" + "net/url" + "strings" + "testing" + "time" +) + +type operationTestStore struct { + *MockStore + roster []CustomerVehicleAssignment + assignments []CustomerVehicleAssignment + totals []OperationMileageTotal + query url.Values + reads int + days []OperationMileageDay + dayReads int +} + +func (s *operationTestStore) OperationVehicleRoster(context.Context, url.Values) ([]CustomerVehicleAssignment, error) { + return s.roster, nil +} +func (s *operationTestStore) OperationAssignments(context.Context, string) ([]CustomerVehicleAssignment, time.Time, error) { + return s.assignments, time.Now(), nil +} +func (s *operationTestStore) OperationMileageTotals(_ context.Context, q url.Values) ([]OperationMileageTotal, error) { + s.query = q + s.reads++ + return s.totals, nil +} +func (s *operationTestStore) OperationMileageDays(_ context.Context, q url.Values) ([]OperationMileageDay, error) { + s.query = q + s.dayReads++ + return s.days, nil +} +func operationContext() context.Context { + return WithPrincipal(context.Background(), Principal{Role: "admin", UserType: "admin", MenuKeys: []string{operationComplianceMenu}}) +} +func operationQuery() url.Values { + return url.Values{"dateFrom": {"2026-09-01"}, "dateTo": {"2026-09-03"}} +} +func operationMeters(v int64) *int64 { return &v } + +func TestOperationComplianceTotalsCoverageThresholdAndPagination(t *testing.T) { + vins := []string{"LNX00000000000001", "LNX00000000000002", "LNX00000000000003", "LNX00000000000004", "LNX00000000000005", "LNX00000000000006"} + store := &operationTestStore{MockStore: NewMockStore(), totals: []OperationMileageTotal{ + {VIN: vins[0], Meters: operationMeters(3000000), ValidDays: 3}, + {VIN: vins[1], Meters: operationMeters(2999999), ValidDays: 3}, + {VIN: vins[3], Meters: operationMeters(3200000), ValidDays: 2}, + {VIN: vins[4], Meters: operationMeters(2500000), ValidDays: 2}, + {VIN: vins[5], Meters: operationMeters(0), ValidDays: 3}, + }} + for _, vin := range vins { + store.roster = append(store.roster, CustomerVehicleAssignment{VIN: vin, Plate: "测试车"}) + } + // The roster may repeat one vehicle; it must still be counted once. + store.roster = append(store.roster, store.roster[0]) + service := NewService(store) + q := operationQuery() + q.Set("limit", "2") + result, err := service.OperationCompliance(operationContext(), q) + if err != nil { + t.Fatal(err) + } + if result.Total != 6 || len(result.Items) != 2 || result.Summary.Passed != 2 || result.Summary.Failed != 2 || result.Summary.Insufficient != 2 || result.Summary.PassRate == nil || *result.Summary.PassRate != 50 { + t.Fatalf("bad full-result summary: %+v", result) + } + if result.Items[0].VIN != vins[3] || !result.Items[0].Incomplete || result.Items[0].MissingDays != 1 || result.Items[1].Status != "passed" { + t.Fatalf("wrong order or boundary: %+v", result.Items) + } + q.Set("limit", "100") + q.Set("sort", "mileage-asc") + result, err = service.OperationCompliance(operationContext(), q) + if err != nil { + t.Fatal(err) + } + if result.Items[0].MileageKm == nil || *result.Items[0].MileageKm != 0 || result.Items[0].Status != "failed" || result.Items[5].MileageKm != nil { + t.Fatal("zero and absent data conflated") + } + byVIN := map[string]OperationComplianceRow{} + for _, r := range result.Items { + byVIN[r.VIN] = r + } + if byVIN[vins[1]].Status != "failed" || byVIN[vins[4]].Status != "insufficient" || byVIN[vins[0]].Customer != "" { + t.Fatal("boundary, partial data or unassigned vehicle lost") + } + q.Set("offset", "2") + result, err = service.OperationCompliance(operationContext(), q) + if err != nil || result.Summary.Total != 6 { + t.Fatal("pagination changed summary") + } + q.Set("targetKm", "4000") + q.Set("offset", "0") + result, err = service.OperationCompliance(operationContext(), q) + if err != nil || result.Summary.Passed != 0 || result.Summary.Failed != 3 || result.Summary.Insufficient != 3 { + t.Fatalf("target not reevaluated: %+v %v", result, err) + } + q.Set("status", "failed") + result, err = service.OperationCompliance(operationContext(), q) + if err != nil || result.Total != 3 || result.Summary.Total != 3 || result.Summary.Failed != 3 { + t.Fatal("status-filter summary must describe the complete status-filtered result") + } +} + +func TestOperationComplianceMultiSelectAndScopedOptions(t *testing.T) { + rows := []CustomerVehicleAssignment{ + {VIN: "LNX00000000000001", Customer: "客户甲", Department: "一部", ResponsiblePerson: "张"}, + {VIN: "LNX00000000000002", Customer: "客户乙", Department: "一部", ResponsiblePerson: "李"}, + {VIN: "LNX00000000000003", Customer: "客户甲分公司", Department: "二部", ResponsiblePerson: "张"}, + {VIN: "LNX00000000000004", Customer: "范围外客户", Department: "外部", ResponsiblePerson: "外部"}, + } + store := &operationTestStore{MockStore: NewMockStore(), roster: rows, assignments: rows} + p := Principal{Role: "customer", UserType: "customer", VehicleScopeSource: "oneos-vehicle-query", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{rows[0].VIN, rows[1].VIN, rows[2].VIN}} + ctx := WithPrincipal(context.Background(), p) + service := NewService(store) + options, err := service.OperationComplianceOptions(ctx) + if err != nil || len(options.Customers) != 3 || strings.Contains(strings.Join(options.Customers, ","), "范围外") { + t.Fatalf("unscoped options: %+v %v", options, err) + } + q := operationQuery() + q["customer"] = []string{"客户甲", "客户乙"} + q["department"] = []string{"一部"} + q["responsiblePerson"] = []string{"李", "张"} + q.Set("scopeVins", rows[3].VIN) + result, err := service.OperationCompliance(ctx, q) + if err != nil || result.Total != 2 { + t.Fatalf("OR/AND failed: %+v %v", result, err) + } + if strings.Contains(store.query.Get("vins"), rows[3].VIN) || strings.Contains(store.query.Get("vins"), rows[2].VIN) { + t.Fatal("selected values were treated as fuzzy authorization or client scope trusted") + } + p.MenuKeys = []string{"statistics"} + if _, err = service.OperationCompliance(WithPrincipal(context.Background(), p), q); err == nil { + t.Fatal("statistics-only principal gained new permission") + } +} + +func TestOperationComplianceHistoricalBoundsAndMissingGrant(t *testing.T) { + vin := "LNX00000000000001" + zone := time.FixedZone("Asia/Shanghai", 8*3600) + end := time.Date(2026, 9, 4, 12, 0, 0, 0, zone) + p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 9, 1, 12, 0, 0, 0, zone), ValidTo: &end}}} + store := &operationTestStore{MockStore: NewMockStore(), roster: []CustomerVehicleAssignment{{VIN: vin}}, totals: []OperationMileageTotal{{VIN: vin, Meters: operationMeters(0), ValidDays: 2}}} + result, err := NewService(store).OperationCompliance(WithPrincipal(context.Background(), p), operationQuery()) + if err != nil { + t.Fatal(err) + } + row := result.Items[0] + if row.Status != "insufficient" || row.AuthorizedDays != 2 || row.ExpectedDays != 3 || row.MissingDays != 0 { + t.Fatalf("partial authorization treated as complete zero: %+v", row) + } + var starts map[string]string + json.Unmarshal([]byte(store.query.Get("scopeVehicleFrom")), &starts) + if starts[vin] != "2026-09-02" { + t.Fatal("partial first day exposed") + } + p.VehicleGrants[0].ValidTo = func() *time.Time { v := time.Date(2026, 9, 3, 12, 0, 0, 0, zone); return &v }() + _, last, days := operationAuthorizedBounds(p, vin, "2026-09-01", "2026-09-03") + if last != "2026-09-02" || days != 1 { + t.Fatal("partial last day exposed") + } + p.VehicleGrants = nil + store.reads = 0 + result, err = NewService(store).OperationCompliance(WithPrincipal(context.Background(), p), operationQuery()) + if err != nil || store.reads != 0 || result.Items[0].AuthorizedDays != 0 || result.Items[0].MileageKm != nil { + t.Fatal("unverifiable history grant reached data store") + } +} + +func TestOperationComplianceTargetValidationAndSQL(t *testing.T) { + for _, raw := range []string{"", "0", "0.000", "-1", "NaN", "Infinity", "1e3", "0.0001", "9999999999999999"} { + if _, err := parseOperationTarget(url.Values{"targetKm": {raw}}); err == nil { + t.Fatalf("accepted invalid target %q", raw) + } + } + for raw, want := range map[string]int64{"3000": 3000000, "4000": 4000000, "0.001": 1, "50.001": 50001} { + got, err := parseOperationTarget(url.Values{"targetKm": {raw}}) + if err != nil || got != want { + t.Fatalf("decimal lost: %s %d %v", raw, got, err) + } + } + q := operationQuery() + q.Set("vins", "LNX00000000000001") + q.Set("scopeVehicleFrom", `{"LNX00000000000001":"2026-09-02"}`) + q.Set("operationVehicleTo", `{"LNX00000000000001":"2026-09-03"}`) + built, err := buildOperationMileageSQL(q) + if err != nil { + t.Fatal(err) + } + for _, text := range []string{"m.protocol = 'JT808'", "COUNT(DISTINCT m.daily_mileage_km)=1", "GROUP BY m.vin,m.stat_date", "MAX(m.daily_mileage_km)<=3000", "km*1000", "m.stat_date <= ?"} { + if !strings.Contains(built.Text, text) { + t.Fatalf("missing semantic guard: %s", text) + } + } + if strings.Contains(built.Text, "LNX00000000000001") { + t.Fatal("VIN embedded in SQL") + } + q.Del("vins") + built, err = buildOperationMileageSQL(q) + if err != nil || !strings.Contains(built.Text, "1=0") { + t.Fatal("empty scope queried every vehicle") + } +} + +func TestOperationComplianceDailyDetailsSourceAndHistoricalScope(t *testing.T) { + vin := "LNX00000000000001" + zone := time.FixedZone("Asia/Shanghai", 8*3600) + p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 9, 1, 12, 0, 0, 0, zone)}}} + store := &operationTestStore{MockStore: NewMockStore(), roster: []CustomerVehicleAssignment{{VIN: vin, Plate: "测试车"}}, days: []OperationMileageDay{ + {VIN: vin, Date: "2026-09-02", Protocol: "GB32960", Meters: operationMeters(0)}, + {VIN: vin, Date: "2026-09-03", Protocol: "YUTONG_MQTT", Meters: operationMeters(100000)}, + {VIN: vin, Date: "2026-09-04", Protocol: "GB32960"}, + }, totals: []OperationMileageTotal{{VIN: vin, Meters: operationMeters(100000), ValidDays: 2, InvalidDays: 1}}} + q := operationQuery() + q.Set("dateTo", "2026-09-05") + q.Set("vin", vin) + q.Set("mileageSource", "odometer") + q.Set("targetKm", "100") + q.Set("vins", "FORGED") + q.Set("scopeVehicleFrom", `{"LNX00000000000001":"2000-01-01"}`) + svc := NewService(store) + ctx := WithPrincipal(context.Background(), p) + detail, err := svc.OperationComplianceDetail(ctx, q) + if err != nil { + t.Fatal(err) + } + if detail.MileageSource != "odometer" || len(detail.Days) != 5 || detail.Vehicle.Status != "passed" || detail.Vehicle.MileageKm == nil || *detail.Vehicle.MileageKm != 100 || detail.Vehicle.ValidDays != 2 || detail.Vehicle.InvalidDays != 1 || detail.Vehicle.MissingDays != 1 || detail.Vehicle.AuthorizedDays != 4 { + t.Fatalf("bad detail: %+v", detail) + } + for i, want := range []string{"restricted", "valid", "valid", "invalid", "missing"} { + if detail.Days[i].Status != want { + t.Fatalf("day %d: %+v", i, detail.Days[i]) + } + } + if detail.Days[1].MileageKm == nil || *detail.Days[1].MileageKm != 0 || detail.Days[0].MileageKm != nil || detail.Days[2].Protocol != "YUTONG_MQTT" { + t.Fatal("zero, missing or protocol lost") + } + if store.query.Get("vins") != vin || store.query.Get("dateFrom") != "2026-09-02" || store.query.Get("mileageSource") != "odometer" || strings.Contains(store.query.Encode(), "FORGED") { + t.Fatalf("scope trusted caller: %+v", store.query) + } + list, err := svc.OperationCompliance(ctx, q) + if err != nil || list.MileageSource != detail.MileageSource || list.Items[0].Status != detail.Vehicle.Status || *list.Items[0].MileageKm != *detail.Vehicle.MileageKm { + t.Fatalf("list/detail differ: %+v %v", list, err) + } + q.Set("vin", "LNX00000000000002") + if _, err = svc.OperationComplianceDetail(ctx, q); err == nil || store.dayReads != 1 { + t.Fatal("unauthorized VIN reached daily store") + } + q.Set("vin", vin) + p.VehicleGrants = nil + detail, err = svc.OperationComplianceDetail(WithPrincipal(context.Background(), p), q) + if err != nil || detail.Vehicle.MileageKm != nil || store.dayReads != 1 { + t.Fatal("missing historical grant reached store") + } + p.MenuKeys = []string{"statistics"} + if _, err = svc.OperationComplianceDetail(WithPrincipal(context.Background(), p), q); err == nil { + t.Fatal("other menu gained daily access") + } +} + +func TestOperationComplianceSourceValidationAndSelection(t *testing.T) { + for _, source := range []string{"all", "GB32960", "gps,odometer", "unknown"} { + q := operationQuery() + q.Set("mileageSource", source) + if _, err := NewService(&operationTestStore{MockStore: NewMockStore()}).OperationCompliance(operationContext(), q); err == nil { + t.Fatalf("accepted source %s", source) + } + } + q := operationQuery() + q.Set("mileageSource", "odometer") + q.Set("vins", "LNX00000000000001") + q.Set("operationVehicleTo", `{}`) + built, err := buildOperationMileageDaysSQL(q) + if err != nil { + t.Fatal(err) + } + for _, guard := range []string{"m.protocol IN ('GB32960','YUTONG_MQTT')", "GROUP BY m.vin,m.stat_date,m.protocol", "PARTITION BY vin,stat_date", "CASE WHEN km IS NULL THEN 1 ELSE 0 END", "CASE WHEN COALESCE(km, 0) > 0 THEN 0 ELSE 1 END", "WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2", "source_rank=1"} { + if !strings.Contains(built.Text, guard) { + t.Fatalf("missing source selection %s", guard) + } + } +} + +func TestOperationComplianceHydrogenUsesCallerScopeAndReconcilesDaily(t *testing.T) { + vin := "LNX00000000000001" + store := &operationTestStore{MockStore: NewMockStore(), roster: []CustomerVehicleAssignment{{VIN: vin}}, + totals: []OperationMileageTotal{{VIN: vin, Meters: operationMeters(1500125), ValidDays: 2}}, + days: []OperationMileageDay{{VIN: vin, Date: "2026-09-02", Protocol: "GZEV", Meters: operationMeters(0)}, {VIN: vin, Date: "2026-09-03", Protocol: "GZEV", Meters: operationMeters(1500125)}}, + } + p := Principal{Role: "customer", UserType: "customer", MenuKeys: []string{operationComplianceMenu}, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 9, 1, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))}}} + ctx := WithPrincipal(context.Background(), p) + q := operationQuery() + q.Set("mileageSource", "hydrogen") + q.Set("targetKm", "1500.125") + q.Set("vins", "FORGED") + q.Set("scopeVehicleFrom", `{"LNX00000000000001":"2000-01-01"}`) + svc := NewService(store) + list, err := svc.OperationCompliance(ctx, q) + if err != nil || list.MileageSource != "hydrogen" || list.Items[0].Status != "passed" || !list.Items[0].Incomplete { + t.Fatalf("hydrogen threshold or coverage: %+v %v", list, err) + } + q.Set("vin", vin) + detail, err := svc.OperationComplianceDetail(ctx, q) + if err != nil || detail.MileageSource != "hydrogen" || *detail.Vehicle.MileageKm != *list.Items[0].MileageKm || detail.Vehicle.Status != list.Items[0].Status { + t.Fatalf("hydrogen list/detail differ: %+v %v", detail, err) + } + if detail.Days[0].Status != "restricted" || detail.Days[0].MileageKm != nil || *detail.Days[1].MileageKm != 0 || detail.Days[2].Protocol != "GZEV" { + t.Fatalf("hydrogen authorization, zero or source lost: %+v", detail.Days) + } + if store.query.Get("vins") != vin || store.query.Get("dateFrom") != "2026-09-02" || store.query.Get("mileageSource") != "hydrogen" || strings.Contains(store.query.Encode(), "FORGED") { + t.Fatalf("hydrogen trusted client scope: %+v", store.query) + } + q.Set("dateTo", "2026-09-04") + detail, err = svc.OperationComplianceDetail(ctx, q) + if err != nil || detail.Days[3].MileageKm != nil || detail.Days[3].Reason != "暂无纯氢日里程,不补为零" { + t.Fatalf("missing hydrogen became zero: %+v %v", detail, err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/principal.go b/vehicle-data-platform/apps/api/internal/platform/principal.go index 9591926b..4c07094d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/principal.go +++ b/vehicle-data-platform/apps/api/internal/platform/principal.go @@ -13,25 +13,31 @@ type VehicleGrant struct { } type Principal struct { - SubjectID string `json:"subjectId,omitempty"` - SessionID string `json:"-"` - Name string `json:"name"` - Username string `json:"username,omitempty"` - Role string `json:"role"` - UserType string `json:"userType"` - CustomerRef string `json:"customerRef,omitempty"` - TenantRef string `json:"tenantRef,omitempty"` - AuthProvider string `json:"authProvider"` - MenuKeys []string `json:"menuKeys"` - VehicleVINs []string `json:"-"` - VehicleGrants []VehicleGrant `json:"-"` - VehicleCount int `json:"vehicleCount"` - BusinessScopeLevel string `json:"businessScopeLevel,omitempty"` - DepartmentIDs []string `json:"departmentIds,omitempty"` - ResponsibleUserID string `json:"responsibleUserId,omitempty"` + VehicleScopeSource string `json:"vehicleScopeSource,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + DataSource string `json:"dataSource,omitempty"` + AuthorizationStatus string `json:"authorizationStatus,omitempty"` + PermissionKeys []string `json:"-"` + SubjectID string `json:"subjectId,omitempty"` + SessionID string `json:"-"` + Name string `json:"name"` + Username string `json:"username,omitempty"` + Role string `json:"role"` + UserType string `json:"userType"` + CustomerRef string `json:"customerRef,omitempty"` + TenantRef string `json:"tenantRef,omitempty"` + AuthProvider string `json:"authProvider"` + MenuKeys []string `json:"menuKeys"` + VehicleVINs []string `json:"-"` + VehicleGrants []VehicleGrant `json:"-"` + VehicleCount int `json:"vehicleCount"` + BusinessScopeLevel string `json:"businessScopeLevel,omitempty"` + DepartmentIDs []string `json:"departmentIds,omitempty"` + ResponsibleUserID string `json:"responsibleUserId,omitempty"` } func (p Principal) Clone() Principal { + p.PermissionKeys = append([]string(nil), p.PermissionKeys...) p.MenuKeys = append([]string(nil), p.MenuKeys...) p.VehicleVINs = append([]string(nil), p.VehicleVINs...) p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...) @@ -41,6 +47,9 @@ func (p Principal) Clone() Principal { } func (p Principal) CanMenu(key string) bool { + if p.AuthorizationStatus == "loading" { + return false + } for _, value := range p.MenuKeys { if value == key { return true @@ -55,12 +64,31 @@ func (p Principal) CanMenu(key string) bool { return false } +func (p Principal) HasVehicleScope() bool { + return p.UserType == "customer" || p.VehicleScopeSource == "oneos-vehicle-query" +} + +// Vehicle-query authorization grants history for currently visible VINs. It +// carries no assignment dates; existing local/contract time grants stay intact. +func (p Principal) HasHistoryTimeScope() bool { + return p.HasVehicleScope() && p.VehicleScopeSource != "oneos-vehicle-query" +} + func (p Principal) CanVIN(vin string) bool { - if p.UserType != "customer" { + if p.AuthorizationStatus == "loading" { + return false + } + if !p.HasVehicleScope() { return true } for _, allowed := range p.VehicleVINs { if allowed == vin { + if p.AuthProvider == "oneos" { + if grant, ok := p.VehicleGrant(vin); ok && !grant.ValidFrom.IsZero() { + now := time.Now() + return !grant.ValidFrom.After(now) && (grant.ValidTo == nil || grant.ValidTo.After(now)) + } + } return true } } diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 71d90459..fad8f44d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -387,6 +387,7 @@ func (s *ProductionStore) coverageMissingSourceStats(ctx context.Context, query func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { built := buildVehicleRealtimeSQL(query) + inlineEnergy := query.Get("includeEnergy") == "1" && query.Get("sort") == "hydrogenPressureMpa" total := 0 if err := s.db.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&total); err != nil { return Page[VehicleRealtimeRow]{}, err @@ -412,9 +413,16 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) var todayMileageAvailable int var reportIntervalMs int64 var todayMileage string - if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &locationAvailable, &longitude, &latitude, &speedAvailable, &speed, &socAvailable, &soc, &mileageAvailable, &mileage, &todayMileageAvailable, &todayMileage, &row.LastSeen, &reportIntervalMs); err != nil { + var energyPayload string + var tankCapacity float64 + scanArgs := []any{&row.VIN, &row.Plate, &row.Phone, &row.OEM, &protocols, &onlineProtocols, &row.SourceCount, &row.OnlineSourceCount, &online, &row.BindingStatus, &row.PrimaryProtocol, &row.LocationSource, &locationConflict, &conflictDistance, &locationAvailable, &longitude, &latitude, &speedAvailable, &speed, &socAvailable, &soc, &mileageAvailable, &mileage, &todayMileageAvailable, &todayMileage, &row.LastSeen, &reportIntervalMs} + if inlineEnergy { + scanArgs = append(scanArgs, &energyPayload, &tankCapacity) + } + if err := rows.Scan(scanArgs...); err != nil { return Page[VehicleRealtimeRow]{}, err } + row.VehicleNumber = vehicleNumber(row.VIN) row.Protocols = splitCSV(protocols) row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, splitCSV(onlineProtocols), row.LastSeen) row.Online = online == 1 @@ -440,11 +448,22 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values) if reportIntervalMs > 0 { row.ReportIntervalMs = &reportIntervalMs } + if inlineEnergy { + applyMonitorEnergyWithCapacity(&row, parsedFieldsFromString(energyPayload), tankCapacity) + } items = append(items, row) } if err := rows.Err(); err != nil { return Page[VehicleRealtimeRow]{}, err } + if err := rows.Close(); err != nil { + return Page[VehicleRealtimeRow]{}, err + } + if query.Get("includeEnergy") == "1" && !inlineEnergy { + if err := s.populateMonitorEnergy(ctx, items); err != nil { + return Page[VehicleRealtimeRow]{}, err + } + } limit, offset := buildLimitOffset(query) return Page[VehicleRealtimeRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil } @@ -621,7 +640,9 @@ func isTDengineTableNotExist(err error) bool { return false } message := strings.ToLower(err.Error()) - return strings.Contains(message, "table does not exist") || strings.Contains(message, "fail to get table info") + // "Fail to get table info" also wraps connection and other metadata errors. + // Only an explicit missing table can safely become an empty history result. + return strings.Contains(message, "table does not exist") } func (s *ProductionStore) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) { @@ -1367,6 +1388,7 @@ func hydrogenRatePer100Km(consumptionKg, dailyMileageKm float64, dataDays int) * func buildQualityIssueWhere(query url.Values) (string, []any) { where := []string{"1 = 1"} args := []any{} + where, args = appendVINListFilter(where, args, "q.vin", query.Get("scopeVins")) if vin := strings.TrimSpace(query.Get("vin")); vin != "" { where = append(where, "q.vin = ?") args = append(args, vin) diff --git a/vehicle-data-platform/apps/api/internal/platform/profile.go b/vehicle-data-platform/apps/api/internal/platform/profile.go index fdfbacd6..dbe4cbcc 100644 --- a/vehicle-data-platform/apps/api/internal/platform/profile.go +++ b/vehicle-data-platform/apps/api/internal/platform/profile.go @@ -50,6 +50,9 @@ func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfil } func (s *Service) SaveVehicleProfile(ctx context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) { + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return VehicleProfile{}, err + } vin = strings.TrimSpace(vin) if err := validateProfileVIN(vin); err != nil { return VehicleProfile{}, err @@ -85,6 +88,11 @@ func (s *Service) SyncVehicleProfiles(ctx context.Context, request VehicleProfil if err := validateVehicleProfileSyncRequest(request); err != nil { return VehicleProfileSyncResult{}, err } + for _, item := range request.Items { + if err := authorizeVehicleVIN(ctx, item.VIN); err != nil { + return VehicleProfileSyncResult{}, err + } + } return store.SyncVehicleProfiles(ctx, request) } diff --git a/vehicle-data-platform/apps/api/internal/platform/reconciliation.go b/vehicle-data-platform/apps/api/internal/platform/reconciliation.go index fb796cd3..b2c3f913 100644 --- a/vehicle-data-platform/apps/api/internal/platform/reconciliation.go +++ b/vehicle-data-platform/apps/api/internal/platform/reconciliation.go @@ -25,6 +25,9 @@ func (s *Service) reconciliationStore() (ReconciliationStore, error) { } func (s *Service) ReconciliationSummary(ctx context.Context, days int) (ReconciliationSummary, error) { + if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() { + return s.scopedReconciliationSummary(ctx) + } store, err := s.reconciliationStore() if err != nil { return ReconciliationSummary{}, err @@ -36,6 +39,7 @@ func (s *Service) ReconciliationSummary(ctx context.Context, days int) (Reconcil } func (s *Service) ReconciliationIssues(ctx context.Context, query ReconciliationQuery) (Page[ReconciliationIssue], error) { + query.AllowedVINs = principalVehicleVINScope(ctx) store, err := s.reconciliationStore() if err != nil { return Page[ReconciliationIssue]{}, err @@ -222,6 +226,11 @@ func (s *Service) SetReconciliationIssueArchived(ctx context.Context, id string, if err != nil { return ReconciliationIssue{}, err } + if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() { + if _, err := s.ReconciliationIssue(ctx, id); err != nil { + return ReconciliationIssue{}, err + } + } return store.SetReconciliationIssueArchived(ctx, id, archived, normalized) } @@ -276,6 +285,11 @@ func (s *Service) AssignReconciliationIssue(ctx context.Context, id string, requ if err != nil { return ReconciliationIssue{}, err } + if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() { + if _, err := s.ReconciliationIssue(ctx, id); err != nil { + return ReconciliationIssue{}, err + } + } return store.AssignReconciliationIssue(ctx, id, request) } @@ -319,7 +333,14 @@ func (s *Service) ReconciliationIssue(ctx context.Context, id string) (Reconcili if err != nil { return ReconciliationIssue{}, err } - return store.ReconciliationIssue(ctx, id) + item, err := store.ReconciliationIssue(ctx, id) + if err != nil { + return item, err + } + if err = authorizeVehicleVIN(ctx, item.VIN); err != nil { + return ReconciliationIssue{}, err + } + return item, nil } func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, request ReconciliationActionRequest) (ReconciliationIssue, error) { @@ -332,6 +353,11 @@ func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, requ if err != nil { return ReconciliationIssue{}, err } + if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() { + if _, err := s.ReconciliationIssue(ctx, id); err != nil { + return ReconciliationIssue{}, err + } + } return store.UpdateReconciliationIssue(ctx, id, request) } @@ -397,3 +423,62 @@ func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEva } return store.EvaluateReconciliation(ctx) } + +// Historical run totals have no VIN dimension. Scoped users receive current +// issue totals only, rather than another user's fleet-wide trend. +func (s *Service) scopedReconciliationSummary(ctx context.Context) (ReconciliationSummary, error) { + if store, ok := s.store.(interface { + ScopedReconciliationSummary(context.Context) (ReconciliationSummary, error) + }); ok { + return store.ScopedReconciliationSummary(ctx) + } + out := ReconciliationSummary{ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{}, Trend: []ReconciliationTrendPoint{}, AsOf: time.Now().Format(time.RFC3339)} + rules, severities := map[string]int{}, map[string]int{} + for _, scope := range []string{"current", "archived"} { + for offset := 0; ; offset += 200 { + page, err := s.ReconciliationIssues(ctx, ReconciliationQuery{Scope: scope, Limit: 200, Offset: offset}) + if err != nil { + return out, err + } + if page.Total > reconciliationFindingLimit { + return out, clientError{Code: "SCOPE_TOO_LARGE", Message: "授权范围内差异过多,请缩小查询范围"} + } + for _, item := range page.Items { + if scope == "archived" { + out.Archived++ + continue + } + out.Current++ + active := item.Status == "pending" || item.Status == "confirmed_source_a" || item.Status == "confirmed_source_b" + switch item.Status { + case "pending": + out.Pending++ + case "confirmed_source_a", "confirmed_source_b": + out.Confirmed++ + case "recovered": + out.Recovered++ + } + if active { + out.Active++ + rules[item.RuleCode]++ + severities[item.Severity]++ + if due, ok := parseTrackRequestTime(item.DueAt); ok && due.Before(time.Now()) { + out.OverSLA++ + } + } + } + if offset+len(page.Items) >= page.Total || len(page.Items) == 0 { + break + } + } + } + for name, count := range rules { + out.ByRule = append(out.ByRule, ReconciliationBucket{Name: name, Count: count}) + } + for name, count := range severities { + out.BySeverity = append(out.BySeverity, ReconciliationBucket{Name: name, Count: count}) + } + sort.Slice(out.ByRule, func(i, j int) bool { return out.ByRule[i].Name < out.ByRule[j].Name }) + sort.Slice(out.BySeverity, func(i, j int) bool { return out.BySeverity[i].Name < out.BySeverity[j].Name }) + return out, nil +} diff --git a/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go b/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go index d67fab3f..9d03b927 100644 --- a/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/reconciliation_store.go @@ -392,6 +392,13 @@ WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL` func buildReconciliationWhere(query ReconciliationQuery) (string, []any) { where := []string{"i.archived_at IS NULL"} args := []any{} + if query.AllowedVINs != nil { + if len(query.AllowedVINs) == 0 { + where = append(where, "1=0") + } else { + where, args = appendVINListFilter(where, args, "i.vin", strings.Join(query.AllowedVINs, ",")) + } + } if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") { where[0] = "i.archived_at IS NOT NULL" } @@ -838,3 +845,57 @@ GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column) } return out, rows.Err() } + +// Aggregate in SQL before pagination; walking all issue pages made scoped +// operations dashboards exceed the browser's request deadline. +func (s *ProductionStore) ScopedReconciliationSummary(ctx context.Context) (ReconciliationSummary, error) { + if err := s.ensureReconciliationSchema(ctx); err != nil { + return ReconciliationSummary{}, err + } + vins := principalVehicleVINScope(ctx) + where, args := []string{"1=1"}, []any{} + if len(vins) == 0 { + where = append(where, "1=0") + } else { + where, args = appendVINListFilter(where, args, "vin", strings.Join(vins, ",")) + } + filter := strings.Join(where, " AND ") + out := ReconciliationSummary{ByRule: []ReconciliationBucket{}, BySeverity: []ReconciliationBucket{}, Trend: []ReconciliationTrendPoint{}, AsOf: time.Now().Format(time.RFC3339)} + err := s.db.QueryRowContext(ctx, `SELECT + COALESCE(SUM(archived_at IS NULL),0),COALESCE(SUM(archived_at IS NOT NULL),0), + COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')),0), + COALESCE(SUM(archived_at IS NULL AND status='pending'),0), + COALESCE(SUM(archived_at IS NULL AND status IN ('confirmed_source_a','confirmed_source_b')),0), + COALESCE(SUM(archived_at IS NULL AND status='recovered'),0), + COALESCE(SUM(archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b') AND COALESCE(due_at,DATE_ADD(first_seen_at,INTERVAL 24 HOUR)) len(vehicles.Items), - AsOf: time.Now().UTC().Format(time.RFC3339), + result := MonitorSummary{Truncated: vehicles.Total > len(vehicles.Items), AsOf: time.Now().UTC().Format(time.RFC3339)} + principal, scoped := PrincipalFromContext(ctx) + if !scoped || !principal.HasVehicleScope() { + var dashboard DashboardSummary + var err error + if store, ok := s.store.(monitorActivityStore); ok { + dashboard, err = store.MonitorActivitySummary(ctx) + } else { + dashboard, err = s.store.DashboardSummary(ctx) + } + if err != nil { + return MonitorSummary{}, err + } + result.ActiveToday = dashboard.ActiveToday + result.FrameToday = dashboard.FrameToday + result.ActivityDataAvailable = true } activeAlertVINs := map[string]struct{}{} if store, ok := s.store.(monitorAlertStore); ok { @@ -860,6 +861,15 @@ func (s *Service) SourceReadiness(ctx context.Context) (SourceReadinessPlan, err } func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { + // A partial fleet number may also match a plate/phone. Do not resolve that + // search to the first vehicle before the list's OR predicate can run. + if strings.TrimSpace(query.Get("vin")) == "" && len(vehicleNumberVINs(vehicleSearchKeywords(query), false)) > 0 { + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return Page[VehicleRealtimeRow]{}, err + } + return s.store.VehicleRealtime(ctx, scopedQuery) + } resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil { return Page[VehicleRealtimeRow]{}, err @@ -870,7 +880,7 @@ func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[V func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) { keyword = strings.TrimSpace(keyword) protocol = strings.TrimSpace(protocol) - if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() { resolvedScopeVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol) if err != nil { return VehicleServiceOverview{}, err @@ -965,7 +975,7 @@ func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOver func principalVehicleVINScope(ctx context.Context) []string { principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasVehicleScope() { return nil } scope := make([]string, 0, len(principal.VehicleVINs)) @@ -979,7 +989,7 @@ func principalVehicleVINScope(ctx context.Context) []string { func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOverview]) Page[VehicleServiceOverview] { principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasVehicleScope() { return page } for index := range page.Items { @@ -992,6 +1002,17 @@ func restrictVehicleOverviewPage(ctx context.Context, page Page[VehicleServiceOv } func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { + return s.vehicleDetail(ctx, vin, protocol, true) +} + +// VehicleLiveDetail loads the single-vehicle workspace without historical +// previews. Those previews belong to the history pages and must not make live +// identity, location, profile and business data depend on historical storage. +func (s *Service) VehicleLiveDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) { + return s.vehicleDetail(ctx, vin, protocol, false) +} + +func (s *Service) vehicleDetail(ctx context.Context, vin string, protocol string, includeHistory bool) (VehicleDetail, error) { keyword := strings.TrimSpace(vin) protocol = strings.TrimSpace(protocol) vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}} @@ -1028,6 +1049,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string return VehicleDetail{}, err } return VehicleDetail{ + HistoryIncluded: includeHistory, VIN: "", LookupKey: keyword, LookupResolved: false, @@ -1080,24 +1102,31 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string if err != nil { return VehicleDetail{}, err } - history, err := s.store.HistoryLocationsFromTDengine(ctx, historyQuery) - if err != nil { - return VehicleDetail{}, err - } - raw, err := s.vehicleDetailRawPreview(ctx, rawQuery) - if err != nil { - return VehicleDetail{}, err - } - mileage, err := s.store.DailyMileage(ctx, mileageQuery) - if err != nil { - return VehicleDetail{}, err + history := Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20} + raw := Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10} + mileage := Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20} + if includeHistory { + history, err = s.store.HistoryLocationsFromTDengine(ctx, historyQuery) + if err != nil { + return VehicleDetail{}, err + } + raw, err = s.vehicleDetailRawPreview(ctx, rawQuery) + if err != nil { + return VehicleDetail{}, err + } + mileage, err = s.store.DailyMileage(ctx, mileageQuery) + if err != nil { + return VehicleDetail{}, err + } } quality, err := s.store.QualityIssues(ctx, qualityQuery) if err != nil { return VehicleDetail{}, err } observedSourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items) - observedSourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, observedSourceStatus) + if includeHistory { + observedSourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, observedSourceStatus) + } availableSources := sourceNamesForProtocol(observedSourceStatus, protocol) sourceStatus := completeVehicleSourceStatus(observedSourceStatus, protocol) serviceStatus := buildVehicleServiceStatus(true, sourceStatus) @@ -1125,6 +1154,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string } } return VehicleDetail{ + HistoryIncluded: includeHistory, VIN: resolvedVIN, LookupKey: keyword, LookupResolved: resolvedVIN != "", @@ -1150,10 +1180,11 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string } const vehicleDetailRawProjectionLookback = 24 * time.Hour +const vehicleDetailRawPreviewTimeout = time.Second type vehicleDetailRawPreviewResult struct { - anchor RawFrameRow - page Page[RawFrameRow] + index int + page Page[RawFrameRow] } // vehicleDetailRawPreview preserves the latest-ever semantics without issuing @@ -1187,32 +1218,47 @@ func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQue return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil } + previewCtx, cancel := context.WithTimeout(ctx, vehicleDetailRawPreviewTimeout) + defer cancel() results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items)) - for _, anchor := range anchors.Items { - anchor := anchor + previews := make([][]RawFrameRow, len(anchors.Items)) + for index, anchor := range anchors.Items { + previews[index] = []RawFrameRow{anchor} go func() { bounded, valid := boundedRawPreviewQuery(query, anchor) if !valid { - results <- vehicleDetailRawPreviewResult{anchor: anchor} + results <- vehicleDetailRawPreviewResult{index: index} return } - page, queryErr := s.store.RawFrames(ctx, bounded) + page, queryErr := s.store.RawFrames(previewCtx, bounded) if queryErr != nil { - results <- vehicleDetailRawPreviewResult{anchor: anchor} + results <- vehicleDetailRawPreviewResult{index: index} return } - results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page} + results <- vehicleDetailRawPreviewResult{index: index, page: page} }() } - items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit) + // Some historical drivers return after their context expires. Keep a + // buffered result channel and select on the budget so a slow driver cannot + // prevent the already-read durable projections from reaching the caller. +collect: for range anchors.Items { - result := <-results - if len(result.page.Items) == 0 { - items = append(items, result.anchor) - continue + select { + case result := <-results: + if len(result.page.Items) > 0 { + previews[result.index] = result.page.Items + } + case <-previewCtx.Done(): + if ctx.Err() != nil { + return Page[RawFrameRow]{}, ctx.Err() + } + break collect } - items = append(items, result.page.Items...) + } + items := make([]RawFrameRow, 0, len(anchors.Items)) + for _, preview := range previews { + items = append(items, preview...) } items = newestRawFrames(items, query.Limit) limit := query.Limit @@ -1361,7 +1407,7 @@ func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date st func authorizeVehicleDailyEvidenceDate(ctx context.Context, vin string, date string) error { principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasHistoryTimeScope() { return nil } grant, ok := principal.VehicleGrant(vin) @@ -4287,7 +4333,14 @@ func canAccessHistoryExport(principal Principal, job *HistoryExportJob) bool { if job == nil { return false } - if principal.Role == "admin" || principal.UserType == "admin" { + if principal.HasVehicleScope() { + for _, vin := range job.VehicleVINs { + if !principal.CanVIN(vin) { + return false + } + } + } + if (principal.Role == "admin" || principal.UserType == "admin") && !principal.HasVehicleScope() { return true } ownerID := historyExportOwnerID(principal) @@ -5841,11 +5894,19 @@ func offlineDurationMinutes(online bool, lastSeen string, now time.Time) *int { } func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) { - return s.store.QualitySummary(ctx, query) + q, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return QualitySummary{}, err + } + return s.store.QualitySummary(ctx, q) } func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) { - return s.store.QualityIssues(ctx, query) + q, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return Page[QualityIssueRow]{}, err + } + return s.store.QualityIssues(ctx, q) } func (s *Service) QualityNotificationPlan(ctx context.Context, query url.Values) (QualityNotificationPlan, error) { @@ -6127,7 +6188,7 @@ func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (ur return nil, err } if resolvedVIN != "" { - if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" && !principal.CanVIN(resolvedVIN) { + if principal, ok := PrincipalFromContext(ctx); ok && principal.HasVehicleScope() && !principal.CanVIN(resolvedVIN) { return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"} } resolved.Set("vin", resolvedVIN) @@ -6162,11 +6223,16 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Values, error) { next := cloneValues(query) principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasVehicleScope() { return next, nil } allowed := make(map[string]bool, len(principal.VehicleVINs)) + activeVINs := make([]string, 0, len(principal.VehicleVINs)) for _, vin := range principal.VehicleVINs { + if !principal.CanVIN(vin) { + continue + } + activeVINs = append(activeVINs, vin) allowed[strings.ToUpper(strings.TrimSpace(vin))] = true } for _, value := range []string{next.Get("vin"), next.Get("vins")} { @@ -6177,10 +6243,14 @@ func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Valu } } } - if len(principal.VehicleVINs) == 0 { + if len(activeVINs) == 0 { next.Set("scopeVins", "__NO_VEHICLE_SCOPE__") } else { - next.Set("scopeVins", strings.Join(principal.VehicleVINs, ",")) + next.Set("scopeVins", strings.Join(activeVINs, ",")) + } + if !principal.HasHistoryTimeScope() { + next.Del("scopeVehicleFrom") + return next, nil } validFromByVIN := make(map[string]string, len(principal.VehicleGrants)) shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) @@ -6205,9 +6275,12 @@ func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Valu } func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.Values) (url.Values, error) { + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return nil, err + } next := cloneValues(query) principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasHistoryTimeScope() { return next, nil } grant, ok := principal.VehicleGrant(vin) @@ -6251,7 +6324,7 @@ func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.V func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values) error { principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasHistoryTimeScope() { return nil } requested := make([]string, 0) @@ -6277,7 +6350,7 @@ func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values) func authorizeVehicleVIN(ctx context.Context, vin string) error { principal, ok := PrincipalFromContext(ctx) - if !ok || principal.UserType != "customer" { + if !ok || !principal.HasVehicleScope() { return nil } vin = strings.ToUpper(strings.TrimSpace(vin)) diff --git a/vehicle-data-platform/apps/api/internal/platform/source_policy.go b/vehicle-data-platform/apps/api/internal/platform/source_policy.go index 390e1d37..2d76de8d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/source_policy.go +++ b/vehicle-data-platform/apps/api/internal/platform/source_policy.go @@ -128,6 +128,9 @@ func (s *Service) VehicleSourceDiagnostic(ctx context.Context, vin string) (Vehi } func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, update VehicleSourcePolicyUpdate) (VehicleSourceDiagnostic, error) { + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return VehicleSourceDiagnostic{}, err + } if err := authorizeInternalOperations(ctx, true); err != nil { return VehicleSourceDiagnostic{}, err } diff --git a/vehicle-data-platform/apps/api/internal/platform/vehicle_live_detail_test.go b/vehicle-data-platform/apps/api/internal/platform/vehicle_live_detail_test.go new file mode 100644 index 00000000..91587afc --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/vehicle_live_detail_test.go @@ -0,0 +1,158 @@ +package platform + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +type unavailableDetailHistoryStore struct { + *MockStore + historyCalls int +} + +func (s *unavailableDetailHistoryStore) HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error) { + s.historyCalls++ + return Page[HistoryLocationRow]{}, errors.New("historical storage unavailable") +} + +func (s *unavailableDetailHistoryStore) RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error) { + s.historyCalls++ + return Page[RawFrameRow]{}, errors.New("historical storage unavailable") +} + +func (s *unavailableDetailHistoryStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) { + s.historyCalls++ + return Page[DailyMileageRow]{}, errors.New("historical preview unavailable") +} + +func TestVehicleLiveDetailSurvivesHistoricalStorageFailure(t *testing.T) { + store := &unavailableDetailHistoryStore{MockStore: NewMockStore()} + service := NewService(store) + detail, err := service.VehicleLiveDetail(exportAdminContext(), "粤AG18312", "JT808") + if err != nil { + t.Fatal(err) + } + if store.historyCalls != 0 || detail.HistoryIncluded { + t.Fatalf("live detail queried historical previews: calls=%d detail=%+v", store.historyCalls, detail) + } + if !detail.LookupResolved || detail.VIN != "LB9A32A24R0LS1426" || detail.Identity == nil || detail.RealtimeSummary == nil || detail.Profile == nil || detail.BusinessRelation == nil { + t.Fatalf("live vehicle workspace lost required data: %+v", detail) + } + if len(detail.Sources) != 1 || detail.Sources[0] != "JT808" || len(detail.Realtime) == 0 { + t.Fatalf("protocol filter or live sources lost: %+v", detail) + } + if detail.History.Items == nil || detail.Raw.Items == nil || detail.Mileage.Items == nil { + t.Fatal("omitted previews must still serialize as arrays") + } + if _, err := service.VehicleDetail(exportAdminContext(), detail.VIN, ""); err == nil || store.historyCalls == 0 { + t.Fatal("legacy full-detail endpoint no longer queries historical data") + } +} + +func TestHandlerLiveDetailKeepsVehicleAuthorization(t *testing.T) { + const vin = "LB9A32A24R0LS1426" + for _, allowed := range []bool{true, false} { + store := &unavailableDetailHistoryStore{MockStore: NewMockStore()} + principal := Principal{Role: "admin", UserType: "admin", AuthProvider: "oneos", VehicleScopeSource: "oneos-vehicle-query", VehicleVINs: []string{}} + if allowed { + principal.VehicleVINs = []string{vin} + } + request := httptest.NewRequest(http.MethodGet, "/api/vehicle-service?keyword="+vin+"&includeHistory=0", nil) + request = request.WithContext(WithPrincipal(request.Context(), principal)) + response := httptest.NewRecorder() + NewHandler(NewService(store)).ServeHTTP(response, request) + if !allowed { + if response.Code != http.StatusForbidden { + t.Fatalf("live mode bypassed OneOS scope: %d %s", response.Code, response.Body.String()) + } + continue + } + var body struct { + Data VehicleDetail `json:"data"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil || response.Code != http.StatusOK || !body.Data.LookupResolved || body.Data.HistoryIncluded || store.historyCalls != 0 { + t.Fatalf("live handler failed: %d %s err=%v", response.Code, response.Body.String(), err) + } + } +} + +// Historical drivers can keep waiting even after cancellation. The caller +// must still receive the durable latest frame within its preview budget. +type delayedDetailRawStore struct { + *MockStore + release chan struct{} +} + +func (s *delayedDetailRawStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { + rows := []RawFrameRow{} + for _, protocol := range []string{"GB32960", "JT808"} { + if query.Protocol == "" || query.Protocol == protocol { + rows = append(rows, RawFrameRow{ID: "snapshot-" + protocol, VIN: "LB9A32A24R0LS1426", Protocol: protocol, FrameType: "realtime_projection", ServerTime: "2026-10-16T21:00:00+08:00", ParsedFields: map[string]any{"speed": 42}}) + } + } + return Page[RawFrameRow]{Items: rows, Total: len(rows)}, nil +} + +func (s *delayedDetailRawStore) RawFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { + if query.Protocol == "GB32960" { + <-s.release + return Page[RawFrameRow]{}, errors.New("slow historical read failed") + } + return Page[RawFrameRow]{Items: []RawFrameRow{{ID: "fast-jt", VIN: query.VIN, Protocol: "JT808", FrameType: "raw", ServerTime: "2026-10-16T21:00:00+08:00", ParsedFields: map[string]any{"speed": 43}}}}, nil +} + +func TestVehicleRawPreviewTimesOutToDurableProjection(t *testing.T) { + store := &delayedDetailRawStore{MockStore: NewMockStore(), release: make(chan struct{})} + defer close(store.release) + started := time.Now() + page, err := NewService(store).vehicleDetailRawPreview(context.Background(), RawFrameQuery{VIN: "LB9A32A24R0LS1426", IncludeFields: true, Limit: 10, SkipCount: true}) + if err != nil || time.Since(started) > 2*time.Second { + t.Fatalf("historical storage blocked projection fallback: %s err=%v", time.Since(started), err) + } + if len(page.Items) != 2 { + t.Fatalf("preview lost fast history or slow-source projection: %+v", page) + } + ids := map[string]bool{} + for _, row := range page.Items { + ids[row.ID] = true + } + if !ids["snapshot-GB32960"] || !ids["fast-jt"] { + t.Fatalf("fallback replaced completed reads or lost a source: %+v", page) + } +} + +func TestLatestTelemetrySurvivesUnresponsiveHistoricalDriver(t *testing.T) { + store := &delayedDetailRawStore{MockStore: NewMockStore(), release: make(chan struct{})} + defer close(store.release) + started := time.Now() + result, err := NewService(store).LatestTelemetry(exportAdminContext(), "LB9A32A24R0LS1426") + if err != nil || time.Since(started) > 2*time.Second || len(result.Values) == 0 { + t.Fatalf("latest telemetry blocked by historical storage: elapsed=%s values=%+v err=%v", time.Since(started), result.Values, err) + } + found := false + for _, value := range result.Values { + if value.FrameID == "snapshot-GB32960" && value.Protocol == "GB32960" { + found = true + } + } + if !found { + t.Fatalf("latest durable source was lost: %+v", result.Values) + } +} + +func TestVehicleRawPreviewHonorsCallerCancellation(t *testing.T) { + store := &delayedDetailRawStore{MockStore: NewMockStore(), release: make(chan struct{})} + defer close(store.release) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := NewService(store).vehicleDetailRawPreview(ctx, RawFrameQuery{VIN: "LB9A32A24R0LS1426", Limit: 10}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("caller cancellation was hidden by preview fallback: %v", err) + } +} diff --git a/vehicle-data-platform/apps/api/internal/platform/vehicle_number.go b/vehicle-data-platform/apps/api/internal/platform/vehicle_number.go new file mode 100644 index 00000000..9f9392d1 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/vehicle_number.go @@ -0,0 +1,61 @@ +package platform + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// The user-supplied fleet number registry is bundled with the API release, so +// labels and search always use the same version. VINs remain the identity key. +// Source workbook, sheet and SHA-256 are recorded alongside the imported rows. +// +//go:embed data/vehicle_numbers.json +var vehicleNumberData []byte + +var vehicleNumbers = loadVehicleNumbers(vehicleNumberData) + +func loadVehicleNumbers(data []byte) map[string]string { + var source struct { + Vehicles []struct { + VIN string `json:"vin"` + Number string `json:"number"` + } `json:"vehicles"` + } + if err := json.Unmarshal(data, &source); err != nil { + panic(fmt.Errorf("invalid vehicle number registry: %w", err)) + } + numbers := make(map[string]string, len(source.Vehicles)) + for _, row := range source.Vehicles { + vin, number := strings.ToUpper(strings.TrimSpace(row.VIN)), strings.TrimSpace(row.Number) + if len(vin) != 17 || number == "" || numbers[vin] != "" { + panic("invalid or duplicate VIN in vehicle number registry") + } + numbers[vin] = number + } + return numbers +} + +func vehicleNumber(vin string) string { + return vehicleNumbers[strings.ToUpper(strings.TrimSpace(vin))] +} + +func vehicleNumberVINs(keywords []string, exact bool) []string { + vins := []string{} + for vin, number := range vehicleNumbers { + for _, keyword := range keywords { + keyword = strings.ToUpper(strings.TrimSpace(keyword)) + if keyword == "" { + continue + } + if exact && strings.EqualFold(number, keyword) || !exact && strings.Contains(strings.ToUpper(number), keyword) { + vins = append(vins, vin) + break + } + } + } + sort.Strings(vins) + return vins +} diff --git a/vehicle-data-platform/apps/api/internal/platform/vehicle_number_test.go b/vehicle-data-platform/apps/api/internal/platform/vehicle_number_test.go new file mode 100644 index 00000000..17a6d0f4 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/vehicle_number_test.go @@ -0,0 +1,89 @@ +package platform + +import ( + "context" + "encoding/json" + "net/url" + "reflect" + "slices" + "strings" + "testing" +) + +func TestVehicleNumberRegistryPreservesWorkbookIdentifiers(t *testing.T) { + if len(vehicleNumbers) != 110 || vehicleNumber("LA9GG64L9NBAF4191") != "01Q" || vehicleNumber("LA9GG68L2PBAF4269") != "备用车110Q" { + t.Fatal("workbook identifiers or leading zeros changed") + } + if vehicleNumber("UNKNOWN") != "" { + t.Fatal("unmapped vehicle received a number") + } + encoded, _ := json.Marshal(VehicleRealtimeRow{VIN: "UNKNOWN"}) + if strings.Contains(string(encoded), "vehicleNumber") { + t.Fatal("unmapped number must be omitted") + } +} + +func TestVehicleNumberSearchMatchesPartialAndCaseInsensitiveNumbers(t *testing.T) { + vin := "LA9GG64L9NBAF4191" + if got := vehicleNumberVINs([]string{"01q"}, true); !reflect.DeepEqual(got, []string{vin}) { + t.Fatalf("exact number search: %v", got) + } + if got := vehicleNumberVINs([]string{"1q"}, false); len(got) <= 1 || !slices.Contains(got, vin) { + t.Fatalf("partial number search: %v", got) + } + if got := vehicleNumberVINs([]string{"110q"}, false); !reflect.DeepEqual(got, []string{"LA9GG68L2PBAF4269"}) { + t.Fatalf("backup vehicle search: %v", got) + } + for _, keyword := range []string{"", "%", "_", "unassigned"} { + if len(vehicleNumberVINs([]string{keyword}, false)) != 0 { + t.Fatalf("unexpected match for %q", keyword) + } + } +} + +func TestVehicleNumberSQLKeepsScopeCountAndPagination(t *testing.T) { + for _, key := range []string{"keyword", "keywords"} { + q := buildVehicleRealtimeSQL(url.Values{key: {"01q"}, "scopeVins": {"AUTHORIZED-VIN"}, "limit": {"20"}, "offset": {"20"}, "sort": {"hydrogenPressureMpa"}}) + for _, sql := range []string{q.Text, q.CountText} { + if !strings.Contains(sql, "v.vin IN (?) AND (") || !strings.Contains(sql, " OR v.vin IN (") { + t.Fatalf("number predicate must be inside identity search and under authorization: %s", sql) + } + } + if q.CountArgs[0] != "AUTHORIZED-VIN" || !slices.Contains(q.CountArgs, any("LA9GG64L9NBAF4191")) || !reflect.DeepEqual(q.Args[:len(q.Args)-2], q.CountArgs) { + t.Fatalf("data/count scope differ: %v / %v", q.Args, q.CountArgs) + } + } +} + +func TestVehicleNumberMockSearchNeverExpandsAuthorizedScope(t *testing.T) { + m := NewMockStore() + m.locations = nil + m.vehicles = []VehicleRow{ + {VIN: "LA9GG64L9NBAF4191", Plate: "浙F05388F"}, + {VIN: "UNMAPPED", Plate: "测试车"}, + } + ctx := context.Background() + q := url.Values{"keyword": {"01q"}} + page, err := m.VehicleRealtime(ctx, q) + if err != nil || page.Total != 1 || page.Items[0].VehicleNumber != "01Q" { + t.Fatalf("number search failed: %+v %v", page, err) + } + q.Set("scopeVins", "UNMAPPED") + page, err = m.VehicleRealtime(ctx, q) + if err != nil || page.Total != 0 { + t.Fatalf("number search expanded permission scope: %+v %v", page, err) + } +} + +func TestVehicleNumberServiceKeepsFuzzyNumberAndPlateMatches(t *testing.T) { + m := NewMockStore() + m.locations = nil + m.vehicles = []VehicleRow{ + {VIN: "LA9GG64L9NBAF4191", Plate: "浙F05388F"}, + {VIN: "UNMAPPED", Plate: "测试01车"}, + } + result, err := NewService(m).VehicleRealtime(context.Background(), url.Values{"keyword": {"01"}}) + if err != nil || result.Total != 2 { + t.Fatalf("partial number search was resolved to a single plate match: %+v %v", result, err) + } +} diff --git a/vehicle-data-platform/apps/open-portal/src/catalog.ts b/vehicle-data-platform/apps/open-portal/src/catalog.ts index 7fc0db62..42591a4e 100644 --- a/vehicle-data-platform/apps/open-portal/src/catalog.ts +++ b/vehicle-data-platform/apps/open-portal/src/catalog.ts @@ -70,10 +70,26 @@ export const products: Product[] = [ method: "POST", path: "/api/v1/hydrogen-stations/query", unit: "座" + }, + { + code: "operating_data", + name: "车辆综合运营数据", + description: "批量聚合实时、单日里程与单日用氢,保留各自质量和执行状态。", + version: "v1", + status: "available", + method: "POST", + path: "/api/v1/vehicles/operating-data/query", + unit: "" } ]; export const curlExample = (product: Product) => { + if (product.code === "operating_data") return `curl --request POST \\ + --url https://open.d.lnoneos.com${product.path} \\ + --header 'Authorization: Bearer YOUR_APP_KEY' \\ + --header 'Content-Type: application/json' \\ + --data '{"plateNumbers":["粤A09635F"],"dailyDate":"2026-10-23"}'`; + if (product.code === "realtime_vehicle") return `curl --request POST \\ --url https://open.d.lnoneos.com${product.path} \\ --header 'Authorization: Bearer YOUR_APP_KEY' \\ diff --git a/vehicle-data-platform/apps/web/components.json b/vehicle-data-platform/apps/web/components.json new file mode 100644 index 00000000..91d5f8e0 --- /dev/null +++ b/vehicle-data-platform/apps/web/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/v2/styles/foundation.css", + "baseColor": "neutral", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/vehicle-data-platform/apps/web/index.html b/vehicle-data-platform/apps/web/index.html index 6b01f443..5a959621 100644 --- a/vehicle-data-platform/apps/web/index.html +++ b/vehicle-data-platform/apps/web/index.html @@ -6,7 +6,7 @@ 羚牛车辆数据中台 diff --git a/vehicle-data-platform/apps/web/package.json b/vehicle-data-platform/apps/web/package.json index fba9796f..181a2940 100644 --- a/vehicle-data-platform/apps/web/package.json +++ b/vehicle-data-platform/apps/web/package.json @@ -12,14 +12,24 @@ "@douyinfe/semi-icons": "^2.71.0", "@douyinfe/semi-theme-default": "^2.71.0", "@douyinfe/semi-ui": "^2.71.0", + "@tailwindcss/vite": "^4.3.3", "@tanstack/react-query": "5.101.2", "@vitejs/plugin-react": "^4.3.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "cn": "^0.3.0", "echarts": "^5.6.0", "exceljs": "4.4.0", + "lucide-react": "^1.47.0", "qrcode": "^1.5.4", + "radix-ui": "^1.6.7", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "6.30.1", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0", "vite": "^6.0.0" }, "devDependencies": { diff --git a/vehicle-data-platform/apps/web/pnpm-lock.yaml b/vehicle-data-platform/apps/web/pnpm-lock.yaml index ca54d9ce..34fa8cfb 100644 --- a/vehicle-data-platform/apps/web/pnpm-lock.yaml +++ b/vehicle-data-platform/apps/web/pnpm-lock.yaml @@ -16,22 +16,43 @@ importers: version: 2.101.0 '@douyinfe/semi-ui': specifier: ^2.71.0 - version: 2.101.0(@floating-ui/dom@1.7.6)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.101.0(@floating-ui/dom@1.7.6)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)) '@tanstack/react-query': specifier: 5.101.2 version: 5.101.2(react@18.3.1) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.7.0(vite@6.4.3(@types/node@26.1.1)(sass@1.101.0)) + version: 4.7.0(vite@6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + cn: + specifier: ^0.3.0 + version: 0.3.0 echarts: specifier: ^5.6.0 version: 5.6.0 exceljs: specifier: 4.4.0 version: 4.4.0 + lucide-react: + specifier: ^1.47.0 + version: 1.47.0(react@18.3.1) qrcode: specifier: ^1.5.4 version: 1.5.4 + radix-ui: + specifier: ^1.6.7 + version: 1.6.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -41,9 +62,18 @@ importers: react-router-dom: specifier: 6.30.1 version: 6.30.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tailwind-merge: + specifier: ^3.7.0 + version: 3.7.0 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 vite: specifier: ^6.0.0 - version: 6.4.3(@types/node@26.1.1)(sass@1.101.0) + version: 6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.3 @@ -71,7 +101,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.8 - version: 2.1.9(@types/node@26.1.1)(jsdom@25.0.1)(sass@1.101.0) + version: 2.1.9(@types/node@26.1.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.101.0) packages: @@ -555,12 +585,27 @@ packages: '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -668,6 +713,696 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-accessible-icon@1.1.15': + resolution: {integrity: sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.20': + resolution: {integrity: sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.23': + resolution: {integrity: sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.15': + resolution: {integrity: sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.2.6': + resolution: {integrity: sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.11': + resolution: {integrity: sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.20': + resolution: {integrity: sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.7': + resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.16': + resolution: {integrity: sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.23': + resolution: {integrity: sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.15': + resolution: {integrity: sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.24': + resolution: {integrity: sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.22': + resolution: {integrity: sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.16': + resolution: {integrity: sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.11': + resolution: {integrity: sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.16': + resolution: {integrity: sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.4.7': + resolution: {integrity: sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.18': + resolution: {integrity: sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.7': + resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.15': + resolution: {integrity: sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.4.7': + resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.7': + resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.21': + resolution: {integrity: sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.23': + resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.19': + resolution: {integrity: sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.18': + resolution: {integrity: sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.19': + resolution: {integrity: sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.5': + resolution: {integrity: sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.4': + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@remix-run/router@1.23.0': resolution: {integrity: sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==} engines: {node: '>=14.0.0'} @@ -813,6 +1548,100 @@ packages: cpu: [x64] os: [win32] + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tanstack/query-core@5.101.2': resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} @@ -1163,6 +1992,10 @@ packages: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -1287,6 +2120,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -1297,6 +2133,17 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + cn@0.3.0: + resolution: {integrity: sha512-qRoU0ARrZD3Bbj1wKFftEKxPjSnzTEdFkf22NVhW+zU9D/wajhoP+qUZ5DChhhSOBCH1onV12hrmGqfu1+5Z2Q==} + engines: {node: '>=20'} + hasBin: true + collapse-white-space@2.1.0: resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} @@ -1404,6 +2251,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -1435,6 +2285,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.25.1: + resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} + engines: {node: '>=10.13.0'} + entities@6.0.1: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} @@ -1573,6 +2427,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -1682,6 +2540,10 @@ packages: isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -1717,6 +2579,80 @@ packages: lie@3.3.0: resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + linkifyjs@4.3.3: resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} @@ -1789,6 +2725,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.47.0: + resolution: {integrity: sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2137,6 +3078,19 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + radix-ui@1.6.7: + resolution: {integrity: sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -2158,6 +3112,26 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-resizable@3.2.0: resolution: {integrity: sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==} peerDependencies: @@ -2177,6 +3151,16 @@ packages: peerDependencies: react: '>=16.8' + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react-window@1.8.11: resolution: {integrity: sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==} engines: {node: '>8.0.0'} @@ -2352,6 +3336,16 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.7.0: + resolution: {integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -2412,6 +3406,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2450,6 +3447,26 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -2873,7 +3890,7 @@ snapshots: '@douyinfe/semi-theme-default@2.101.0': {} - '@douyinfe/semi-ui@2.101.0(@floating-ui/dom@1.7.6)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + '@douyinfe/semi-ui@2.101.0(@floating-ui/dom@1.7.6)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@dnd-kit/core': 6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dnd-kit/sortable': 7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) @@ -2888,7 +3905,7 @@ snapshots: '@tiptap/extension-document': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) '@tiptap/extension-hard-break': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) '@tiptap/extension-image': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) - '@tiptap/extension-mention': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)) + '@tiptap/extension-mention': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)) '@tiptap/extension-paragraph': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) '@tiptap/extension-text': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) '@tiptap/extension-text-align': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1)) @@ -3089,13 +4106,33 @@ snapshots: '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 + optional: true - '@floating-ui/utils@0.2.11': {} + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@floating-ui/utils@0.2.11': + optional: true + + '@floating-ui/utils@0.2.12': {} '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -3207,6 +4244,753 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-accessible-icon@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-accordion@1.2.20(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-alert-dialog@1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-arrow@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-aspect-ratio@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-avatar@1.2.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-checkbox@1.3.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-collapsible@1.1.20(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-collection@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-context@1.2.2(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-direction@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-focus-guards@1.1.6(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-form@0.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-label': 2.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-hover-card@1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-id@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-label@2.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-menu@2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-menubar@1.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-navigation-menu@1.2.22(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-one-time-password-field@0.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-password-toggle-field@0.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-popover@1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-popper@1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/rect': 1.1.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-portal@1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-progress@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-radio-group@1.4.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-scroll-area@1.2.18(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-select@2.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-separator@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-slider@1.4.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-slot@1.3.3(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-switch@1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-tabs@1.1.21(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-toast@1.2.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-toggle-group@1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-toggle@1.1.18(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-toolbar@1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-escape-keydown@1.1.5(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-previous@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-rect@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-use-size@1.1.4(@types/react@18.3.31)(react@18.3.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.31 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + + '@radix-ui/rect@1.1.3': {} + '@remix-run/router@1.23.0': {} '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -3286,6 +5070,74 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.25.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0) + '@tanstack/query-core@5.101.2': {} '@tanstack/react-query@5.101.2(react@18.3.1)': @@ -3414,11 +5266,11 @@ snapshots: '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) '@tiptap/pm': 3.27.1 - '@tiptap/extension-mention@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))': + '@tiptap/extension-mention@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)(@tiptap/suggestion@3.27.1(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))': dependencies: '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) '@tiptap/pm': 3.27.1 - '@tiptap/suggestion': 3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) + '@tiptap/suggestion': 3.27.1(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) '@tiptap/extension-ordered-list@3.27.1(@tiptap/extension-list@3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1))': dependencies: @@ -3513,9 +5365,9 @@ snapshots: '@tiptap/extensions': 3.27.1(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1) '@tiptap/pm': 3.27.1 - '@tiptap/suggestion@3.27.1(@floating-ui/dom@1.7.6)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)': + '@tiptap/suggestion@3.27.1(@floating-ui/dom@1.8.0)(@tiptap/core@3.27.1(@tiptap/pm@3.27.1))(@tiptap/pm@3.27.1)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 '@tiptap/core': 3.27.1(@tiptap/pm@3.27.1) '@tiptap/pm': 3.27.1 @@ -3593,7 +5445,7 @@ snapshots: '@ungap/structured-clone@1.3.2': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@26.1.1)(sass@1.101.0))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) @@ -3601,7 +5453,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.3(@types/node@26.1.1)(sass@1.101.0) + vite: 6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0) transitivePeerDependencies: - supports-color @@ -3612,13 +5464,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 1.2.0 - '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.1.1)(sass@1.101.0))': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0))': dependencies: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21(@types/node@26.1.1)(sass@1.101.0) + vite: 5.4.21(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0) '@vitest/pretty-format@2.1.9': dependencies: @@ -3697,6 +5549,10 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -3805,6 +5661,10 @@ snapshots: dependencies: readdirp: 5.0.0 + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + classnames@2.5.1: {} cliui@6.0.0: @@ -3815,6 +5675,20 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + cn@0.3.0: {} + collapse-white-space@2.1.0: {} color-convert@2.0.1: @@ -3895,8 +5769,9 @@ snapshots: dequal@2.0.3: {} - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} devlop@1.1.0: dependencies: @@ -3931,6 +5806,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.25.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@6.0.1: {} es-define-property@1.0.1: {} @@ -4131,6 +6011,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -4270,6 +6152,8 @@ snapshots: isarray@1.0.0: {} + jiti@2.7.0: {} + js-tokens@4.0.0: {} jsdom@25.0.1: @@ -4321,6 +6205,55 @@ snapshots: dependencies: immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + linkifyjs@4.3.3: {} listenercount@1.0.1: {} @@ -4373,6 +6306,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.47.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -5003,6 +6940,69 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + radix-ui@1.6.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-accessible-icon': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-accordion': 1.2.20(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-alert-dialog': 1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-aspect-ratio': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-avatar': 1.2.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-checkbox': 1.3.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': 1.1.20(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context-menu': 2.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': 2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-form': 0.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-hover-card': 1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-label': 2.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-menubar': 1.1.24(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-navigation-menu': 1.2.22(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-one-time-password-field': 0.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-password-toggle-field': 0.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popover': 1.1.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-progress': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-radio-group': 1.4.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-scroll-area': 1.2.18(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-select': 2.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-separator': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slider': 1.4.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-switch': 1.3.7(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tabs': 1.1.21(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toast': 1.2.23(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle': 1.1.18(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toggle-group': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-toolbar': 1.1.19(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-tooltip': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 @@ -5022,6 +7022,25 @@ snapshots: react-refresh@0.17.0: {} + react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@18.3.1): + dependencies: + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.31 + + react-remove-scroll@2.7.2(@types/react@18.3.31)(react@18.3.1): + dependencies: + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.31)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.31)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.31)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.31 + react-resizable@3.2.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: prop-types: 15.8.1 @@ -5041,6 +7060,14 @@ snapshots: '@remix-run/router': 1.23.0 react: 18.3.1 + react-style-singleton@2.2.3(@types/react@18.3.31)(react@18.3.1): + dependencies: + get-nonce: 1.0.1 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.31 + react-window@1.8.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@babel/runtime': 7.29.7 @@ -5287,6 +7314,12 @@ snapshots: symbol-tree@3.2.4: {} + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + tar-stream@2.2.0: dependencies: bl: 4.1.0 @@ -5336,6 +7369,8 @@ snapshots: tslib@2.8.1: {} + tw-animate-css@1.4.0: {} + typescript@5.9.3: {} undici-types@8.3.0: {} @@ -5396,6 +7431,21 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.3.1): + dependencies: + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.31 + + use-sidecar@1.1.3(@types/react@18.3.31)(react@18.3.1): + dependencies: + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.31 + use-sync-external-store@1.6.0(react@18.3.1): dependencies: react: 18.3.1 @@ -5416,13 +7466,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@2.1.9(@types/node@26.1.1)(sass@1.101.0): + vite-node@2.1.9(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 1.1.2 - vite: 5.4.21(@types/node@26.1.1)(sass@1.101.0) + vite: 5.4.21(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0) transitivePeerDependencies: - '@types/node' - less @@ -5434,7 +7484,7 @@ snapshots: - supports-color - terser - vite@5.4.21(@types/node@26.1.1)(sass@1.101.0): + vite@5.4.21(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0): dependencies: esbuild: 0.21.5 postcss: 8.5.16 @@ -5442,9 +7492,10 @@ snapshots: optionalDependencies: '@types/node': 26.1.1 fsevents: 2.3.3 + lightningcss: 1.32.0 sass: 1.101.0 - vite@6.4.3(@types/node@26.1.1)(sass@1.101.0): + vite@6.4.3(@types/node@26.1.1)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.101.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -5455,12 +7506,14 @@ snapshots: optionalDependencies: '@types/node': 26.1.1 fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 sass: 1.101.0 - vitest@2.1.9(@types/node@26.1.1)(jsdom@25.0.1)(sass@1.101.0): + vitest@2.1.9(@types/node@26.1.1)(jsdom@25.0.1)(lightningcss@1.32.0)(sass@1.101.0): dependencies: '@vitest/expect': 2.1.9 - '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.1.1)(sass@1.101.0)) + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0)) '@vitest/pretty-format': 2.1.9 '@vitest/runner': 2.1.9 '@vitest/snapshot': 2.1.9 @@ -5476,8 +7529,8 @@ snapshots: tinyexec: 0.3.2 tinypool: 1.1.1 tinyrainbow: 1.2.0 - vite: 5.4.21(@types/node@26.1.1)(sass@1.101.0) - vite-node: 2.1.9(@types/node@26.1.1)(sass@1.101.0) + vite: 5.4.21(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0) + vite-node: 2.1.9(@types/node@26.1.1)(lightningcss@1.32.0)(sass@1.101.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.1 diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index 2ee622f9..b7752ae6 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test, vi } from 'vitest'; import { API_QUERY_TIMEOUT_MS, api } from './client'; -import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from '../v2/auth/session'; +import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken, setAuthCredentials } from '../v2/auth/session'; function boundedRequest(init: Record = {}) { return { ...init, signal: expect.any(AbortSignal) }; @@ -21,6 +21,33 @@ test('authenticated requests use the session-only bearer token', async () => { expect(window.localStorage.getItem('vehicle-platform.access-token')).toBeNull(); }); +test('platform requests forward the exact OneOS token and client ID, then drop both for local credentials', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response); + setAuthCredentials('oneos-token-unchanged', 'oneos-client-id'); + await api.session(); + const firstHeaders = new Headers(fetchMock.mock.calls[0][1]?.headers); + expect(firstHeaders.get('Authorization')).toBe('Bearer oneos-token-unchanged'); + expect(firstHeaders.get('X-OneOS-Client-ID')).toBe('oneos-client-id'); + + setAccessToken('local-token'); + await api.session(); + const secondHeaders = new Headers(fetchMock.mock.calls[1][1]?.headers); + expect(secondHeaders.get('Authorization')).toBe('Bearer local-token'); + expect(secondHeaders.has('X-OneOS-Client-ID')).toBe(false); +}); + +test('OneOS public login and captcha never receive the previous identity headers', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { captchaEnabled: false } }) } as Response); + setAuthCredentials('previous-token', 'previous-client'); + await api.oneOSCaptcha(); + await api.oneOSLogin({ username: 'next', password: 'password' }); + for (const [, init] of fetchMock.mock.calls) { + const headers = new Headers(init?.headers); + expect(headers.has('Authorization')).toBe(false); + expect(headers.has('X-OneOS-Client-ID')).toBe(false); + } +}); + test('history export downloads use bearer authentication and retain the server filename', async () => { setAccessToken('customer-export-token'); const payload = new Blob(['export'], { type: 'text/csv' }); diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 43498821..7adbbfe1 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -1,3 +1,5 @@ +import type { CustomerMileageResponse, CustomerMileageRules } from './customerMileage'; +import type { OperationComplianceDetail, OperationComplianceOptions, OperationComplianceResponse } from './operationCompliance'; import type { ApiEnvelope, AdminUser, @@ -72,6 +74,11 @@ import type { SessionInfo, LoginResponse, OneOSExchangeResponse, + AuthProviders, + OneOSPasswordCredentials, + OneOSPendingIdentity, + OneOSAssetPage, + OneOSCaptcha, CustomerUserInput, CustomerUserBatchItem, CustomerUserBatchResult, @@ -93,7 +100,7 @@ import type { VehicleSourcePolicyUpdate, VehicleRow } from './types'; -import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session'; +import { getAuthCredentials, notifyUnauthorizedSession } from '../v2/auth/session'; export type RawFrameQuery = { keyword?: string; @@ -165,14 +172,14 @@ function requestTimeoutError() { return error; } -async function request(path: string, init?: RequestInit): Promise { - const token = getAccessToken(); - const requestInit = token ? { ...init, headers: withAuthorization(init?.headers, token) } : init; +async function request(path: string, init?: RequestInit, anonymous = false): Promise { + const credentials = anonymous ? { accessToken: '', clientId: '' } : getAuthCredentials(); + const requestInit = credentials.accessToken ? { ...init, headers: withAuthorization(init?.headers, credentials.accessToken, credentials.clientId) } : init; const timeout = queryTimeout(requestInit); try { const response = await fetch(path, timeout?.init ?? requestInit); if (!response.ok) { - if (response.status === 401 && token && path !== '/api/v2/session') notifyUnauthorizedSession(token); + if (response.status === 401 && credentials.accessToken && path !== '/api/v2/session') notifyUnauthorizedSession(credentials.accessToken, credentials.clientId); throw new Error(await responseErrorMessage(response)); } const envelope = (await response.json()) as ApiEnvelope; @@ -189,13 +196,13 @@ async function request(path: string, init?: RequestInit): Promise { } async function requestBlob(path: string, signal?: AbortSignal, requestInit?: RequestInit, fallbackFilename = 'history-export.csv') { - const token = getAccessToken(); - const init: RequestInit = token - ? { ...requestInit, signal, headers: withAuthorization(requestInit?.headers, token) } + const credentials = getAuthCredentials(); + const init: RequestInit = credentials.accessToken + ? { ...requestInit, signal, headers: withAuthorization(requestInit?.headers, credentials.accessToken, credentials.clientId) } : { ...requestInit, signal }; const response = await fetch(path, init); if (!response.ok) { - if (response.status === 401 && token) notifyUnauthorizedSession(token); + if (response.status === 401 && credentials.accessToken) notifyUnauthorizedSession(credentials.accessToken, credentials.clientId); throw new Error(await responseErrorMessage(response)); } const encodedFilename = response.headers.get('X-Export-Name')?.trim(); @@ -213,9 +220,11 @@ async function requestBlob(path: string, signal?: AbortSignal, requestInit?: Req }; } -function withAuthorization(headers: HeadersInit | undefined, token: string) { +function withAuthorization(headers: HeadersInit | undefined, token: string, clientId = '') { const authorized = new Headers(headers); authorized.set('Authorization', `Bearer ${token}`); + if (clientId) authorized.set('X-OneOS-Client-ID', clientId); + else authorized.delete('X-OneOS-Client-ID'); return authorized; } @@ -261,10 +270,29 @@ function withTraceID(message: string, traceID?: string) { } export const api = { + customerMileage: (params: URLSearchParams, signal?: AbortSignal) => request(`/api/v2/customer-mileage?${params.toString()}`, withSignal(undefined, signal)), + operationCompliance: (params: URLSearchParams, signal?: AbortSignal) => request(`/api/v2/operation-compliance?${params.toString()}`, withSignal(undefined, signal)), + operationComplianceOptions: (signal?: AbortSignal) => request('/api/v2/operation-compliance/options', withSignal(undefined, signal)), + operationComplianceDetail: (params: URLSearchParams, signal?: AbortSignal) => request(`/api/v2/operation-compliance/detail?${params.toString()}`, withSignal(undefined, signal)), + saveCustomerMileageRules: (rules: CustomerMileageRules) => request('/api/v2/customer-mileage/rules', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rules) }), + vehicleScope: (signal?: AbortSignal) => request('/api/v2/auth/vehicle-scope', withSignal(undefined, signal)), session: (signal?: AbortSignal) => request('/api/v2/session', withSignal(undefined, signal)), + authProviders: (signal?: AbortSignal) => request('/api/v2/auth/providers', withSignal(undefined, signal), true), + oneOSCaptcha: () => request('/api/v2/auth/oneos/captcha', undefined, true), + oneOSLogin: (credentials: OneOSPasswordCredentials) => request('/api/v2/auth/oneos/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) + }, true), + oneOSPending: () => request('/api/v2/auth/oneos/pending', undefined, true), + oneOSAssetVehicles: (params: URLSearchParams, signal?: AbortSignal) => request(`/api/v2/auth/oneos/vehicles?${params.toString()}`, withSignal(undefined, signal)), + oneOSConfirm: (csrfToken: string) => request('/api/v2/auth/oneos/confirm', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrfToken }) + }, true), + oneOSCancel: (csrfToken: string) => request<{ cancelled: boolean }>('/api/v2/auth/oneos/cancel', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrfToken }) + }, true), login: (credentials: { username: string; password: string }) => request('/api/v2/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) - }), + }, true), exchangeOneOSTicket: (ticket: string) => request('/api/v2/auth/oneos/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket }) }), @@ -385,6 +413,9 @@ export const api = { dashboardSummary: () => request('/api/dashboard/summary'), vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined), vehicleResolve: (params = new URLSearchParams()) => request(`/api/vehicles/resolve?${params.toString()}`), + mileageVehicleOptions: (params = new URLSearchParams(), signal?: AbortSignal) => request>>(`/api/v2/statistics/vehicle-options?${params.toString()}`, withSignal(undefined, signal)), + mileageVehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request>>(`/api/v2/statistics/vehicles?${params.toString()}`, withSignal(undefined, signal)), + mileageVehicleResolve: (query: VehicleOverviewBatchQuery) => request>>('/api/v2/statistics/vehicle-resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }), vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined), vehicleCoverageSummary: (params = new URLSearchParams()) => request(`/api/vehicles/coverage/summary?${params.toString()}`), vehicleBusinessFilters: (signal?: AbortSignal) => request('/api/vehicles/business-filters', withSignal(undefined, signal)), diff --git a/vehicle-data-platform/apps/web/src/api/customerMileage.ts b/vehicle-data-platform/apps/web/src/api/customerMileage.ts new file mode 100644 index 00000000..19513a75 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/api/customerMileage.ts @@ -0,0 +1,26 @@ +export type CustomerMileageRules = { + version: number; lowMileageKm: number; targetMileageKm: number; + deviationKm: number; deviationPercent: number; minimumGpsKm: number; includeToday: boolean; +}; +export type CustomerVehicleDay = { + restricted?: boolean; vin: string; plate: string; customer: string; department: string; responsiblePerson: string; + hydrogenCoveragePercent?: number | null; hydrogenObservedKm?: number | null; hydrogenSource?: string; + date: string; gpsKm: number | null; hydrogenKm: number | null; + gpsStatus: string; hydrogenStatus: string; differenceKm: number | null; differencePercent: number | null; + alert: string; provisional: boolean; qualityReason?: string; +}; +export type CustomerMileageDay = { + date: string; vehicleCount: number; gpsVehicleCount: number; hydrogenVehicleCount: number; + gpsKm: number | null; hydrogenKm: number | null; suspectedChargingCount: number; + dataIssueCount: number; lowMileageCount: number; provisional: boolean; +}; +export type CustomerMileageGroup = { + customer: string; vehicleCount: number; departments: string[]; responsiblePeople: string[]; + days: CustomerMileageDay[]; vehicles: CustomerVehicleDay[]; +}; +export type CustomerMileageResponse = { + hydrogenJobs?: {date: string; status: string; completedAt: string; attempts?: number}[]; + groups: CustomerMileageGroup[]; rules: CustomerMileageRules; dates: string[]; + assignmentSource: string; assignmentObservedAt: string; assignmentStale: boolean; + assignmentMissingCount?: number; excludedUnassignedCount: number; canConfigure: boolean; +}; diff --git a/vehicle-data-platform/apps/web/src/api/operationCompliance.ts b/vehicle-data-platform/apps/web/src/api/operationCompliance.ts new file mode 100644 index 00000000..89ad5128 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/api/operationCompliance.ts @@ -0,0 +1,20 @@ +export type OperationComplianceStatus = 'passed' | 'failed' | 'insufficient'; +export type OperationMileageSource = 'gps' | 'odometer' | 'hydrogen'; +export type OperationComplianceOptions = { customers: string[]; departments: string[]; responsiblePeople: string[] }; +export type OperationComplianceRow = { + vin: string; plate: string; customer: string; department: string; responsiblePerson: string; + mileageKm: number | null; status: OperationComplianceStatus; + validDays: number; expectedDays: number; authorizedDays: number; invalidDays: number; missingDays: number; + incomplete: boolean; reasons: string[]; +}; +export type OperationComplianceResponse = { + items: OperationComplianceRow[]; total: number; limit: number; offset: number; + dateFrom: string; dateTo: string; targetKm: number; mileageSource: OperationMileageSource; + summary: { total: number; passed: number; failed: number; insufficient: number; passRate: number | null }; + assignmentSource: string; assignmentObservedAt: string; assignmentStale: boolean; assignmentMissingCount: number; +}; +export type OperationComplianceDetail = { + vehicle: OperationComplianceRow; + dateFrom: string; dateTo: string; targetKm: number; mileageSource: OperationMileageSource; + days: { date: string; mileageKm: number | null; protocol: string; status: 'valid' | 'missing' | 'invalid' | 'restricted'; reason: string }[]; +}; diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index aa416ee4..1437b2dd 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -5,12 +5,16 @@ export interface ApiEnvelope { } export interface SessionInfo { + readOnly?: boolean; + dataSource?: string; + vehicleScopeSource?: string; subjectId?: string; name: string; username?: string; role: 'viewer' | 'operator' | 'admin' | 'customer'; userType?: 'viewer' | 'operator' | 'admin' | 'customer'; authProvider?: 'local' | 'legacy-token' | 'disabled' | string; + authorizationStatus?: 'loading' | 'pending' | 'asset-catalog' | 'ready'; menuKeys?: string[]; vehicleCount?: number; customerRef?: string; @@ -23,6 +27,7 @@ export interface SessionInfo { export interface LoginResponse { accessToken: string; + clientId?: string; expiresAt: string; session: Omit; } @@ -31,6 +36,63 @@ export interface OneOSExchangeResponse extends LoginResponse { returnTo: string; } +export interface AuthProviders { + oneos: { + enabled: boolean; + passwordEnabled: boolean; + ssoEnabled: boolean; + authorizationReady: boolean; + devIntegration?: boolean; + }; + local: { enabled: boolean }; +} + +export interface OneOSPasswordCredentials { + username: string; + password: string; + code?: string; + uuid?: string; +} + +export interface OneOSPendingIdentity { + identity: { + username: string; + displayName: string; + tenantId: string; + departmentName?: string; + }; + csrfToken: string; + expiresAt: string; +} + +export interface OneOSAssetVehicle { + vin: string; + plateNumber: string; + brand: string; + brandName: string; + model: string; + modelName: string; + businessDepartment: string; + operationStatus: string; + vehicleSource: string; +} + +export interface OneOSAssetPage { + records: OneOSAssetVehicle[]; + total: number; + current: number; + size: number; + telemetryAvailable: false; + historyAvailable: false; + source: 'oneos-asset-catalog'; +} + +export interface OneOSCaptcha { + captchaEnabled: boolean; + uuid?: string; + img?: string; +} + export interface AdminUser { id: number; username: string; @@ -115,8 +177,9 @@ export interface MonitorSummary { idleVehicles: number; alertVehicles: number; unknownVehicles: number; - activeToday: number; - frameToday: number; + activityDataAvailable?: boolean; + activeToday?: number; + frameToday?: number; alertDataAvailable: boolean; truncated: boolean; asOf: string; @@ -525,6 +588,7 @@ export interface VehicleIdentityResolution { } export interface VehicleDetail { + historyIncluded?: boolean; vin: string; lookupKey: string; lookupResolved: boolean; @@ -836,6 +900,7 @@ export interface RealtimeLocationRow { } export interface VehicleRealtimeRow { + vehicleNumber?: string; vin: string; plate: string; phone: string; @@ -858,6 +923,10 @@ export interface VehicleRealtimeRow { speedKmh: number; socAvailable?: boolean; socPercent: number; + hydrogenPressureMpa?: number | null; + hydrogenMassKg?: number | null; + hydrogenMassSource?: 'REPORTED' | 'ESTIMATED'; + hydrogenConsumptionKgPer100Km?: number | null; mileageAvailable?: boolean; totalMileageKm: number; todayMileageAvailable?: boolean; diff --git a/vehicle-data-platform/apps/web/src/components/ui/alert.tsx b/vehicle-data-platform/apps/web/src/components/ui/alert.tsx new file mode 100644 index 00000000..11ffb6ba --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/alert.tsx @@ -0,0 +1,65 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/vehicle-data-platform/apps/web/src/components/ui/badge.tsx b/vehicle-data-platform/apps/web/src/components/ui/badge.tsx new file mode 100644 index 00000000..6c15fe00 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,50 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { Slot } from "radix-ui" + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + success: "border-transparent bg-success/10 text-success", + warning: "border-transparent bg-warning/10 text-warning", + danger: "border-transparent bg-destructive/10 text-destructive", + destructive: + "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/vehicle-data-platform/apps/web/src/components/ui/button.tsx b/vehicle-data-platform/apps/web/src/components/ui/button.tsx new file mode 100644 index 00000000..950ca782 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/button.tsx @@ -0,0 +1,61 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" +import { Slot } from "radix-ui" + +const buttonVariants = cva( + "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Button = React.forwardRef & VariantProps & { asChild?: boolean }>(function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}, ref) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +}) + +export { Button, buttonVariants } diff --git a/vehicle-data-platform/apps/web/src/components/ui/checkbox.tsx b/vehicle-data-platform/apps/web/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..194f9b53 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/checkbox.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/vehicle-data-platform/apps/web/src/components/ui/command.tsx b/vehicle-data-platform/apps/web/src/components/ui/command.tsx new file mode 100644 index 00000000..b0b3d5ef --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { cn } from "@/lib/utils" +import { SearchIcon } from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + + {title} + {description} + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/vehicle-data-platform/apps/web/src/components/ui/dialog.tsx b/vehicle-data-platform/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..cd8461a9 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +const DialogOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(function DialogOverlay({ + className, + ...props +}, ref) { + return ( + + ) +}) + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/vehicle-data-platform/apps/web/src/components/ui/empty.tsx b/vehicle-data-platform/apps/web/src/components/ui/empty.tsx new file mode 100644 index 00000000..ebcce89e --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/empty.tsx @@ -0,0 +1,103 @@ +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +function Empty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const emptyMediaVariants = cva( + "mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0", + { + variants: { + variant: { + default: "bg-transparent", + icon: "flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-foreground [&_svg:not([class*='size-'])]:size-6", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function EmptyMedia({ + className, + variant = "default", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) { + return ( +
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + className + )} + {...props} + /> + ) +} + +function EmptyContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Empty, + EmptyHeader, + EmptyTitle, + EmptyDescription, + EmptyContent, + EmptyMedia, +} diff --git a/vehicle-data-platform/apps/web/src/components/ui/field.tsx b/vehicle-data-platform/apps/web/src/components/ui/field.tsx new file mode 100644 index 00000000..fffad777 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/components/ui/field.tsx @@ -0,0 +1,248 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=field-group]]:gap-4", + className + )} + {...props} + /> + ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +