feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
cfg := config.Load()
|
||||
if strings.TrimSpace(cfg.MySQLDSN) == "" {
|
||||
return fmt.Errorf("MYSQL_DSN is required")
|
||||
}
|
||||
gateways := map[string]platform.AlertNotificationGateway{}
|
||||
for _, candidate := range []struct {
|
||||
channel string
|
||||
endpoint string
|
||||
secret string
|
||||
}{
|
||||
{channel: "sms", endpoint: cfg.AlertNotificationSMSURL, secret: cfg.AlertNotificationSMSSecret},
|
||||
{channel: "email", endpoint: cfg.AlertNotificationEmailURL, secret: cfg.AlertNotificationEmailSecret},
|
||||
{channel: "wecom", endpoint: cfg.AlertNotificationWeComURL, secret: cfg.AlertNotificationWeComSecret},
|
||||
} {
|
||||
if strings.TrimSpace(candidate.endpoint) == "" && strings.TrimSpace(candidate.secret) == "" {
|
||||
continue
|
||||
}
|
||||
gateway, err := platform.NewHTTPAlertNotificationGateway(candidate.endpoint, candidate.secret, cfg.AlertNotificationTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s gateway: %w", candidate.channel, err)
|
||||
}
|
||||
gateways[candidate.channel] = gateway
|
||||
}
|
||||
if len(gateways) == 0 {
|
||||
return fmt.Errorf("at least one ALERT_NOTIFICATION_*_URL and matching signing secret are required")
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
db, err := platform.OpenSQL(ctx, "mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
host, _ := os.Hostname()
|
||||
dispatcher := platform.NewAlertNotificationDispatcher(
|
||||
platform.NewProductionStore(db, nil, ""),
|
||||
gateways,
|
||||
cfg.AlertNotificationBatchSize,
|
||||
cfg.AlertNotificationLease,
|
||||
"notification-dispatcher-"+host,
|
||||
)
|
||||
poll := cfg.AlertNotificationPollInterval
|
||||
if poll < 250*time.Millisecond {
|
||||
poll = time.Second
|
||||
}
|
||||
if poll > time.Minute {
|
||||
poll = time.Minute
|
||||
}
|
||||
ticker := time.NewTicker(poll)
|
||||
defer ticker.Stop()
|
||||
log.Printf("alert notification dispatcher started channels=%d batch=%d poll=%s lease=%s", len(gateways), cfg.AlertNotificationBatchSize, poll, cfg.AlertNotificationLease)
|
||||
for {
|
||||
started := time.Now()
|
||||
result, dispatchErr := dispatcher.RunOnce(ctx)
|
||||
if dispatchErr != nil {
|
||||
log.Printf("alert notification dispatch failed claimed=%d sent=%d failed=%d duration=%s error=%v", result.Claimed, result.Sent, result.Failed, time.Since(started), dispatchErr)
|
||||
} else if result.Claimed > 0 {
|
||||
log.Printf("alert notification dispatch completed claimed=%d sent=%d failed=%d duration=%s", result.Claimed, result.Sent, result.Failed, time.Since(started))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("alert notification dispatcher stopped")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
82
vehicle-data-platform/apps/api/cmd/open-platform-api/main.go
Normal file
82
vehicle-data-platform/apps/api/cmd/open-platform-api/main.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mysqlDSN := os.Getenv("MYSQL_DSN")
|
||||
if mysqlDSN == "" {
|
||||
log.Fatal("MYSQL_DSN is required")
|
||||
}
|
||||
db, err := sql.Open("mysql", mysqlDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
log.Fatalf("connect MySQL: %v", err)
|
||||
}
|
||||
tdengineDSN := os.Getenv("TDENGINE_DSN")
|
||||
if tdengineDSN == "" {
|
||||
log.Fatal("TDENGINE_DSN is required")
|
||||
}
|
||||
tdengineDriver := env("TDENGINE_DRIVER", "taosWS")
|
||||
tdengineDB, err := sql.Open(tdengineDriver, tdengineDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer tdengineDB.Close()
|
||||
tdCtx, tdCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer tdCancel()
|
||||
if err := tdengineDB.PingContext(tdCtx); err != nil {
|
||||
log.Fatalf("connect TDengine: %v", err)
|
||||
}
|
||||
addr := env("OPEN_PLATFORM_HTTP_ADDR", ":20310")
|
||||
handler := openplatform.NewStandaloneServer(db, openplatform.StandaloneConfig{
|
||||
StaticDir: os.Getenv("OPEN_PLATFORM_STATIC_DIR"),
|
||||
SessionTTL: time.Duration(envInt("OPEN_PLATFORM_SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||
RequestTimeout: time.Duration(envInt("OPEN_PLATFORM_REQUEST_TIMEOUT_MS", 10000)) * time.Millisecond,
|
||||
Release: os.Getenv("OPEN_PLATFORM_RELEASE"),
|
||||
TDengine: tdengineDB,
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
|
||||
})
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
log.Printf("vehicle open platform listening on %s", addr)
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func env(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
value, err := strconv.Atoi(os.Getenv(name))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/taosdata/driver-go/v3/taosWS"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
)
|
||||
|
||||
func main() {
|
||||
date := flag.String("date", "", "last date to calculate in yyyy-MM-dd; defaults to yesterday in Asia/Shanghai")
|
||||
lookback := flag.Int("lookback-days", envInt("OPEN_STAT_LOOKBACK_DAYS", 2), "number of dates ending at -date")
|
||||
noise := flag.Float64("hydrogen-noise-kg", envFloat("OPEN_STAT_HYDROGEN_NOISE_KG", 0.05), "ignored mass jitter")
|
||||
maxDrop := flag.Float64("hydrogen-max-drop-kg", envFloat("OPEN_STAT_HYDROGEN_MAX_DROP_KG", 20), "maximum accepted drop between samples")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.Load()
|
||||
if cfg.MySQLDSN == "" || cfg.TDengineDSN == "" {
|
||||
log.Fatal("MYSQL_DSN and TDENGINE_DSN are required")
|
||||
}
|
||||
mysqlDB, err := sql.Open("mysql", cfg.MySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdengineDB, err := sql.Open(cfg.TDengineDriver, cfg.TDengineDSN)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer tdengineDB.Close()
|
||||
|
||||
location := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||||
endDate := time.Now().In(location).AddDate(0, 0, -1)
|
||||
if *date != "" {
|
||||
endDate, err = time.ParseInLocation("2006-01-02", *date, location)
|
||||
if err != nil {
|
||||
log.Fatal("-date must use yyyy-MM-dd")
|
||||
}
|
||||
}
|
||||
if *lookback < 1 || *lookback > 31 {
|
||||
log.Fatal("-lookback-days must be between 1 and 31")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
capacities, err := openplatform.LoadHydrogenCapacities(ctx, mysqlDB)
|
||||
if err != nil {
|
||||
log.Fatalf("load hydrogen tank capacities: %v", err)
|
||||
}
|
||||
for offset := *lookback - 1; offset >= 0; offset-- {
|
||||
start := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, location).AddDate(0, 0, -offset)
|
||||
observations, err := openplatform.LoadHydrogenObservations(ctx, tdengineDB, cfg.TDengineDatabase, start, start.AddDate(0, 0, 1), capacities)
|
||||
if err != nil {
|
||||
log.Fatalf("load hydrogen observations for %s: %v", start.Format("2006-01-02"), err)
|
||||
}
|
||||
stats := openplatform.BuildHydrogenDailyStats(observations, start.Format("2006-01-02"), *noise, *maxDrop)
|
||||
if err := openplatform.ReplaceHydrogenDailyStats(ctx, mysqlDB, start.Format("2006-01-02"), stats); err != nil {
|
||||
log.Fatalf("persist hydrogen statistics for %s: %v", start.Format("2006-01-02"), err)
|
||||
}
|
||||
fmt.Printf("date=%s observations=%d vehicles=%d\n", start.Format("2006-01-02"), len(observations), len(stats))
|
||||
}
|
||||
}
|
||||
|
||||
func envInt(name string, fallback int) int {
|
||||
value, err := strconv.Atoi(os.Getenv(name))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envFloat(name string, fallback float64) float64 {
|
||||
value, err := strconv.ParseFloat(os.Getenv(name), 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -3,13 +3,15 @@ module lingniu/vehicle-data-platform/apps/api
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/segmentio/kafka-go v0.4.49
|
||||
github.com/taosdata/driver-go/v3 v3.8.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -17,6 +19,4 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
github.com/segmentio/kafka-go v0.4.49 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
)
|
||||
|
||||
@@ -38,8 +38,18 @@ github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/taosdata/driver-go/v3 v3.8.1 h1:kkd4ABsGiU+oXDbsw/sic985LKAvnpF9Gb/TEunTnLE=
|
||||
github.com/taosdata/driver-go/v3 v3.8.1/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
@@ -30,6 +30,8 @@ type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
@@ -60,7 +62,17 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local}
|
||||
oneOS, err := newOneOSIdentityAdapter(cfg)
|
||||
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}
|
||||
if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") {
|
||||
authenticator.demo = newDemoAuthDirectory()
|
||||
}
|
||||
configured := []configuredPrincipal{}
|
||||
if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil {
|
||||
@@ -97,6 +109,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/oneos/exchange" {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.oneOS == nil || a.local == nil {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_SSO_UNAVAILABLE", "OneOS 单点登录尚未启用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
a.local.exchangeOneOSTicket(w, r, a.oneOS)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/login" {
|
||||
if a.local == nil {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r))
|
||||
@@ -146,7 +170,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" {
|
||||
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") {
|
||||
a.local.logout(r.Context(), bearerToken(r))
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
@@ -163,6 +187,9 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
if a.local != nil && a.local.handleAdmin(w, r, principal) {
|
||||
return
|
||||
}
|
||||
if a.demo != nil && a.demo.handleAdmin(w, r, principal) {
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -220,9 +247,9 @@ func requiredMenu(r *http.Request) string {
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary":
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage":
|
||||
return "statistics"
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve":
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters":
|
||||
return "shared"
|
||||
case strings.HasPrefix(path, "/api/v2/vehicles/"):
|
||||
return "vehicles"
|
||||
@@ -232,7 +259,14 @@ func requiredMenu(r *http.Request) string {
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") {
|
||||
return "admin"
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/reconciliation/") {
|
||||
if r.Method == http.MethodPost && (strings.HasSuffix(r.URL.Path, "/archive") || strings.HasSuffix(r.URL.Path, "/restore") ||
|
||||
strings.HasSuffix(r.URL.Path, "/batch-archive") || strings.HasSuffix(r.URL.Path, "/batch-restore")) {
|
||||
return "admin"
|
||||
}
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodPost {
|
||||
return "operator"
|
||||
}
|
||||
@@ -255,7 +289,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/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
|
||||
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":
|
||||
return "viewer"
|
||||
case "/api/v2/alerts/notifications/read":
|
||||
return "operator"
|
||||
@@ -266,6 +300,9 @@ func requiredRole(r *http.Request) string {
|
||||
if path == "/api/v2/alerts/rules" {
|
||||
return "admin"
|
||||
}
|
||||
if strings.HasPrefix(path, "/api/v2/alerts/rules/") && strings.HasSuffix(path, "/rollback") {
|
||||
return "admin"
|
||||
}
|
||||
}
|
||||
if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
|
||||
return "admin"
|
||||
|
||||
260
vehicle-data-platform/apps/api/internal/app/auth_demo.go
Normal file
260
vehicle-data-platform/apps/api/internal/app/auth_demo.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
// demoAuthDirectory keeps account governance operable in DATA_MODE=mock. It is
|
||||
// intentionally isolated from production authentication and only exists when
|
||||
// authentication is disabled and no SQL identity store is configured.
|
||||
type demoAuthDirectory struct {
|
||||
mu sync.Mutex
|
||||
users []authUser
|
||||
nextID uint64
|
||||
}
|
||||
|
||||
func newDemoAuthDirectory() *demoAuthDirectory {
|
||||
now := time.Now().Truncate(time.Second)
|
||||
grant := func(vin, plate string, daysAgo int) authVehicleGrant {
|
||||
return authVehicleGrant{VIN: vin, Plate: plate, ValidFrom: now.AddDate(0, 0, -daysAgo), SourceSystem: "manual", GrantedBy: "demo-admin"}
|
||||
}
|
||||
users := []authUser{
|
||||
{ID: 101, Username: "customer-south", DisplayName: "华南运营中心", UserType: "customer", Status: "enabled", CustomerRef: "CUS-SOUTH", TenantRef: "tenant-south", AuthProvider: "local", MenuKeys: append([]string(nil), customerMenuKeys...), Vehicles: []authVehicleGrant{grant("LB9A32A24R0LS1426", "粤AG18312", 120), grant("LB9A32A24P0LS1230", "粤AFF7936", 45)}, LastLoginAt: timePointer(now.Add(-18 * time.Minute)), CreatedAt: now.AddDate(0, -5, 0), UpdatedAt: now.Add(-2 * time.Hour)},
|
||||
{ID: 102, Username: "oneos-east", DisplayName: "华东数据客户", UserType: "customer", Status: "enabled", CustomerRef: "CUS-EAST", TenantRef: "tenant-east", AuthProvider: "OneOS", ExternalSubject: "oneos:tenant-east:customer-002", MenuKeys: append([]string(nil), customerMenuKeys...), Vehicles: []authVehicleGrant{grant("LMRKH9AC2R1004087", "豫A88888", 88)}, LastLoginAt: timePointer(now.Add(-3 * time.Hour)), CreatedAt: now.AddDate(0, -4, 0), UpdatedAt: now.Add(-25 * time.Minute)},
|
||||
{ID: 103, Username: "ruoyi-west", DisplayName: "西区联营客户", UserType: "customer", Status: "enabled", CustomerRef: "CUS-WEST", TenantRef: "tenant-west", AuthProvider: "RuoYi", MenuKeys: []string{"monitor", "vehicles"}, Vehicles: []authVehicleGrant{grant("LNXNEGRR7SR318212", "川AHTWO1", 31)}, CreatedAt: now.AddDate(0, -2, 0), UpdatedAt: now.Add(-40 * time.Minute)},
|
||||
{ID: 104, Username: "customer-archive", DisplayName: "历史合作客户", UserType: "customer", Status: "disabled", CustomerRef: "CUS-ARCHIVE", AuthProvider: "local", MenuKeys: []string{"monitor"}, Vehicles: []authVehicleGrant{grant("LB9A32A24P0LS1230", "粤AFF7936", 200)}, CreatedAt: now.AddDate(-1, 0, 0), UpdatedAt: now.AddDate(0, 0, -12)},
|
||||
}
|
||||
for index := range users {
|
||||
users[index].VehicleVINs = grantVINsFromAuth(users[index].Vehicles)
|
||||
}
|
||||
return &demoAuthDirectory{users: users, nextID: 105}
|
||||
}
|
||||
|
||||
var customerMenuKeys = []string{"monitor", "vehicles", "tracks", "statistics"}
|
||||
|
||||
func timePointer(value time.Time) *time.Time { return &value }
|
||||
|
||||
func grantVINsFromAuth(grants []authVehicleGrant) []string {
|
||||
vins := make([]string, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
vins = append(vins, grant.VIN)
|
||||
}
|
||||
return vins
|
||||
}
|
||||
|
||||
func cloneAuthUsers(users []authUser) []authUser {
|
||||
result := make([]authUser, len(users))
|
||||
for index, user := range users {
|
||||
result[index] = user
|
||||
result[index].MenuKeys = append([]string(nil), user.MenuKeys...)
|
||||
result[index].VehicleVINs = append([]string(nil), user.VehicleVINs...)
|
||||
result[index].Vehicles = append([]authVehicleGrant(nil), user.Vehicles...)
|
||||
result[index].GrantHistory = append([]authVehicleGrantHistory(nil), user.GrantHistory...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (d *demoAuthDirectory) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
|
||||
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
|
||||
return false
|
||||
}
|
||||
if principal.UserType != "admin" && principal.Role != "admin" {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "仅管理员可以管理账号与权限", "", requestTraceID(r))
|
||||
return true
|
||||
}
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
|
||||
d.mu.Lock()
|
||||
users := cloneAuthUsers(d.users)
|
||||
d.mu.Unlock()
|
||||
httpx.WriteOK(w, requestTraceID(r), users)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users/batch":
|
||||
d.batchCustomers(w, r)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
|
||||
d.createCustomer(w, r)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
|
||||
d.updateCustomer(w, r)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "不支持的账号管理操作", "", requestTraceID(r))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateDemoCustomer(input userMutation, requirePassword bool) ([]string, []authVehicleGrant, error) {
|
||||
if requirePassword && !usernamePattern.MatchString(strings.TrimSpace(input.Username)) {
|
||||
return nil, nil, demoValidationError("用户名需为 3-64 位字母、数字、点、下划线或短横线")
|
||||
}
|
||||
if strings.TrimSpace(input.DisplayName) == "" || len([]rune(strings.TrimSpace(input.DisplayName))) > 48 {
|
||||
return nil, nil, demoValidationError("客户名称不能为空且不能超过 48 个字符")
|
||||
}
|
||||
if input.Status != "enabled" && input.Status != "disabled" {
|
||||
return nil, nil, demoValidationError("账号状态无效")
|
||||
}
|
||||
if requirePassword || input.Password != "" {
|
||||
if err := validatePassword(input.Password); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
menus := normalizeMenus(input.MenuKeys)
|
||||
if len(menus) == 0 {
|
||||
return nil, nil, demoValidationError("至少分配一个客户菜单")
|
||||
}
|
||||
mutations, err := normalizeVehicleGrantMutations(input)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(mutations) == 0 {
|
||||
return nil, nil, demoValidationError("至少分配一辆可查看车辆")
|
||||
}
|
||||
now := time.Now().Truncate(time.Second)
|
||||
plates := map[string]string{"LB9A32A24R0LS1426": "粤AG18312", "LNXNEGRR7SR318212": "川AHTWO1", "LMRKH9AC2R1004087": "豫A88888", "LB9A32A24P0LS1230": "粤AFF7936"}
|
||||
grants := make([]authVehicleGrant, 0, len(mutations))
|
||||
for _, item := range mutations {
|
||||
if _, exists := plates[item.VIN]; !exists {
|
||||
return nil, nil, demoValidationError("车辆不存在或尚未接入:" + item.VIN)
|
||||
}
|
||||
validFrom := now
|
||||
if item.ValidFrom != nil {
|
||||
validFrom = *item.ValidFrom
|
||||
}
|
||||
grants = append(grants, authVehicleGrant{VIN: item.VIN, Plate: plates[item.VIN], ValidFrom: validFrom, ValidTo: item.ValidTo, SourceSystem: "manual", GrantedBy: "local-developer"})
|
||||
}
|
||||
return menus, grants, nil
|
||||
}
|
||||
|
||||
type demoValidationError string
|
||||
|
||||
func (e demoValidationError) Error() string { return string(e) }
|
||||
|
||||
func (d *demoAuthDirectory) createCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, grants, err := validateDemoCustomer(input, true)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for _, user := range d.users {
|
||||
if strings.EqualFold(user.Username, strings.TrimSpace(input.Username)) {
|
||||
httpx.WriteError(w, http.StatusConflict, "USERNAME_EXISTS", "登录账号已经存在", input.Username, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
}
|
||||
now := time.Now().Truncate(time.Second)
|
||||
id := d.nextID
|
||||
d.nextID++
|
||||
d.users = append(d.users, authUser{ID: id, Username: strings.TrimSpace(input.Username), DisplayName: strings.TrimSpace(input.DisplayName), UserType: "customer", Status: input.Status, CustomerRef: strings.TrimSpace(input.CustomerRef), TenantRef: strings.TrimSpace(input.TenantRef), AuthProvider: "local", MenuKeys: menus, VehicleVINs: grantVINsFromAuth(grants), Vehicles: grants, CreatedAt: now, UpdatedAt: now})
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (d *demoAuthDirectory) updateCustomer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseUint(strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_ID_INVALID", "账号编号无效", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
var input userMutation
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, grants, err := validateDemoCustomer(input, false)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for index := range d.users {
|
||||
user := &d.users[index]
|
||||
if user.ID != id || user.UserType != "customer" {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(user.AuthProvider, "local") && strings.TrimSpace(user.AuthProvider) != "" && input.Password != "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", user.AuthProvider, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
user.Status = input.Status
|
||||
user.CustomerRef = strings.TrimSpace(input.CustomerRef)
|
||||
user.TenantRef = strings.TrimSpace(input.TenantRef)
|
||||
user.MenuKeys = menus
|
||||
user.Vehicles = grants
|
||||
user.VehicleVINs = grantVINsFromAuth(grants)
|
||||
user.UpdatedAt = time.Now().Truncate(time.Second)
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
return
|
||||
}
|
||||
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
|
||||
}
|
||||
|
||||
func (d *demoAuthDirectory) batchCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
var input userBatchRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
if input.Mode != "preview" && input.Mode != "create" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量处理模式无效", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", "每批需要包含 1 至 50 个账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
result := userBatchResult{Mode: input.Mode, Summary: userBatchSummary{Received: len(input.Items)}, Items: make([]userBatchResultItem, 0, len(input.Items))}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range input.Items {
|
||||
entry := userBatchResultItem{Row: item.Row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
|
||||
key := strings.ToLower(strings.TrimSpace(item.Input.Username))
|
||||
if seen[key] {
|
||||
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内账号重复"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
if _, _, err := validateDemoCustomer(item.Input, true); err != nil {
|
||||
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
d.mu.Lock()
|
||||
exists := false
|
||||
for _, user := range d.users {
|
||||
exists = exists || strings.EqualFold(user.Username, key)
|
||||
}
|
||||
d.mu.Unlock()
|
||||
if exists {
|
||||
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "登录账号已经存在"
|
||||
result.Summary.Failed++
|
||||
} else if input.Mode == "preview" {
|
||||
entry.Status, entry.Message = "ready", "校验通过,可以创建"
|
||||
result.Summary.Ready++
|
||||
} else {
|
||||
menus, grants, _ := validateDemoCustomer(item.Input, true)
|
||||
d.mu.Lock()
|
||||
id := d.nextID
|
||||
d.nextID++
|
||||
now := time.Now().Truncate(time.Second)
|
||||
d.users = append(d.users, authUser{ID: id, Username: strings.TrimSpace(item.Input.Username), DisplayName: strings.TrimSpace(item.Input.DisplayName), UserType: "customer", Status: firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled"), CustomerRef: strings.TrimSpace(item.Input.CustomerRef), TenantRef: strings.TrimSpace(item.Input.TenantRef), AuthProvider: "local", MenuKeys: menus, VehicleVINs: grantVINsFromAuth(grants), Vehicles: grants, CreatedAt: now, UpdatedAt: now})
|
||||
d.mu.Unlock()
|
||||
entry.ID, entry.Status, entry.Message = int64(id), "created", "创建成功"
|
||||
result.Summary.Created++
|
||||
}
|
||||
result.Items = append(result.Items, entry)
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), result)
|
||||
}
|
||||
@@ -123,6 +123,41 @@ type userMutation struct {
|
||||
VehicleGrants []userVehicleGrantInput `json:"vehicleGrants"`
|
||||
}
|
||||
|
||||
const maxUserBatchItems = 50
|
||||
|
||||
type userBatchRequest struct {
|
||||
Mode string `json:"mode"`
|
||||
Items []userBatchItem `json:"items"`
|
||||
}
|
||||
|
||||
type userBatchItem struct {
|
||||
Row int `json:"row"`
|
||||
Input userMutation `json:"input"`
|
||||
}
|
||||
|
||||
type userBatchResultItem struct {
|
||||
Row int `json:"row"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
type userBatchSummary struct {
|
||||
Received int `json:"received"`
|
||||
Ready int `json:"ready"`
|
||||
Created int `json:"created"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type userBatchResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Summary userBatchSummary `json:"summary"`
|
||||
Items []userBatchResultItem `json:"items"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
@@ -139,6 +174,17 @@ type loginResponse struct {
|
||||
Session platform.Principal `json:"session"`
|
||||
}
|
||||
|
||||
type oneOSTicketExchangeRequest struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
|
||||
type oneOSTicketExchangeResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session platform.Principal `json:"session"`
|
||||
ReturnTo string `json:"returnTo"`
|
||||
}
|
||||
|
||||
type cachedSession struct {
|
||||
principal platform.Principal
|
||||
expiresAt time.Time
|
||||
@@ -146,10 +192,11 @@ type cachedSession struct {
|
||||
}
|
||||
|
||||
type authStore struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
oneOSSessionTTL time.Duration
|
||||
cacheMu sync.RWMutex
|
||||
cache map[string]cachedSession
|
||||
}
|
||||
|
||||
func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
|
||||
@@ -160,7 +207,11 @@ func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 12 * time.Hour
|
||||
}
|
||||
store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}}
|
||||
oneOSTTL := cfg.OneOSSessionTTL
|
||||
if oneOSTTL <= 0 {
|
||||
oneOSTTL = 30 * time.Minute
|
||||
}
|
||||
store := &authStore{db: db, sessionTTL: ttl, oneOSSessionTTL: oneOSTTL, cache: map[string]cachedSession{}}
|
||||
if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -243,28 +294,168 @@ func (s *authStore) login(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
accessToken, tokenHash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
expiresAt := now.Add(s.sessionTTL)
|
||||
_, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`, sessionID, credential.ID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255))
|
||||
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
s.audit(r.Context(), principal.Name, "login", "user", strconv.FormatUint(credential.ID, 10), "success", nil, remoteAddress(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal})
|
||||
httpx.WriteOK(w, requestTraceID(r), response)
|
||||
}
|
||||
|
||||
func (s *authStore) exchangeOneOSTicket(w http.ResponseWriter, r *http.Request, adapter *oneOSIdentityAdapter) {
|
||||
var input oneOSTicketExchangeRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
identity, err := adapter.ExchangeTicket(r.Context(), input.Ticket)
|
||||
if err != nil {
|
||||
s.audit(r.Context(), "oneos", "sso.exchange", "ticket", "", "denied", map[string]any{"reason": err.Error()}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "ONEOS_TICKET_REJECTED", "OneOS 登录票据无效、已过期或已使用", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
user, err := s.syncOneOSIdentity(r.Context(), identity, adapter.defaultMenus)
|
||||
if err != nil {
|
||||
s.audit(r.Context(), identity.Username, "sso.exchange", "user", identity.Subject, "failed", map[string]any{"reason": err.Error()}, remoteAddress(r))
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法同步 OneOS 用户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
principal, err := s.principalForUser(r.Context(), user)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法加载 OneOS 用户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
response, err := s.issueSession(r, user.ID, principal, s.oneOSSessionTTL)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET last_login_at=NOW(3),failed_login_count=0,locked_until=NULL WHERE id=?`, user.ID)
|
||||
s.audit(r.Context(), principal.Name, "sso.exchange", "user", identity.Subject, "success", map[string]any{
|
||||
"scopeLevel": identity.ScopeLevel, "departmentIds": identity.DepartmentIDs,
|
||||
}, remoteAddress(r))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), oneOSTicketExchangeResponse{
|
||||
AccessToken: response.AccessToken,
|
||||
ExpiresAt: response.ExpiresAt,
|
||||
Session: response.Session,
|
||||
ReturnTo: identity.ReturnTo,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *authStore) syncOneOSIdentity(ctx context.Context, identity oneOSIdentity, menus []string) (authUser, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userID uint64
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`, identity.Subject).Scan(&userID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
result, insertErr := tx.ExecContext(ctx, `INSERT INTO platform_user(
|
||||
username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,created_by,updated_by
|
||||
) VALUES(?,?,?,'customer','enabled','',?,'oneos',?,'oneos-sso','oneos-sso')`,
|
||||
oneOSPlatformUsername(identity.Subject), oneOSDisplayName(identity), "", identity.TenantID, identity.Subject,
|
||||
)
|
||||
if insertErr != nil {
|
||||
return authUser{}, insertErr
|
||||
}
|
||||
insertedID, insertErr := result.LastInsertId()
|
||||
if insertErr != nil {
|
||||
return authUser{}, insertErr
|
||||
}
|
||||
userID = uint64(insertedID)
|
||||
case err != nil:
|
||||
return authUser{}, err
|
||||
default:
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE platform_user SET display_name=?,tenant_ref=?,status='enabled',updated_by='oneos-sso' WHERE id=?`,
|
||||
oneOSDisplayName(identity), identity.TenantID, userID); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
for _, menu := range menus {
|
||||
if !customerMenuSet[menu] {
|
||||
continue
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`, userID, menu); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
}
|
||||
departmentIDs := strings.Join(normalizeStringList(identity.DepartmentIDs, 100), ",")
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_business_scope(
|
||||
user_id,scope_level,department_ids,responsible_user_id,enabled,source_system,source_updated_at
|
||||
) VALUES(?,?,?,?,1,'oneos',?)
|
||||
ON DUPLICATE KEY UPDATE scope_level=VALUES(scope_level),department_ids=VALUES(department_ids),
|
||||
responsible_user_id=VALUES(responsible_user_id),enabled=1,source_system='oneos',
|
||||
source_updated_at=VALUES(source_updated_at)`,
|
||||
userID, identity.ScopeLevel, departmentIDs, strings.TrimSpace(identity.ResponsibleUserID), identity.IssuedAt); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return authUser{}, err
|
||||
}
|
||||
s.invalidateUser(userID)
|
||||
return authUser{
|
||||
ID: userID, Username: oneOSPlatformUsername(identity.Subject), DisplayName: oneOSDisplayName(identity),
|
||||
UserType: "customer", Status: "enabled", TenantRef: identity.TenantID,
|
||||
AuthProvider: "oneos", ExternalSubject: identity.Subject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *authStore) issueSession(r *http.Request, userID uint64, principal platform.Principal, ttl time.Duration) (loginResponse, error) {
|
||||
accessToken, tokenHash, err := randomSessionToken()
|
||||
if err != nil {
|
||||
return loginResponse{}, err
|
||||
}
|
||||
sessionID, err := randomHex(16)
|
||||
if err != nil {
|
||||
return loginResponse{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
expiresAt := now.Add(ttl)
|
||||
if _, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
sessionID, userID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255)); err != nil {
|
||||
return loginResponse{}, err
|
||||
}
|
||||
principal.SessionID = sessionID
|
||||
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
|
||||
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
|
||||
}
|
||||
|
||||
func oneOSPlatformUsername(subject string) string {
|
||||
normalized := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, strings.TrimSpace(subject))
|
||||
normalized = strings.Trim(normalized, "-")
|
||||
if normalized == "" {
|
||||
sum := sha256.Sum256([]byte(subject))
|
||||
normalized = hex.EncodeToString(sum[:8])
|
||||
}
|
||||
value := "oneos-" + normalized
|
||||
if len(value) > 64 {
|
||||
sum := sha256.Sum256([]byte(subject))
|
||||
value = "oneos-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func oneOSDisplayName(identity oneOSIdentity) string {
|
||||
if value := strings.TrimSpace(identity.DisplayName); value != "" {
|
||||
return truncateUTF8(value, 96)
|
||||
}
|
||||
return truncateUTF8(identity.Username, 96)
|
||||
}
|
||||
|
||||
func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) {
|
||||
@@ -363,6 +554,9 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
menus := append([]string(nil), adminMenus...)
|
||||
vehicles := []string{}
|
||||
vehicleGrants := []platform.VehicleGrant{}
|
||||
businessScopeLevel := ""
|
||||
departmentIDs := []string{}
|
||||
responsibleUserID := ""
|
||||
if user.UserType == "customer" {
|
||||
menus = []string{}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`, user.ID)
|
||||
@@ -404,14 +598,96 @@ func (s *authStore) principalForUser(ctx context.Context, user authUser) (platfo
|
||||
if err := rows.Close(); err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(user.AuthProvider), "oneos") {
|
||||
var rawDepartmentIDs string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT scope_level,department_ids,responsible_user_id
|
||||
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`, user.ID).Scan(
|
||||
&businessScopeLevel, &rawDepartmentIDs, &responsibleUserID,
|
||||
)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
if err == nil {
|
||||
departmentIDs = normalizeScopeValues(rawDepartmentIDs)
|
||||
businessVINs, err := s.loadOneOSBusinessVINs(ctx, businessScopeLevel, departmentIDs, responsibleUserID)
|
||||
if err != nil {
|
||||
return platform.Principal{}, err
|
||||
}
|
||||
vehicles = businessVINs
|
||||
vehicleGrants = make([]platform.VehicleGrant, 0, len(businessVINs))
|
||||
for _, vin := range businessVINs {
|
||||
vehicleGrants = append(vehicleGrants, platform.VehicleGrant{VIN: vin})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return platform.Principal{
|
||||
SubjectID: strconv.FormatUint(user.ID, 10), Name: user.DisplayName, Username: user.Username,
|
||||
Role: user.UserType, UserType: user.UserType, CustomerRef: user.CustomerRef, TenantRef: user.TenantRef,
|
||||
AuthProvider: user.AuthProvider, MenuKeys: menus, VehicleVINs: vehicles, VehicleGrants: vehicleGrants,
|
||||
BusinessScopeLevel: businessScopeLevel, DepartmentIDs: departmentIDs, ResponsibleUserID: strings.TrimSpace(responsibleUserID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeScopeValues(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
result := []string{}
|
||||
for _, value := range strings.Split(raw, ",") {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] || len(result) >= 100 {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *authStore) loadOneOSBusinessVINs(ctx context.Context, level string, departmentIDs []string, responsibleUserID string) ([]string, error) {
|
||||
where := []string{"st.id=1"}
|
||||
args := []any{}
|
||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||
case "department":
|
||||
if len(departmentIDs) == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(departmentIDs)), ",")
|
||||
where = append(where, "scope.department_id IN ("+placeholders+")")
|
||||
for _, id := range departmentIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
case "responsible":
|
||||
responsibleUserID = strings.TrimSpace(responsibleUserID)
|
||||
if responsibleUserID == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
where = append(where, "scope.responsible_user_id=?")
|
||||
args = append(args, responsibleUserID)
|
||||
default:
|
||||
return []string{}, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT UPPER(TRIM(scope.vin))
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope scope ON BINARY scope.source_version=BINARY st.active_version
|
||||
WHERE `+strings.Join(where, " AND ")+` AND scope.vin<>'' ORDER BY UPPER(TRIM(scope.vin))`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []string{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vin = strings.TrimSpace(vin); vin != "" {
|
||||
result = append(result, vin)
|
||||
}
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principal platform.Principal) bool {
|
||||
if r.URL.Path != "/api/v2/admin/users" && !strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/") {
|
||||
return false
|
||||
@@ -423,6 +699,8 @@ func (s *authStore) handleAdmin(w http.ResponseWriter, r *http.Request, principa
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/admin/users":
|
||||
s.listUsers(w, r)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users/batch":
|
||||
s.batchCustomers(w, r, principal)
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/admin/users":
|
||||
s.createCustomer(w, r, principal)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/admin/users/"):
|
||||
@@ -598,32 +876,127 @@ func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
id, grantChanges, err := s.createCustomerRecord(r.Context(), input, menus, grants, actor.Name)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
result, err := tx.ExecContext(r.Context(), `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name, actor.Name)
|
||||
if err != nil {
|
||||
writeUserMutationError(w, r, err, "创建")
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
grantChanges := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(r.Context(), tx, uint64(id), menus, grants, actor.Name, &grantChanges); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
writeUserMutationError(w, r, err, "创建")
|
||||
} else {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r))
|
||||
}
|
||||
return
|
||||
}
|
||||
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id})
|
||||
}
|
||||
|
||||
func (s *authStore) batchCustomers(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
var input userBatchRequest
|
||||
if !decodeAuthJSON(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Mode = strings.ToLower(strings.TrimSpace(input.Mode))
|
||||
if input.Mode != "preview" && input.Mode != "create" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量操作模式必须为 preview 或 create", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", fmt.Sprintf("每次需导入 1-%d 个客户账号", maxUserBatchItems), "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
|
||||
result := userBatchResult{Mode: input.Mode, Summary: userBatchSummary{Received: len(input.Items)}, Items: make([]userBatchResultItem, 0, len(input.Items))}
|
||||
seen := map[string]bool{}
|
||||
for index, item := range input.Items {
|
||||
row := item.Row
|
||||
if row <= 1 {
|
||||
row = index + 2
|
||||
}
|
||||
item.Input.Username = strings.TrimSpace(item.Input.Username)
|
||||
item.Input.DisplayName = strings.TrimSpace(item.Input.DisplayName)
|
||||
item.Input.Status = firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled")
|
||||
entry := userBatchResultItem{Row: row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
|
||||
usernameKey := strings.ToLower(item.Input.Username)
|
||||
if seen[usernameKey] {
|
||||
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内用户名重复"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
seen[usernameKey] = true
|
||||
menus, grants, err := s.validateMutation(r.Context(), item.Input, true)
|
||||
if err != nil {
|
||||
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
var existingID uint64
|
||||
err = s.db.QueryRowContext(r.Context(), `SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`, item.Input.Username).Scan(&existingID)
|
||||
if err == nil {
|
||||
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
entry.Status, entry.Code, entry.Message = "failed", "USER_CHECK_FAILED", "暂时无法校验用户名"
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
if input.Mode == "preview" {
|
||||
entry.Status, entry.Message = "ready", "校验通过,可以创建"
|
||||
result.Summary.Ready++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
id, grantChanges, err := s.createCustomerRecord(r.Context(), item.Input, menus, grants, actor.Name)
|
||||
if err != nil {
|
||||
entry.Status, entry.Code, entry.Message = "failed", "USER_CREATE_FAILED", "创建失败,请重试"
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
|
||||
}
|
||||
result.Summary.Failed++
|
||||
result.Items = append(result.Items, entry)
|
||||
continue
|
||||
}
|
||||
entry.Status, entry.Message, entry.ID = "created", "账号与权限已创建", id
|
||||
result.Summary.Created++
|
||||
result.Items = append(result.Items, entry)
|
||||
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"batch": true, "row": row, "menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
|
||||
}
|
||||
s.audit(r.Context(), actor.Name, "user.batch."+input.Mode, "user_batch", strconv.Itoa(len(input.Items)), "success", map[string]any{"received": result.Summary.Received, "ready": result.Summary.Ready, "created": result.Summary.Created, "failed": result.Summary.Failed}, remoteAddress(r))
|
||||
httpx.WriteOK(w, requestTraceID(r), result)
|
||||
}
|
||||
|
||||
func (s *authStore) createCustomerRecord(ctx context.Context, input userMutation, menus []string, grants []vehicleGrantMutation, actor string) (int64, []vehicleGrantAuditChange, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
created, err := tx.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?)`, strings.TrimSpace(input.Username), strings.TrimSpace(input.DisplayName), string(hash), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor, actor)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
id, err := created.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
grantChanges := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(ctx, tx, uint64(id), menus, grants, actor, &grantChanges); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return id, grantChanges, nil
|
||||
}
|
||||
|
||||
func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
|
||||
idText := strings.TrimPrefix(r.URL.Path, "/api/v2/admin/users/")
|
||||
id, err := strconv.ParseUint(idText, 10, 64)
|
||||
@@ -647,11 +1020,15 @@ func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var userType string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_type FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType); err != nil || userType != "customer" {
|
||||
var userType, authProvider string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType, &authProvider); err != nil || userType != "customer" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if authProvider = strings.TrimSpace(authProvider); authProvider != "" && !strings.EqualFold(authProvider, "local") && input.Password != "" {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", authProvider, requestTraceID(r))
|
||||
return
|
||||
}
|
||||
args := []any{strings.TrimSpace(input.DisplayName), input.Status, strings.TrimSpace(input.CustomerRef), strings.TrimSpace(input.TenantRef), actor.Name}
|
||||
query := `UPDATE platform_user SET display_name=?,status=?,customer_ref=?,tenant_ref=?,updated_by=?`
|
||||
if input.Password != "" {
|
||||
|
||||
@@ -1,14 +1,177 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
func TestBatchCustomersPreviewsReadyRowsAndFileDuplicatesIndependently(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
input := userBatchRequest{Mode: "preview", Items: []userBatchItem{
|
||||
{Row: 2, Input: userMutation{Username: "customer-east", DisplayName: "华东客户", Password: "ChangeMe2026!", Status: "enabled", MenuKeys: []string{"monitor"}, VehicleVINs: []string{"VIN001"}}},
|
||||
{Row: 3, Input: userMutation{Username: "CUSTOMER-EAST", DisplayName: "重复客户", Password: "ChangeMe2026!", Status: "enabled", MenuKeys: []string{"monitor"}, VehicleVINs: []string{"VIN001"}}},
|
||||
}}
|
||||
body, _ := json.Marshal(input)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (?)`)).
|
||||
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`)).
|
||||
WithArgs("customer-east").WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?)`)).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/admin/users/batch", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
store.batchCustomers(response, request, platform.Principal{Name: "平台管理员", UserType: "admin"})
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch preview failed: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data userBatchResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Data.Summary.Ready != 1 || envelope.Data.Summary.Failed != 1 || len(envelope.Data.Items) != 2 {
|
||||
t.Fatalf("unexpected batch summary: %+v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.Items[0].Status != "ready" || envelope.Data.Items[1].Code != "DUPLICATE_IN_FILE" {
|
||||
t.Fatalf("unexpected batch items: %+v", envelope.Data.Items)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForOneOSDepartmentRoleUsesDepartmentVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
|
||||
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("vehicles"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`)).
|
||||
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT scope_level,department_ids,responsible_user_id
|
||||
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
|
||||
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"scope_level", "department_ids", "responsible_user_id"}).AddRow("department", "40002,40001", "50001"))
|
||||
mock.ExpectQuery("SELECT DISTINCT UPPER").
|
||||
WithArgs("40001", "40002").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN-A").AddRow("VIN-B"))
|
||||
|
||||
principal, err := store.principalForUser(context.Background(), authUser{
|
||||
ID: 7, DisplayName: "部门负责人", Username: "leader", UserType: "customer", AuthProvider: "OneOS",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if principal.BusinessScopeLevel != "department" || strings.Join(principal.DepartmentIDs, ",") != "40001,40002" || strings.Join(principal.VehicleVINs, ",") != "VIN-A,VIN-B" {
|
||||
t.Fatalf("unexpected OneOS department principal: %+v", principal)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrincipalForOneOSOrdinaryUserUsesResponsibleVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key`)).
|
||||
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"menu_key"}).AddRow("vehicles"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`)).
|
||||
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to"}).AddRow("MANUAL-VIN", time.Now(), nil))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT scope_level,department_ids,responsible_user_id
|
||||
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`)).
|
||||
WithArgs(uint64(8)).WillReturnRows(sqlmock.NewRows([]string{"scope_level", "department_ids", "responsible_user_id"}).AddRow("responsible", "", "50008"))
|
||||
mock.ExpectQuery("SELECT DISTINCT UPPER").
|
||||
WithArgs("50008").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("OWN-VIN"))
|
||||
|
||||
principal, err := store.principalForUser(context.Background(), authUser{
|
||||
ID: 8, DisplayName: "普通业务", Username: "seller", UserType: "customer", AuthProvider: "oneos",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if principal.BusinessScopeLevel != "responsible" || principal.ResponsibleUserID != "50008" || strings.Join(principal.VehicleVINs, ",") != "OWN-VIN" {
|
||||
t.Fatalf("ordinary OneOS user must only see responsible vehicles: %+v", principal)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncOneOSIdentityCreatesCustomerMenusAndResponsibleScopeAtomically(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
issuedAt := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`)).
|
||||
WithArgs("50008").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user(
|
||||
username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,created_by,updated_by
|
||||
) VALUES(?,?,?,'customer','enabled','',?,'oneos',?,'oneos-sso','oneos-sso')`)).
|
||||
WithArgs("oneos-50008", "业务人员", "", "000000", "50008").
|
||||
WillReturnResult(sqlmock.NewResult(18, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).
|
||||
WithArgs(uint64(18)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`)).
|
||||
WithArgs(uint64(18), "vehicles").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_business_scope(
|
||||
user_id,scope_level,department_ids,responsible_user_id,enabled,source_system,source_updated_at
|
||||
) VALUES(?,?,?,?,1,'oneos',?)
|
||||
ON DUPLICATE KEY UPDATE scope_level=VALUES(scope_level),department_ids=VALUES(department_ids),
|
||||
responsible_user_id=VALUES(responsible_user_id),enabled=1,source_system='oneos',
|
||||
source_updated_at=VALUES(source_updated_at)`)).
|
||||
WithArgs(uint64(18), "responsible", "40002,40001", "50008", issuedAt).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
user, err := store.syncOneOSIdentity(context.Background(), oneOSIdentity{
|
||||
Subject: "50008", Username: "seller", DisplayName: "业务人员", TenantID: "000000",
|
||||
DepartmentIDs: []string{"40002", "40001"}, ScopeLevel: "responsible",
|
||||
ResponsibleUserID: "50008", IssuedAt: issuedAt,
|
||||
}, []string{"vehicles"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.ID != 18 || user.Username != "oneos-50008" || user.AuthProvider != "oneos" || user.UserType != "customer" {
|
||||
t.Fatalf("unexpected synced user: %+v", user)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -210,3 +373,36 @@ func TestNormalizeVehicleGrantMutationsRequiresOrderedInterval(t *testing.T) {
|
||||
t.Fatalf("valid authorization interval was not normalized: grants=%+v err=%v", grants, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCustomerRejectsPasswordResetForExternalIdentity(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := &authStore{db: db, cache: map[string]cachedSession{}}
|
||||
input := userMutation{
|
||||
DisplayName: "华东外部客户",
|
||||
Password: "ChangeMe2026!",
|
||||
Status: "enabled",
|
||||
MenuKeys: []string{"monitor"},
|
||||
VehicleVINs: []string{"VIN001"},
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN (?)`)).
|
||||
WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`)).
|
||||
WithArgs(uint64(7)).WillReturnRows(sqlmock.NewRows([]string{"user_type", "auth_provider"}).AddRow("customer", "OneOS"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/7", bytes.NewReader(body))
|
||||
response := httptest.NewRecorder()
|
||||
store.updateCustomer(response, request, platform.Principal{Name: "平台管理员", UserType: "admin"})
|
||||
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") {
|
||||
t.Fatalf("external password reset should be rejected: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -86,6 +87,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if operatorRule.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator rule mutation should be forbidden, status=%d", operatorRule.Code)
|
||||
}
|
||||
operatorRollback := authRequest(t, cfg, http.MethodPost, "/api/v2/alerts/rules/rule-1/rollback", operatorToken)
|
||||
if operatorRollback.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator rule rollback should be forbidden, status=%d", operatorRollback.Code)
|
||||
}
|
||||
operatorProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", operatorToken)
|
||||
if operatorProfile.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator profile mutation should be forbidden, status=%d", operatorProfile.Code)
|
||||
@@ -94,6 +99,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if operatorProfileSync.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code)
|
||||
}
|
||||
operatorIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", operatorToken)
|
||||
if operatorIdentityClaim.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator identity claim should be forbidden, status=%d", operatorIdentityClaim.Code)
|
||||
}
|
||||
viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken)
|
||||
if viewerSourceDiagnostic.Code != http.StatusForbidden {
|
||||
t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code)
|
||||
@@ -114,6 +123,18 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if operatorReconciliation.Code != http.StatusNoContent {
|
||||
t.Fatalf("operator reconciliation action status=%d body=%s", operatorReconciliation.Code, operatorReconciliation.Body.String())
|
||||
}
|
||||
operatorReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", operatorToken)
|
||||
if operatorReconciliationArchive.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator reconciliation archive should be forbidden, status=%d", operatorReconciliationArchive.Code)
|
||||
}
|
||||
adminReconciliationArchive := authRequest(t, cfg, http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-1/archive", adminToken)
|
||||
if adminReconciliationArchive.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin reconciliation archive status=%d body=%s", adminReconciliationArchive.Code, adminReconciliationArchive.Body.String())
|
||||
}
|
||||
operatorOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", operatorToken)
|
||||
if operatorOpenPlatform.Code != http.StatusForbidden {
|
||||
t.Fatalf("operator open-platform management should be forbidden, status=%d", operatorOpenPlatform.Code)
|
||||
}
|
||||
adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken)
|
||||
if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" {
|
||||
t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal"))
|
||||
@@ -126,10 +147,45 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) {
|
||||
if adminThreshold.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String())
|
||||
}
|
||||
adminIdentityClaim := authRequest(t, cfg, http.MethodPost, "/api/v2/access/unresolved-identities/identity-1/claim", adminToken)
|
||||
if adminIdentityClaim.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin identity claim status=%d body=%s", adminIdentityClaim.Code, adminIdentityClaim.Body.String())
|
||||
}
|
||||
adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken)
|
||||
if adminSourcePolicy.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String())
|
||||
}
|
||||
adminOpenPlatform := authRequest(t, cfg, http.MethodGet, "/api/v2/open-platform/apps", adminToken)
|
||||
if adminOpenPlatform.Code != http.StatusNoContent {
|
||||
t.Fatalf("admin open-platform management status=%d body=%s", adminOpenPlatform.Code, adminOpenPlatform.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledMockModeProvidesOperableIdentityDirectory(t *testing.T) {
|
||||
cfg := config.Config{AuthMode: "disabled", DataMode: "mock"}
|
||||
handler := withAPIAuth(http.NotFoundHandler(), cfg)
|
||||
|
||||
listResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(listResponse, httptest.NewRequest(http.MethodGet, "/api/v2/admin/users", nil))
|
||||
if listResponse.Code != http.StatusOK {
|
||||
t.Fatalf("mock directory status=%d body=%s", listResponse.Code, listResponse.Body.String())
|
||||
}
|
||||
var listEnvelope struct {
|
||||
Data []authUser `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(listResponse.Body.Bytes(), &listEnvelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listEnvelope.Data) < 4 || listEnvelope.Data[1].AuthProvider != "OneOS" || listEnvelope.Data[1].ExternalSubject == "" || listEnvelope.Data[2].ExternalSubject != "" {
|
||||
t.Fatalf("mock identity examples are incomplete: %+v", listEnvelope.Data)
|
||||
}
|
||||
|
||||
updateBody := `{"displayName":"华东数据客户","password":"ChangeMe2026!","status":"enabled","customerRef":"CUS-EAST","tenantRef":"tenant-east","menuKeys":["monitor"],"vehicleVins":["LMRKH9AC2R1004087"]}`
|
||||
updateResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(updateResponse, httptest.NewRequest(http.MethodPut, "/api/v2/admin/users/102", strings.NewReader(updateBody)))
|
||||
if updateResponse.Code != http.StatusBadRequest || !strings.Contains(updateResponse.Body.String(), "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY") {
|
||||
t.Fatalf("mock external credential ownership was not enforced: status=%d body=%s", updateResponse.Code, updateResponse.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthSessionAndDisabledMode(t *testing.T) {
|
||||
@@ -156,6 +212,18 @@ func TestAuthSelfServiceEndpointsAllowCustomerRole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileagePostQueriesAllowCustomerRole(t *testing.T) {
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
if role := requiredRole(req); role != "viewer" {
|
||||
t.Fatalf("%s should allow authenticated customers, required role=%s", path, role)
|
||||
}
|
||||
if menu := requiredMenu(req); menu != "statistics" {
|
||||
t.Fatalf("%s should use statistics menu scope, required menu=%s", path, menu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIAuthMisconfigurationFailsClosed(t *testing.T) {
|
||||
cases := []config.Config{
|
||||
{AuthMode: "enforce"},
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
type oneOSIdentity struct {
|
||||
Subject string `json:"subject"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TenantID string `json:"tenantId"`
|
||||
DepartmentIDs []string `json:"departmentIds"`
|
||||
DepartmentNames string `json:"departmentNames"`
|
||||
Permissions []string `json:"permissions"`
|
||||
ScopeLevel string `json:"scopeLevel"`
|
||||
ResponsibleUserID string `json:"responsibleUserId"`
|
||||
Audience string `json:"audience"`
|
||||
ReturnTo string `json:"returnTo"`
|
||||
IssuedAt time.Time `json:"issuedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type oneOSIntrospectionEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data oneOSIdentity `json:"data"`
|
||||
}
|
||||
|
||||
type oneOSIdentityAdapter struct {
|
||||
endpoint *url.URL
|
||||
serviceToken string
|
||||
signingKey []byte
|
||||
audience string
|
||||
defaultMenus []string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
const oneOSIntrospectionCanonicalPath = "/inner/v1/sso/vehicle-platform/introspect"
|
||||
|
||||
func newOneOSIdentityAdapter(cfg config.Config) (*oneOSIdentityAdapter, error) {
|
||||
if !cfg.OneOSSSOEnabled {
|
||||
return nil, nil
|
||||
}
|
||||
rawEndpoint := strings.TrimSpace(cfg.OneOSIntrospectionURL)
|
||||
endpoint, err := url.Parse(rawEndpoint)
|
||||
if err != nil || endpoint.Scheme == "" || endpoint.Host == "" || endpoint.User != nil || endpoint.Fragment != "" {
|
||||
return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if endpoint.Scheme != "https" && !isLoopbackHost(endpoint.Hostname()) {
|
||||
return nil, fmt.Errorf("ONEOS_SSO_INTROSPECTION_URL must use HTTPS outside localhost")
|
||||
}
|
||||
serviceToken := strings.TrimSpace(cfg.OneOSServiceToken)
|
||||
signingSecret := strings.TrimSpace(cfg.OneOSSigningSecret)
|
||||
if len(serviceToken) < 24 {
|
||||
return nil, fmt.Errorf("ONEOS_SSO_SERVICE_TOKEN must contain at least 24 characters")
|
||||
}
|
||||
if len(signingSecret) < 32 {
|
||||
return nil, fmt.Errorf("ONEOS_SSO_SIGNING_SECRET must contain at least 32 characters")
|
||||
}
|
||||
audience := strings.TrimSpace(cfg.OneOSAudience)
|
||||
if audience == "" {
|
||||
audience = "vehicle-platform"
|
||||
}
|
||||
timeout := cfg.OneOSRequestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
defaultMenus := make([]string, 0, len(cfg.OneOSDefaultMenus))
|
||||
for _, menu := range normalizeStringList(cfg.OneOSDefaultMenus, 4) {
|
||||
if customerMenuSet[menu] {
|
||||
defaultMenus = append(defaultMenus, menu)
|
||||
}
|
||||
}
|
||||
if len(defaultMenus) == 0 {
|
||||
defaultMenus = []string{"vehicles"}
|
||||
}
|
||||
return &oneOSIdentityAdapter{
|
||||
endpoint: endpoint, serviceToken: serviceToken, signingKey: []byte(signingSecret),
|
||||
audience: audience, defaultMenus: defaultMenus, client: &http.Client{Timeout: timeout},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *oneOSIdentityAdapter) ExchangeTicket(ctx context.Context, ticket string) (oneOSIdentity, error) {
|
||||
ticket = strings.TrimSpace(ticket)
|
||||
if len(ticket) < 32 || len(ticket) > 256 {
|
||||
return oneOSIdentity{}, fmt.Errorf("invalid ticket")
|
||||
}
|
||||
body, err := json.Marshal(struct {
|
||||
Ticket string `json:"ticket"`
|
||||
Audience string `json:"audience"`
|
||||
}{Ticket: ticket, Audience: a.audience})
|
||||
if err != nil {
|
||||
return oneOSIdentity{}, err
|
||||
}
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
requestID, err := secureRequestID()
|
||||
if err != nil {
|
||||
return oneOSIdentity{}, err
|
||||
}
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:])
|
||||
mac := hmac.New(sha256.New, a.signingKey)
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return oneOSIdentity{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Service "+a.serviceToken)
|
||||
request.Header.Set("X-OneOS-Timestamp", timestamp)
|
||||
request.Header.Set("X-OneOS-Request-Id", requestID)
|
||||
request.Header.Set("X-OneOS-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
response, err := a.client.Do(request)
|
||||
if err != nil {
|
||||
return oneOSIdentity{}, fmt.Errorf("call OneOS introspection: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if err != nil {
|
||||
return oneOSIdentity{}, fmt.Errorf("read OneOS introspection: %w", err)
|
||||
}
|
||||
var envelope oneOSIntrospectionEnvelope
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return oneOSIdentity{}, fmt.Errorf("decode OneOS introspection response: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK || envelope.Code != 200 {
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS rejected ticket: status=%d code=%d message=%s", response.StatusCode, envelope.Code, envelope.Msg)
|
||||
}
|
||||
identity := envelope.Data
|
||||
if strings.TrimSpace(identity.Subject) == "" || strings.TrimSpace(identity.Username) == "" {
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS identity is incomplete")
|
||||
}
|
||||
if !hmac.Equal([]byte(identity.Audience), []byte(a.audience)) {
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS identity audience mismatch")
|
||||
}
|
||||
now := time.Now()
|
||||
if identity.ExpiresAt.IsZero() || !identity.ExpiresAt.After(now) || identity.IssuedAt.After(now.Add(time.Minute)) {
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS ticket has expired or has an invalid issue time")
|
||||
}
|
||||
switch identity.ScopeLevel {
|
||||
case "department":
|
||||
if len(normalizeStringList(identity.DepartmentIDs, 100)) == 0 {
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS department scope has no department")
|
||||
}
|
||||
case "responsible":
|
||||
if strings.TrimSpace(identity.ResponsibleUserID) == "" {
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS responsible scope has no responsible user")
|
||||
}
|
||||
default:
|
||||
return oneOSIdentity{}, fmt.Errorf("OneOS identity has unsupported scope")
|
||||
}
|
||||
identity.DepartmentIDs = normalizeStringList(identity.DepartmentIDs, 100)
|
||||
identity.ReturnTo = safePlatformReturnTo(identity.ReturnTo)
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func secureRequestID() (string, error) {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value[:]), nil
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string, limit int) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] || len(result) >= limit {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func safePlatformReturnTo(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") {
|
||||
return "/vehicles"
|
||||
}
|
||||
for _, prefix := range []string{"/vehicles", "/monitor", "/tracks", "/statistics"} {
|
||||
if value == prefix || strings.HasPrefix(value, prefix+"/") || strings.HasPrefix(value, prefix+"?") {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return "/vehicles"
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(host)) {
|
||||
case "localhost", "127.0.0.1", "::1":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestOneOSIdentityAdapterSignsAndValidatesIntrospection(t *testing.T) {
|
||||
const serviceToken = "service-token-with-more-than-24-characters"
|
||||
const signingSecret = "signing-secret-with-at-least-32-characters"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
timestamp := r.Header.Get("X-OneOS-Timestamp")
|
||||
requestID := r.Header.Get("X-OneOS-Request-Id")
|
||||
bodyHash := sha256.Sum256(body)
|
||||
canonical := http.MethodPost + "\n" + oneOSIntrospectionCanonicalPath + "\n" + timestamp + "\n" + requestID + "\n" + hex.EncodeToString(bodyHash[:])
|
||||
mac := hmac.New(sha256.New, []byte(signingSecret))
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
if r.Header.Get("Authorization") != "Service "+serviceToken {
|
||||
t.Errorf("unexpected service authorization")
|
||||
}
|
||||
if !hmac.Equal([]byte(r.Header.Get("X-OneOS-Signature")), []byte(hex.EncodeToString(mac.Sum(nil)))) {
|
||||
t.Errorf("unexpected HMAC signature")
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"code": 200,
|
||||
"msg": "操作成功",
|
||||
"data": map[string]any{
|
||||
"subject": "50008", "username": "seller", "displayName": "业务人员",
|
||||
"tenantId": "000000", "departmentIds": []string{"40002", "40001", "40001"},
|
||||
"scopeLevel": "responsible", "responsibleUserId": "50008",
|
||||
"audience": "vehicle-platform", "returnTo": "https://evil.example/",
|
||||
"issuedAt": time.Now().Add(-time.Second), "expiresAt": time.Now().Add(time.Minute),
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
adapter, err := newOneOSIdentityAdapter(config.Config{
|
||||
OneOSSSOEnabled: true,
|
||||
OneOSIntrospectionURL: server.URL + "/auth" + oneOSIntrospectionCanonicalPath,
|
||||
OneOSServiceToken: serviceToken,
|
||||
OneOSSigningSecret: signingSecret,
|
||||
OneOSAudience: "vehicle-platform",
|
||||
OneOSDefaultMenus: []string{"vehicles", "users", "vehicles"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
identity, err := adapter.ExchangeTicket(context.Background(), strings.Repeat("a", 64))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if identity.Subject != "50008" || identity.ReturnTo != "/vehicles" || strings.Join(identity.DepartmentIDs, ",") != "40002,40001" {
|
||||
t.Fatalf("unexpected identity: %+v", identity)
|
||||
}
|
||||
if strings.Join(adapter.defaultMenus, ",") != "vehicles" {
|
||||
t.Fatalf("unexpected menus: %v", adapter.defaultMenus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneOSIdentityAdapterRequiresHTTPSOutsideLoopback(t *testing.T) {
|
||||
_, err := newOneOSIdentityAdapter(config.Config{
|
||||
OneOSSSOEnabled: true,
|
||||
OneOSIntrospectionURL: "http://oneos.example.com/auth" + oneOSIntrospectionCanonicalPath,
|
||||
OneOSServiceToken: strings.Repeat("t", 24),
|
||||
OneOSSigningSecret: strings.Repeat("s", 32),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "HTTPS") {
|
||||
t.Fatalf("expected HTTPS validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePlatformReturnToOnlyAllowsKnownApplicationRoutes(t *testing.T) {
|
||||
for input, expected := range map[string]string{
|
||||
"/tracks?vin=VIN001": "/tracks?vin=VIN001",
|
||||
"//evil.example": "/vehicles",
|
||||
"https://evil.test": "/vehicles",
|
||||
"/users": "/vehicles",
|
||||
} {
|
||||
if actual := safePlatformReturnTo(input); actual != expected {
|
||||
t.Fatalf("safePlatformReturnTo(%q)=%q want %q", input, actual, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/openplatform"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/static"
|
||||
)
|
||||
@@ -72,7 +74,12 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
log.Printf("production mysql store enabled")
|
||||
}
|
||||
}
|
||||
var api http.Handler = platform.NewHandler(platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
|
||||
host, _ := os.Hostname()
|
||||
workerHost := strings.TrimSpace(host)
|
||||
if workerHost == "" {
|
||||
workerHost = "api"
|
||||
}
|
||||
platformService := platform.NewServiceWithRuntime(store, platform.RuntimeInfo{
|
||||
DataMode: dataMode,
|
||||
ExportDir: strings.TrimSpace(cfg.ExportDir),
|
||||
RequestTimeoutMs: int(cfg.RequestTimeout / time.Millisecond),
|
||||
@@ -84,13 +91,30 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
PlatformRelease: strings.TrimSpace(cfg.PlatformRelease),
|
||||
AlertStreamMode: strings.TrimSpace(cfg.AlertStreamMode),
|
||||
AlertStreamConsumerGroup: strings.TrimSpace(cfg.AlertStreamKafkaGroup),
|
||||
}))
|
||||
AlertNotificationConfig: alertNotificationConfig(cfg, dataMode),
|
||||
HistoryCleanupAutomation: cfg.HistoryCleanupAutomation,
|
||||
HistoryCleanupPoll: cfg.HistoryCleanupPollInterval,
|
||||
HistoryCleanupLease: cfg.HistoryCleanupLease,
|
||||
HistoryCleanupWorkerID: "cleanup-scheduler-" + workerHost,
|
||||
})
|
||||
platformService.StartHistoryExportCleanupAutomation(context.Background())
|
||||
var api http.Handler = platform.NewHandler(platformService)
|
||||
if storeErr != nil {
|
||||
log.Printf("platform data store unavailable: %v", storeErr)
|
||||
api = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "DATA_STORE_UNAVAILABLE", "生产数据源不可用", storeErr.Error(), requestTraceID(r))
|
||||
})
|
||||
}
|
||||
if authDB != nil && storeErr == nil {
|
||||
openPlatformHandler := openplatform.NewAdminHandler(
|
||||
openplatform.NewService(openplatform.NewMySQLRepository(authDB)),
|
||||
openplatform.NewPortalService(authDB, cfg.SessionTTL),
|
||||
)
|
||||
internalMux := http.NewServeMux()
|
||||
internalMux.Handle("/api/v2/open-platform/", openPlatformHandler)
|
||||
internalMux.Handle("/", api)
|
||||
api = internalMux
|
||||
}
|
||||
// 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)
|
||||
@@ -100,6 +124,32 @@ func NewServer(cfg config.Config) http.Handler {
|
||||
return withRequestTimeout(handler, cfg.RequestTimeout)
|
||||
}
|
||||
|
||||
func alertNotificationConfig(cfg config.Config, dataMode string) platform.AlertNotificationConfig {
|
||||
channels := []platform.AlertNotificationChannelCapability{
|
||||
{Channel: "in_app", Label: "站内信", Configured: true},
|
||||
{Channel: "sms", Label: "短信", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationSMSURL) != "" && strings.TrimSpace(cfg.AlertNotificationSMSSecret) != ""},
|
||||
{Channel: "email", Label: "邮件", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationEmailURL) != "" && strings.TrimSpace(cfg.AlertNotificationEmailSecret) != ""},
|
||||
{Channel: "wecom", Label: "企业通讯", Configured: dataMode == "mock" || strings.TrimSpace(cfg.AlertNotificationWeComURL) != "" && strings.TrimSpace(cfg.AlertNotificationWeComSecret) != ""},
|
||||
}
|
||||
targets := []platform.AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}
|
||||
raw := strings.TrimSpace(cfg.AlertNotificationTargetsJSON)
|
||||
if raw == "" && dataMode == "mock" {
|
||||
targets = []platform.AlertNotificationTargetOption{
|
||||
{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app", "email", "wecom"}},
|
||||
{ID: "night-shift", Label: "夜班负责人", Channels: []string{"in_app", "sms", "wecom"}},
|
||||
{ID: "data-platform", Label: "数据平台负责人", Channels: []string{"in_app", "email", "wecom"}},
|
||||
}
|
||||
} else if raw != "" {
|
||||
var configured []platform.AlertNotificationTargetOption
|
||||
if err := json.Unmarshal([]byte(raw), &configured); err != nil {
|
||||
log.Printf("alert notification target catalog ignored: %v", err)
|
||||
} else {
|
||||
targets = configured
|
||||
}
|
||||
}
|
||||
return platform.NormalizeAlertNotificationConfig(platform.AlertNotificationConfig{Targets: targets, Channels: channels})
|
||||
}
|
||||
|
||||
func withAppConfig(next http.Handler, cfg config.Config) http.Handler {
|
||||
type appConfig struct {
|
||||
AMapWebJSKey string `json:"amapWebJsKey,omitempty"`
|
||||
|
||||
@@ -44,6 +44,40 @@ func TestProductionDataModeFailsClosedWithoutMySQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationConfigExposesOnlyReadyChannelsAndTargetReferences(t *testing.T) {
|
||||
cfg := config.Config{
|
||||
AlertNotificationTargetsJSON: `[{"id":"night-shift","label":"夜班负责人","channels":["sms","wecom"]}]`,
|
||||
AlertNotificationSMSURL: "https://gateway.example.test/sms",
|
||||
AlertNotificationSMSSecret: "signing-secret",
|
||||
AlertNotificationEmailURL: "https://gateway.example.test/email",
|
||||
}
|
||||
result := alertNotificationConfig(cfg, "production")
|
||||
if len(result.Channels) != 4 || !result.Channels[0].Configured || !result.Channels[1].Configured || result.Channels[2].Configured || result.Channels[3].Configured {
|
||||
t.Fatalf("gateway readiness must require both endpoint and secret: %+v", result.Channels)
|
||||
}
|
||||
if len(result.Targets) != 2 || result.Targets[0].ID != "platform-operators" || result.Targets[1].ID != "night-shift" {
|
||||
t.Fatalf("target catalog was not normalized: %+v", result.Targets)
|
||||
}
|
||||
encoded, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "signing-secret") || strings.Contains(string(encoded), "gateway.example.test") {
|
||||
t.Fatalf("public notification config leaked gateway credentials: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPlatformDocsAreServedOnlyByStandaloneService(t *testing.T) {
|
||||
handler := NewServer(config.Config{DataMode: "production", RequestTimeout: time.Second})
|
||||
for _, path := range []string{"/open-api/docs/", "/open-api/swagger/", "/open-api/openapi.yaml"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("internal service unexpectedly served standalone docs: path=%s status=%d body=%s", path, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRequestTimeoutReturnsEnvelopeWithTraceID(t *testing.T) {
|
||||
handler := withRequestTimeout(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
@@ -380,3 +414,20 @@ func TestServerRequiresAuthenticationForAMapReverseGeocode(t *testing.T) {
|
||||
t.Fatalf("anonymous reverse geocode status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDoesNotExposeOpenPlatformPublicDataRoutes(t *testing.T) {
|
||||
handler := NewServer(config.Config{
|
||||
AuthMode: "enforce",
|
||||
AuthTokensJSON: `[{"token":"0123456789abcdef","name":"test-viewer","role":"viewer"}]`,
|
||||
DataMode: "mock",
|
||||
RequestTimeout: time.Second,
|
||||
})
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/vehicles/hydrogen-consumption/query", nil)
|
||||
|
||||
handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("internal server should not expose anonymous open-platform data routes: status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,35 @@ const candidateQuery = `SELECT
|
||||
COALESCE(co.other_customer_id, co.customer_id),
|
||||
COALESCE(co.contract_code, r.contract_code, ''),
|
||||
COALESCE(co.project_name, r.project_name, ''),
|
||||
COALESCE(ci.customer_name, r.customer_name, ''),
|
||||
COALESCE(CAST(
|
||||
CASE WHEN co.other_customer_id IS NOT NULL
|
||||
THEN co.other_business_department_id
|
||||
ELSE co.business_department_id
|
||||
END AS CHAR
|
||||
), ''),
|
||||
COALESCE(
|
||||
CASE WHEN co.other_customer_id IS NOT NULL
|
||||
THEN co.other_business_department_name
|
||||
ELSE co.business_department_name
|
||||
END,
|
||||
r.business_dept,
|
||||
''
|
||||
),
|
||||
COALESCE(CAST(
|
||||
CASE WHEN co.other_customer_id IS NOT NULL
|
||||
THEN co.other_business_manager_id
|
||||
ELSE co.business_manager_id
|
||||
END AS CHAR
|
||||
), ''),
|
||||
COALESCE(
|
||||
CASE WHEN co.other_customer_id IS NOT NULL
|
||||
THEN co.other_business_manager_name
|
||||
ELSE co.business_manager_name
|
||||
END,
|
||||
r.business_manager,
|
||||
''
|
||||
),
|
||||
COALESCE(vs.operation_status, ''),
|
||||
dv.delivery_time,
|
||||
dv.update_time
|
||||
@@ -89,12 +118,14 @@ func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
|
||||
for rows.Next() {
|
||||
var vehicleID, vehicleProfileID, customerID, customerProfileID sql.NullInt64
|
||||
var contractID, contractProfileID, effectiveCustomerID sql.NullInt64
|
||||
var vin, plate, contractCode, projectName, operationStatus string
|
||||
var vin, plate, contractCode, projectName, customerName string
|
||||
var departmentID, departmentName, responsibleUserID, responsibleUserName, operationStatus string
|
||||
var scopeStart time.Time
|
||||
var updatedAt sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&vehicleID, &vehicleProfileID, &vin, &plate, &customerID, &customerProfileID,
|
||||
&contractID, &contractProfileID, &effectiveCustomerID, &contractCode, &projectName,
|
||||
&customerName, &departmentID, &departmentName, &responsibleUserID, &responsibleUserName,
|
||||
&operationStatus, &scopeStart, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan OneOS scope candidate: %w", err)
|
||||
@@ -108,8 +139,11 @@ func ReadCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
|
||||
ContractID: contractID.Int64, ContractPresent: contractID.Valid,
|
||||
ContractProfileExists: contractProfileID.Valid,
|
||||
EffectiveCustomerID: effectiveCustomerID.Int64, EffectiveCustomerSet: effectiveCustomerID.Valid,
|
||||
ContractCode: contractCode, ProjectName: projectName, OperationStatus: operationStatus,
|
||||
ScopeStartAt: scopeStart,
|
||||
CustomerName: customerName, ContractCode: contractCode, ProjectName: projectName,
|
||||
DepartmentID: departmentID, DepartmentName: departmentName,
|
||||
ResponsibleUserID: responsibleUserID, ResponsibleUserName: responsibleUserName,
|
||||
OperationStatus: operationStatus,
|
||||
ScopeStartAt: scopeStart,
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
value := updatedAt.Time
|
||||
|
||||
@@ -13,6 +13,11 @@ func TestCandidateQueryUsesCompletedLifecycleFactsInsteadOfAggregateReturnTime(t
|
||||
"rt.status IN (2, 3, 5)",
|
||||
"FROM delivery_vehicle newer",
|
||||
"LEFT JOIN vehicle_lease_order_record r",
|
||||
"ci.customer_name",
|
||||
"co.business_department_name",
|
||||
"co.other_business_department_name",
|
||||
"co.business_manager_name",
|
||||
"co.other_business_manager_name",
|
||||
}
|
||||
for _, expected := range assertions {
|
||||
if !strings.Contains(candidateQuery, expected) {
|
||||
|
||||
@@ -8,76 +8,120 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPAddr string
|
||||
StaticDir string
|
||||
MySQLDSN string
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
CapacityCheckBin string
|
||||
AuthToken string
|
||||
AuthMode string
|
||||
AuthTokensJSON string
|
||||
BootstrapAdminUsername string
|
||||
BootstrapAdminPassword string
|
||||
SessionTTL time.Duration
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
AMapWebJSKey string
|
||||
AMapAPIKey string
|
||||
AMapSecurityCode string
|
||||
AMapServiceHost string
|
||||
PlatformRelease string
|
||||
AlertEvaluationInterval time.Duration
|
||||
AlertStreamMode string
|
||||
AlertStreamKafkaBrokers []string
|
||||
AlertStreamKafkaTopics []string
|
||||
AlertStreamKafkaGroup string
|
||||
AlertStreamBatchSize int
|
||||
AlertStreamBatchWait time.Duration
|
||||
AlertStreamLateness time.Duration
|
||||
HTTPAddr string
|
||||
StaticDir string
|
||||
MySQLDSN string
|
||||
RedisAddr string
|
||||
RedisUsername string
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
TDengineDriver string
|
||||
TDengineDSN string
|
||||
TDengineDatabase string
|
||||
CapacityCheckBin string
|
||||
AuthToken string
|
||||
AuthMode string
|
||||
AuthTokensJSON string
|
||||
BootstrapAdminUsername string
|
||||
BootstrapAdminPassword string
|
||||
SessionTTL time.Duration
|
||||
OneOSSSOEnabled bool
|
||||
OneOSIntrospectionURL string
|
||||
OneOSServiceToken string
|
||||
OneOSSigningSecret string
|
||||
OneOSAudience string
|
||||
OneOSDefaultMenus []string
|
||||
OneOSRequestTimeout time.Duration
|
||||
OneOSSessionTTL time.Duration
|
||||
DataMode string
|
||||
ExportDir string
|
||||
RequestTimeout time.Duration
|
||||
AMapWebJSKey string
|
||||
AMapAPIKey string
|
||||
AMapSecurityCode string
|
||||
AMapServiceHost string
|
||||
PlatformRelease string
|
||||
AlertEvaluationInterval time.Duration
|
||||
AlertStreamMode string
|
||||
AlertStreamKafkaBrokers []string
|
||||
AlertStreamKafkaTopics []string
|
||||
AlertStreamKafkaGroup string
|
||||
AlertStreamBatchSize int
|
||||
AlertStreamBatchWait time.Duration
|
||||
AlertStreamLateness time.Duration
|
||||
AlertNotificationTargetsJSON string
|
||||
AlertNotificationSMSURL string
|
||||
AlertNotificationSMSSecret string
|
||||
AlertNotificationEmailURL string
|
||||
AlertNotificationEmailSecret string
|
||||
AlertNotificationWeComURL string
|
||||
AlertNotificationWeComSecret string
|
||||
AlertNotificationBatchSize int
|
||||
AlertNotificationPollInterval time.Duration
|
||||
AlertNotificationLease time.Duration
|
||||
AlertNotificationTimeout time.Duration
|
||||
HistoryCleanupAutomation bool
|
||||
HistoryCleanupPollInterval time.Duration
|
||||
HistoryCleanupLease time.Duration
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
HTTPAddr: env("HTTP_ADDR", ":20300"),
|
||||
StaticDir: env("STATIC_DIR", ""),
|
||||
MySQLDSN: os.Getenv("MYSQL_DSN"),
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
RedisDB: envInt("REDIS_DB", 50),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: os.Getenv("TDENGINE_DSN"),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
|
||||
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
|
||||
AuthToken: os.Getenv("AUTH_TOKEN"),
|
||||
AuthMode: authMode(),
|
||||
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
|
||||
BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"),
|
||||
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
||||
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
||||
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
||||
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
||||
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
||||
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second,
|
||||
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")),
|
||||
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")),
|
||||
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")),
|
||||
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"),
|
||||
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200),
|
||||
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second,
|
||||
HTTPAddr: env("HTTP_ADDR", ":20300"),
|
||||
StaticDir: env("STATIC_DIR", ""),
|
||||
MySQLDSN: os.Getenv("MYSQL_DSN"),
|
||||
RedisAddr: os.Getenv("REDIS_ADDR"),
|
||||
RedisUsername: os.Getenv("REDIS_USERNAME"),
|
||||
RedisPassword: os.Getenv("REDIS_PASSWORD"),
|
||||
RedisDB: envInt("REDIS_DB", 50),
|
||||
TDengineDriver: env("TDENGINE_DRIVER", "taosWS"),
|
||||
TDengineDSN: os.Getenv("TDENGINE_DSN"),
|
||||
TDengineDatabase: env("TDENGINE_DATABASE", "lingniu_vehicle_ts"),
|
||||
CapacityCheckBin: os.Getenv("CAPACITY_CHECK_BIN"),
|
||||
AuthToken: os.Getenv("AUTH_TOKEN"),
|
||||
AuthMode: authMode(),
|
||||
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"),
|
||||
BootstrapAdminUsername: os.Getenv("BOOTSTRAP_ADMIN_USERNAME"),
|
||||
BootstrapAdminPassword: os.Getenv("BOOTSTRAP_ADMIN_PASSWORD"),
|
||||
SessionTTL: time.Duration(envInt("AUTH_SESSION_TTL_HOURS", 12)) * time.Hour,
|
||||
OneOSSSOEnabled: envBool("ONEOS_SSO_ENABLED", false),
|
||||
OneOSIntrospectionURL: os.Getenv("ONEOS_SSO_INTROSPECTION_URL"),
|
||||
OneOSServiceToken: os.Getenv("ONEOS_SSO_SERVICE_TOKEN"),
|
||||
OneOSSigningSecret: os.Getenv("ONEOS_SSO_SIGNING_SECRET"),
|
||||
OneOSAudience: env("ONEOS_SSO_AUDIENCE", "vehicle-platform"),
|
||||
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,
|
||||
DataMode: dataMode(),
|
||||
ExportDir: os.Getenv("EXPORT_DIR"),
|
||||
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
AMapWebJSKey: os.Getenv("AMAP_WEB_JS_KEY"),
|
||||
AMapAPIKey: os.Getenv("AMAP_API_KEY"),
|
||||
AMapSecurityCode: os.Getenv("AMAP_SECURITY_JS_CODE"),
|
||||
AMapServiceHost: os.Getenv("AMAP_SECURITY_SERVICE_HOST"),
|
||||
PlatformRelease: os.Getenv("PLATFORM_RELEASE"),
|
||||
AlertEvaluationInterval: time.Duration(envInt("ALERT_EVALUATION_INTERVAL_SEC", 10)) * time.Second,
|
||||
AlertStreamMode: strings.ToLower(env("ALERT_STREAM_MODE", "disabled")),
|
||||
AlertStreamKafkaBrokers: splitCSV(firstEnv("ALERT_STREAM_KAFKA_BROKERS", "KAFKA_BROKERS")),
|
||||
AlertStreamKafkaTopics: splitCSV(env("ALERT_STREAM_KAFKA_TOPICS", "vehicle.fields.go.gb32960.v1,vehicle.fields.go.jt808.v1,vehicle.fields.go.yutong-mqtt.v1")),
|
||||
AlertStreamKafkaGroup: env("ALERT_STREAM_KAFKA_GROUP", "vehicle-alert-stream-v1"),
|
||||
AlertStreamBatchSize: envInt("ALERT_STREAM_BATCH_SIZE", 200),
|
||||
AlertStreamBatchWait: time.Duration(envInt("ALERT_STREAM_BATCH_WAIT_MS", 100)) * time.Millisecond,
|
||||
AlertStreamLateness: time.Duration(envInt("ALERT_STREAM_LATENESS_SEC", 120)) * time.Second,
|
||||
AlertNotificationTargetsJSON: os.Getenv("ALERT_NOTIFICATION_TARGETS_JSON"),
|
||||
AlertNotificationSMSURL: os.Getenv("ALERT_NOTIFICATION_SMS_URL"),
|
||||
AlertNotificationSMSSecret: os.Getenv("ALERT_NOTIFICATION_SMS_SECRET"),
|
||||
AlertNotificationEmailURL: os.Getenv("ALERT_NOTIFICATION_EMAIL_URL"),
|
||||
AlertNotificationEmailSecret: os.Getenv("ALERT_NOTIFICATION_EMAIL_SECRET"),
|
||||
AlertNotificationWeComURL: os.Getenv("ALERT_NOTIFICATION_WECOM_URL"),
|
||||
AlertNotificationWeComSecret: os.Getenv("ALERT_NOTIFICATION_WECOM_SECRET"),
|
||||
AlertNotificationBatchSize: envInt("ALERT_NOTIFICATION_BATCH_SIZE", 20),
|
||||
AlertNotificationPollInterval: time.Duration(envInt("ALERT_NOTIFICATION_POLL_INTERVAL_MS", 1000)) * time.Millisecond,
|
||||
AlertNotificationLease: time.Duration(envInt("ALERT_NOTIFICATION_LEASE_SEC", 30)) * time.Second,
|
||||
AlertNotificationTimeout: time.Duration(envInt("ALERT_NOTIFICATION_TIMEOUT_MS", 5000)) * time.Millisecond,
|
||||
HistoryCleanupAutomation: envBool("HISTORY_EXPORT_CLEANUP_AUTOMATION_ENABLED", false),
|
||||
HistoryCleanupPollInterval: time.Duration(envInt("HISTORY_EXPORT_CLEANUP_POLL_SEC", 60)) * time.Second,
|
||||
HistoryCleanupLease: time.Duration(envInt("HISTORY_EXPORT_CLEANUP_LEASE_SEC", 300)) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,3 +186,15 @@ func envInt(key string, fallback int) int {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) bool {
|
||||
raw := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
switch raw {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,3 +50,32 @@ func TestLoadKeepsExplicitMockDataMode(t *testing.T) {
|
||||
t.Fatalf("DataMode = %q, want mock", cfg.DataMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsAlertNotificationDispatcherSettings(t *testing.T) {
|
||||
t.Setenv("ALERT_NOTIFICATION_TARGETS_JSON", `[{"id":"night-shift","label":"夜班负责人","channels":["sms"]}]`)
|
||||
t.Setenv("ALERT_NOTIFICATION_SMS_URL", "https://gateway.example.test/sms")
|
||||
t.Setenv("ALERT_NOTIFICATION_SMS_SECRET", "signing-secret")
|
||||
t.Setenv("ALERT_NOTIFICATION_BATCH_SIZE", "48")
|
||||
t.Setenv("ALERT_NOTIFICATION_POLL_INTERVAL_MS", "750")
|
||||
t.Setenv("ALERT_NOTIFICATION_LEASE_SEC", "45")
|
||||
t.Setenv("ALERT_NOTIFICATION_TIMEOUT_MS", "8000")
|
||||
|
||||
cfg := Load()
|
||||
if cfg.AlertNotificationTargetsJSON == "" || cfg.AlertNotificationSMSURL != "https://gateway.example.test/sms" || cfg.AlertNotificationSMSSecret != "signing-secret" {
|
||||
t.Fatalf("notification target or gateway settings were not loaded: %+v", cfg)
|
||||
}
|
||||
if cfg.AlertNotificationBatchSize != 48 || cfg.AlertNotificationPollInterval != 750*time.Millisecond || cfg.AlertNotificationLease != 45*time.Second || cfg.AlertNotificationTimeout != 8*time.Second {
|
||||
t.Fatalf("notification worker timing settings were not loaded: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReadsHistoryCleanupAutomationSettings(t *testing.T) {
|
||||
t.Setenv("HISTORY_EXPORT_CLEANUP_AUTOMATION_ENABLED", "true")
|
||||
t.Setenv("HISTORY_EXPORT_CLEANUP_POLL_SEC", "45")
|
||||
t.Setenv("HISTORY_EXPORT_CLEANUP_LEASE_SEC", "420")
|
||||
|
||||
cfg := Load()
|
||||
if !cfg.HistoryCleanupAutomation || cfg.HistoryCleanupPollInterval != 45*time.Second || cfg.HistoryCleanupLease != 7*time.Minute {
|
||||
t.Fatalf("history cleanup automation settings were not loaded: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>车辆数据开放平台</title>
|
||||
<style>
|
||||
:root{color-scheme:light;--ink:#13213c;--muted:#60708c;--line:#dce3ed;--blue:#1768e5;--soft:#f4f7fb}
|
||||
*{box-sizing:border-box}body{margin:0;font:15px/1.65 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--ink);background:#fff}
|
||||
main{max-width:980px;margin:auto;padding:48px 24px 80px}header{padding:34px;border:1px solid var(--line);border-radius:18px;background:linear-gradient(135deg,#f5f9ff,#eef4ff)}
|
||||
h1{margin:0 0 10px;font-size:34px}h2{margin:42px 0 14px;font-size:22px}h3{margin:24px 0 10px;font-size:17px}
|
||||
p{margin:8px 0;color:var(--muted)}a{color:var(--blue)}nav{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}
|
||||
nav a{padding:8px 13px;border:1px solid #b9cef0;border-radius:9px;text-decoration:none;background:#fff}
|
||||
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}pre{overflow:auto;padding:18px;border-radius:12px;background:#101827;color:#e9f1ff;font-size:13px}
|
||||
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:11px;border-bottom:1px solid var(--line);vertical-align:top}th{background:var(--soft)}
|
||||
.method{display:inline-block;margin-right:8px;padding:2px 8px;border-radius:6px;background:#dff3e5;color:#136b35;font-weight:700}
|
||||
.note{padding:14px 16px;border-left:4px solid var(--blue);background:var(--soft);color:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>车辆数据开放平台</h1>
|
||||
<p>面向合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。接口使用独立的 32 位 appKey 认证。</p>
|
||||
<nav>
|
||||
<a href="/open-api/swagger/">Swagger 在线调试</a>
|
||||
<a href="/open-api/openapi.yaml">下载 OpenAPI 3.0</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<h2>认证</h2>
|
||||
<pre>Authorization: Bearer <32位appKey>
|
||||
Content-Type: application/json</pre>
|
||||
<p class="note">appKey 由平台管理员创建并授权车辆。Key 和逐车授权都必须完整覆盖所查询的自然日。</p>
|
||||
|
||||
<h2>开放接口</h2>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</h3>
|
||||
<p>查询指定车辆的单日用氢量,单位 kg。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/query</h3>
|
||||
<p>查询指定车辆的单日行驶里程、累计总里程、实际来源协议、车辆源数据时间和投影更新时间,单位 km。</p>
|
||||
<p class="note">以上两个按日接口的 plateNumbers 可选;省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</h3>
|
||||
<p>按最长 366 天区间分页查询逐车逐日里程。首次请求固化授权车辆清单,后续使用 nextCursor 翻页。</p>
|
||||
<p class="note">两个里程接口均可传 protocolPriority,唯一外部值为 GB32960、MQTT、JT808。逐车逐日按数组顺序选择第一个有效协议;未列出的协议完全禁用。省略字段时保持现有默认选源行为。</p>
|
||||
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
|
||||
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
|
||||
|
||||
<h2>总里程协议口径</h2>
|
||||
<table>
|
||||
<thead><tr><th>protocol 唯一规范值</th><th>总里程含义</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>GB32960</td><td>车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程</td></tr>
|
||||
<tr><td>YUTONG_MQTT</td><td>车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报</td></tr>
|
||||
<tr><td>JT808</td><td>定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="note">protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。接口取不晚于请求时间的最近记录,不跨协议拼接里程。</p>
|
||||
|
||||
<h2>请求示例</h2>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/hydrogen-consumption/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"plateNumbers": ["粤A12345", "粤B67890"],
|
||||
"date": "2026-07-01"
|
||||
}'</pre>
|
||||
|
||||
<h3>查询全部授权车辆的单日数据</h3>
|
||||
<pre>{
|
||||
"date": "2026-07-01"
|
||||
}</pre>
|
||||
|
||||
<h3>按自定义协议优先级查询单日里程</h3>
|
||||
<pre>{
|
||||
"plateNumbers": ["粤A12345"],
|
||||
"date": "2026-07-01",
|
||||
"protocolPriority": ["JT808", "GB32960", "MQTT"]
|
||||
}</pre>
|
||||
|
||||
<h3>指定时刻总里程</h3>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/total-mileage/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"time": "2026-07-21 09:30:00",
|
||||
"protocol": "GB32960"
|
||||
}'</pre>
|
||||
|
||||
<h3>车辆区间日里程</h3>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/mileage/range/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"startDate": "2026-07-01",
|
||||
"endDate": "2026-07-23",
|
||||
"protocolPriority": ["GB32960", "MQTT"],
|
||||
"pageSize": 5000
|
||||
}'</pre>
|
||||
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。</p>
|
||||
|
||||
<h2>响应示例</h2>
|
||||
<h3>车辆单日里程</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "粤A12345",
|
||||
"date": "2026-07-01",
|
||||
"dailyMileageKm": 182.437,
|
||||
"totalMileageKm": 12345.679,
|
||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||
"sourceProtocol": "GB32960",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "b7ff5582ab1a4e13bfb4f10943685599"
|
||||
}</pre>
|
||||
<p class="note">里程状态为 NORMAL 时,dailyMileageKm、totalMileageKm、dataTime 与 updatedAt 均有值;NO_DATA 时相关数据字段为 null,真实零里程仍为 NORMAL。</p>
|
||||
<h3>车辆区间日里程响应</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "粤A12345",
|
||||
"date": "2026-07-01",
|
||||
"dailyMileageKm": 182.437,
|
||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"snapshotId": "9f8a74efbf9846349ae5676f3a5c0de8",
|
||||
"nextCursor": null,
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}</pre>
|
||||
<h3>车辆单日用氢量</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"plateNumber": "粤A12345",
|
||||
"date": "2026-07-01",
|
||||
"hydrogenConsumptionKg": 12.315,
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}</pre>
|
||||
|
||||
<h3>指定时刻总里程响应</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"queryTime": "2026-07-21 09:30:00",
|
||||
"totalMileageKm": 12345.678,
|
||||
"protocol": "GB32960",
|
||||
"protocolInput": "GB32960",
|
||||
"mileageMeaning": "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)",
|
||||
"recordTime": "2026-07-21 09:29:45",
|
||||
"timeDifferenceSeconds": 15,
|
||||
"selectionPolicy": "GB32960 > YUTONG_MQTT > JT808",
|
||||
"status": "NORMAL"
|
||||
},
|
||||
"traceId": "95bddca78133474fa2bf56ecdf758e22"
|
||||
}</pre>
|
||||
|
||||
<h2>状态与错误码</h2>
|
||||
<table>
|
||||
<thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>200</td><td>SUCCESS</td><td>查询成功;无统计数据的车辆以 NO_DATA 返回</td></tr>
|
||||
<tr><td>400</td><td>INVALID_REQUEST</td><td>请求格式、车牌或数量不正确</td></tr>
|
||||
<tr><td>400</td><td>INVALID_DATE_FORMAT</td><td>日期不是 yyyy-MM-dd</td></tr>
|
||||
<tr><td>400</td><td>INVALID_DATETIME_FORMAT</td><td>时间不是 yyyy-MM-dd HH:mm:ss</td></tr>
|
||||
<tr><td>401</td><td>UNAUTHORIZED</td><td>appKey 不存在、停用或过期</td></tr>
|
||||
<tr><td>403</td><td>FORBIDDEN</td><td>Key 或车辆授权未覆盖查询自然日</td></tr>
|
||||
<tr><td>500</td><td>INTERNAL_ERROR</td><td>服务内部异常</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,743 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.5.0
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。
|
||||
appKey 和逐车授权必须完整覆盖查询自然日。
|
||||
servers:
|
||||
- url: /
|
||||
description: 当前服务
|
||||
tags:
|
||||
- name: 合作方数据接口
|
||||
description: 使用 appKey 查询已授权车辆的日统计数据
|
||||
- name: 开放平台管理
|
||||
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
||||
paths:
|
||||
/api/v1/vehicles/hydrogen-consumption/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询车辆单日用氢量
|
||||
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
operationId: queryDailyHydrogenConsumption
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VehicleDailyQuery'
|
||||
example:
|
||||
date: '2026-07-01'
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;无数据车辆仍保留在结果中
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/mileage/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询车辆单日里程
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。
|
||||
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
|
||||
operationId: queryDailyMileage
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageDailyQuery'
|
||||
example:
|
||||
date: '2026-07-01'
|
||||
protocolPriority: [GB32960, MQTT, JT808]
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;无数据车辆仍保留在结果中
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/mileage/range/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 分页查询车辆区间日里程
|
||||
description: |
|
||||
最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
|
||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。
|
||||
operationId: queryDailyMileageRange
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageRangeQuery'
|
||||
example:
|
||||
startDate: '2026-07-01'
|
||||
endDate: '2026-07-23'
|
||||
plateNumbers: [沪A00001, 沪A00002]
|
||||
protocolPriority: [JT808, GB32960, MQTT]
|
||||
cursor: null
|
||||
pageSize: 5000
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;每辆授权车辆每天均有一条 NORMAL 或 NO_DATA 记录
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageRangeQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/total-mileage/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询指定时刻的车辆总里程
|
||||
description: |
|
||||
返回不晚于请求时间的最近一条有效总里程记录、实际记录时间和时间差秒数。
|
||||
protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。
|
||||
GB32960 和 YUTONG_MQTT 为车辆仪表盘或车端累计里程;JT808 为定位终端/GPS侧累计里程。
|
||||
operationId: queryTotalMileageAtTime
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotalMileageQuery'
|
||||
example:
|
||||
vin: LA9GG68L2PBAF4790
|
||||
time: '2026-07-21 09:30:00'
|
||||
protocol: GB32960
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;没有可用记录时返回 NO_DATA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotalMileageQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v2/open-platform/apps:
|
||||
get:
|
||||
tags: [开放平台管理]
|
||||
summary: 查询开放平台应用
|
||||
operationId: listOpenPlatformApps
|
||||
security:
|
||||
- AdminBearer: []
|
||||
responses:
|
||||
'200':
|
||||
description: 应用列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminAppListResponse'
|
||||
'401':
|
||||
description: 未登录
|
||||
'403':
|
||||
description: 非管理员
|
||||
post:
|
||||
tags: [开放平台管理]
|
||||
summary: 创建应用和 appKey
|
||||
description: appKey 明文仅在本次响应中返回。
|
||||
operationId: createOpenPlatformApp
|
||||
security:
|
||||
- AdminBearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppInput'
|
||||
responses:
|
||||
'200':
|
||||
description: 创建成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminAppCreatedResponse'
|
||||
'400':
|
||||
description: 参数错误
|
||||
'401':
|
||||
description: 未登录
|
||||
'403':
|
||||
description: 非管理员
|
||||
/api/v2/open-platform/apps/{id}:
|
||||
put:
|
||||
tags: [开放平台管理]
|
||||
summary: 更新应用状态和有效期
|
||||
operationId: updateOpenPlatformApp
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppInput'
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功
|
||||
'400':
|
||||
description: 参数错误
|
||||
'404':
|
||||
description: 应用不存在
|
||||
/api/v2/open-platform/apps/{id}/rotate-key:
|
||||
post:
|
||||
tags: [开放平台管理]
|
||||
summary: 轮换 appKey
|
||||
description: 旧 Key 立即失效,新 Key 明文仅在本次响应中返回。
|
||||
operationId: rotateOpenPlatformAppKey
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
responses:
|
||||
'200':
|
||||
description: 轮换成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminAppCreatedResponse'
|
||||
'404':
|
||||
description: 应用不存在
|
||||
/api/v2/open-platform/apps/{id}/vehicles:
|
||||
get:
|
||||
tags: [开放平台管理]
|
||||
summary: 查询应用的车辆授权
|
||||
operationId: listOpenPlatformVehicleGrants
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
responses:
|
||||
'200':
|
||||
description: 授权列表
|
||||
'404':
|
||||
description: 应用不存在
|
||||
put:
|
||||
tags: [开放平台管理]
|
||||
summary: 完整替换车辆授权
|
||||
operationId: replaceOpenPlatformVehicleGrants
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VehicleGrantRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 替换成功
|
||||
'400':
|
||||
description: VIN 或有效期不正确
|
||||
'404':
|
||||
description: 应用不存在
|
||||
components:
|
||||
securitySchemes:
|
||||
AppKeyAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: 32-character appKey
|
||||
description: 32 位无连字符 UUID appKey
|
||||
AdminBearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: Platform session token
|
||||
description: 车辆数据平台管理员令牌
|
||||
parameters:
|
||||
AppId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
responses:
|
||||
BadRequest:
|
||||
description: 请求参数不正确
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
invalidDate:
|
||||
value:
|
||||
code: INVALID_DATE_FORMAT
|
||||
message: date格式必须为yyyy-MM-dd
|
||||
traceId: 4ccf63c4e51d4d4ab9107d931783a53e
|
||||
Unauthorized:
|
||||
description: appKey 不存在、停用或过期
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
Forbidden:
|
||||
description: Key 或车辆授权未完整覆盖查询自然日
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
InternalError:
|
||||
description: 服务内部异常
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
schemas:
|
||||
VehicleDailyQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [date]
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
description: 查询自然日,yyyy-MM-dd
|
||||
MileageDailyQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [date]
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
description: 查询自然日,yyyy-MM-dd
|
||||
protocolPriority:
|
||||
$ref: '#/components/schemas/ProtocolPriority'
|
||||
MileageRangeQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [startDate, endDate]
|
||||
properties:
|
||||
startDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 区间开始自然日,Asia/Shanghai
|
||||
endDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 5000
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;省略或传空数组时查询整个区间均有效授权的全部车辆
|
||||
protocolPriority:
|
||||
$ref: '#/components/schemas/ProtocolPriority'
|
||||
cursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 首次查询传 null 或省略;翻页时原样传入上一页 nextCursor
|
||||
pageSize:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
ProtocolPriority:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 3
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
description: 可选;按数组顺序逐车逐日选择第一个有效协议。未列出的协议被禁用且不会作为兜底;省略字段时保持平台默认选源行为
|
||||
TotalMileageQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [vin, time]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
pattern: '^[A-HJ-NPR-Z0-9]{17}$'
|
||||
description: 已授权车辆 VIN
|
||||
time:
|
||||
type: string
|
||||
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
|
||||
description: 请求时刻,北京时间,固定格式 yyyy-MM-dd HH:mm:ss
|
||||
protocol:
|
||||
type: string
|
||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
||||
HydrogenResult:
|
||||
type: object
|
||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
||||
properties:
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
hydrogenConsumptionKg:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日用氢量,kg;无数据时为 null
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
dailyMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日里程,km;当日无记录但存在历史累计里程时为 0
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 本次统计实际采用的最后一条车辆源数据时间;status=NO_DATA 时为 null
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 本行所依据统计周期的计算时间;前向填充时为上一统计周期计算时间,status=NO_DATA 时为 null
|
||||
sourceProtocol:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
nullable: true
|
||||
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageRangeResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
dailyMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: 当日无记录但存在历史累计里程时为 0
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 本行所依据统计周期的计算时间;缺日补齐时为上一统计周期计算时间
|
||||
sourceProtocol:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
nullable: true
|
||||
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
DataStatus:
|
||||
type: string
|
||||
enum: [NORMAL, NO_DATA]
|
||||
HydrogenQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HydrogenResult'
|
||||
MileageQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MileageResult'
|
||||
MileageRangeQueryResponse:
|
||||
type: object
|
||||
required: [code, message, data, snapshotId, nextCursor, traceId]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [SUCCESS]
|
||||
message:
|
||||
type: string
|
||||
example: success
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MileageRangeResult'
|
||||
snapshotId:
|
||||
type: string
|
||||
description: 同一次分页查询保持不变的授权车辆快照标识
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 下一页游标;最后一页为 null
|
||||
traceId:
|
||||
type: string
|
||||
TotalMileageResult:
|
||||
type: object
|
||||
required: [vin, queryTime, totalMileageKm, selectionPolicy, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
queryTime:
|
||||
type: string
|
||||
description: 请求时刻,北京时间
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 总里程,km;无数据时为 null
|
||||
protocol:
|
||||
type: string
|
||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||
description: 实际采用的采集协议
|
||||
protocolInput:
|
||||
type: string
|
||||
description: 请求中指定的协议原值;未指定时省略
|
||||
mileageMeaning:
|
||||
type: string
|
||||
description: 当前协议总里程的业务含义
|
||||
recordTime:
|
||||
type: string
|
||||
description: 命中的实际采集记录时间,北京时间
|
||||
timeDifferenceSeconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: 请求时间减实际记录时间,单位秒
|
||||
selectionPolicy:
|
||||
type: string
|
||||
example: GB32960 > YUTONG_MQTT > JT808
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
TotalMileageQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TotalMileageResult'
|
||||
SuccessEnvelope:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [SUCCESS]
|
||||
message:
|
||||
type: string
|
||||
example: success
|
||||
traceId:
|
||||
type: string
|
||||
example: 4ccf63c4e51d4d4ab9107d931783a53e
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- INVALID_REQUEST
|
||||
- INVALID_DATE_FORMAT
|
||||
- UNAUTHORIZED
|
||||
- FORBIDDEN
|
||||
- INTERNAL_ERROR
|
||||
message:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
AppInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [name, status, validFrom]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 96
|
||||
example: 示例合作方
|
||||
status:
|
||||
type: string
|
||||
enum: [enabled, disabled]
|
||||
validFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
example: '2026-07-01T00:00:00+08:00'
|
||||
validTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: '2027-07-01T00:00:00+08:00'
|
||||
App:
|
||||
type: object
|
||||
required: [id, name, appKeyPrefix, status, validFrom]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
name:
|
||||
type: string
|
||||
appKeyPrefix:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 8
|
||||
status:
|
||||
type: string
|
||||
enum: [enabled, disabled]
|
||||
validFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
validTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
AppCreated:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/App'
|
||||
- type: object
|
||||
required: [appKey]
|
||||
properties:
|
||||
appKey:
|
||||
type: string
|
||||
minLength: 32
|
||||
maxLength: 32
|
||||
pattern: '^[0-9a-f]{32}$'
|
||||
description: 仅本次响应返回
|
||||
VehicleGrantRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [vehicles]
|
||||
properties:
|
||||
vehicles:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items:
|
||||
$ref: '#/components/schemas/VehicleGrantInput'
|
||||
VehicleGrantInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [vin, validFrom]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
minLength: 17
|
||||
maxLength: 17
|
||||
validFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
validTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
AdminAppListResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/App'
|
||||
traceId:
|
||||
type: string
|
||||
AdminAppCreatedResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AppCreated'
|
||||
traceId:
|
||||
type: string
|
||||
@@ -0,0 +1,17 @@
|
||||
window.addEventListener("load", function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/open-api/openapi.yaml",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
persistAuthorization: false,
|
||||
tryItOutEnabled: false,
|
||||
validatorUrl: null,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>车辆数据开放平台 Swagger</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"></script>
|
||||
<script src="/open-api/swagger-init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
vehicle-data-platform/apps/api/internal/openplatform/docs.go
Normal file
83
vehicle-data-platform/apps/api/internal/openplatform/docs.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
OpenAPISpecPath = "/open-api/openapi.yaml"
|
||||
SwaggerUIPath = "/open-api/swagger/"
|
||||
SimpleDocsPath = "/open-api/docs/"
|
||||
swaggerInitJSPath = "/open-api/swagger-init.js"
|
||||
)
|
||||
|
||||
//go:embed assets/openapi.yaml
|
||||
var openAPISpec []byte
|
||||
|
||||
//go:embed assets/swagger.html
|
||||
var swaggerHTML []byte
|
||||
|
||||
//go:embed assets/swagger-init.js
|
||||
var swaggerInitJS []byte
|
||||
|
||||
//go:embed assets/docs.html
|
||||
var simpleDocsHTML []byte
|
||||
|
||||
// WithDocs exposes version-matched API documentation without requiring a
|
||||
// production database connection or an authenticated platform session.
|
||||
func WithDocs(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open-api":
|
||||
redirectDocs(w, r, SimpleDocsPath)
|
||||
case "/open-api/swagger":
|
||||
redirectDocs(w, r, SwaggerUIPath)
|
||||
case "/open-api/docs":
|
||||
redirectDocs(w, r, SimpleDocsPath)
|
||||
case OpenAPISpecPath:
|
||||
serveDocsAsset(w, r, "application/yaml; charset=utf-8", openAPISpec, "")
|
||||
case SwaggerUIPath:
|
||||
serveDocsAsset(w, r, "text/html; charset=utf-8", swaggerHTML,
|
||||
"default-src 'none'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self'; font-src https://cdn.jsdelivr.net; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
case swaggerInitJSPath:
|
||||
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
|
||||
case SimpleDocsPath:
|
||||
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML,
|
||||
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
default:
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func redirectDocs(w http.ResponseWriter, r *http.Request, target string) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func serveDocsAsset(w http.ResponseWriter, r *http.Request, contentType string, body []byte, csp string) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if strings.TrimSpace(csp) != "" {
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) {
|
||||
handler := WithDocs(http.NotFoundHandler())
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
want string
|
||||
}{
|
||||
{OpenAPISpecPath, "application/yaml", "openapi: 3.0.3"},
|
||||
{SwaggerUIPath, "text/html", "swagger-ui-bundle.js"},
|
||||
{SimpleDocsPath, "text/html", "车辆数据开放平台"},
|
||||
{swaggerInitJSPath, "application/javascript", `persistAuthorization: false`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.HasPrefix(recorder.Header().Get("Content-Type"), test.contentType) {
|
||||
t.Fatalf("content-type=%q", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), test.want) {
|
||||
t.Fatalf("body does not contain %q", test.want)
|
||||
}
|
||||
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("documentation asset must set nosniff")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsRedirectsAndRejectsMutatingMethods(t *testing.T) {
|
||||
handler := WithDocs(http.NotFoundHandler())
|
||||
redirect := httptest.NewRecorder()
|
||||
handler.ServeHTTP(redirect, httptest.NewRequest(http.MethodGet, "/open-api/docs", nil))
|
||||
if redirect.Code != http.StatusPermanentRedirect || redirect.Header().Get("Location") != SimpleDocsPath {
|
||||
t.Fatalf("status=%d location=%q", redirect.Code, redirect.Header().Get("Location"))
|
||||
}
|
||||
|
||||
post := httptest.NewRecorder()
|
||||
handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, OpenAPISpecPath, nil))
|
||||
if post.Code != http.StatusMethodNotAllowed || post.Header().Get("Allow") != "GET, HEAD" {
|
||||
t.Fatalf("status=%d allow=%q", post.Code, post.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
|
||||
spec := string(openAPISpec)
|
||||
for _, want := range []string{
|
||||
"openapi: 3.0.3",
|
||||
HydrogenQueryPath + ":",
|
||||
MileageQueryPath + ":",
|
||||
MileageRangeQueryPath + ":",
|
||||
"/api/v2/open-platform/apps:",
|
||||
"AppKeyAuth:",
|
||||
"AdminBearer:",
|
||||
"省略或传空数组时",
|
||||
"protocolPriority:",
|
||||
"sourceProtocol:",
|
||||
"enum: [GB32960, MQTT, JT808]",
|
||||
} {
|
||||
if !strings.Contains(spec, want) {
|
||||
t.Fatalf("OpenAPI spec missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
729
vehicle-data-platform/apps/api/internal/openplatform/handler.go
Normal file
729
vehicle-data-platform/apps/api/internal/openplatform/handler.go
Normal file
@@ -0,0 +1,729 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const (
|
||||
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
|
||||
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
||||
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
||||
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
portal *PortalService
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.registerExternalDataRoutes()
|
||||
handler.registerAdminAppRoutes()
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewAdminHandler(service *Service, portal *PortalService) *Handler {
|
||||
handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()}
|
||||
handler.registerAdminAppRoutes()
|
||||
if portal != nil {
|
||||
handler.registerAdminUserRoutes()
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewExternalHandler(service *Service, portal *PortalService) *Handler {
|
||||
handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()}
|
||||
handler.registerExternalDataRoutes()
|
||||
if portal != nil {
|
||||
handler.registerPortalRoutes()
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Handler) registerExternalDataRoutes() {
|
||||
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||
}
|
||||
|
||||
func (h *Handler) registerAdminAppRoutes() {
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/apps", h.listApps)
|
||||
h.mux.HandleFunc("POST /api/v2/open-platform/apps", h.createApp)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}", h.updateApp)
|
||||
h.mux.HandleFunc("POST /api/v2/open-platform/apps/{id}/rotate-key", h.rotateKey)
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/apps/{id}/vehicles", h.listVehicleGrants)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}/vehicles", h.replaceVehicleGrants)
|
||||
}
|
||||
|
||||
func (h *Handler) registerAdminUserRoutes() {
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/users", h.listPortalUsers)
|
||||
h.mux.HandleFunc("POST /api/v2/open-platform/users", h.createPortalUser)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}", h.updatePortalUser)
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/users/{id}/apps", h.listPortalUserApps)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}/apps", h.replacePortalUserApps)
|
||||
}
|
||||
|
||||
func (h *Handler) registerPortalRoutes() {
|
||||
h.mux.HandleFunc("POST /portal-api/auth/login", h.portalLogin)
|
||||
h.mux.HandleFunc("POST /portal-api/auth/logout", h.portalLogout)
|
||||
h.mux.HandleFunc("GET /portal-api/session", h.portalSession)
|
||||
h.mux.HandleFunc("GET /portal-api/catalog", h.portalCatalog)
|
||||
h.mux.HandleFunc("GET /portal-api/apps", h.portalApps)
|
||||
h.mux.HandleFunc("GET /portal-api/apps/{id}/vehicles", h.portalVehicles)
|
||||
h.mux.HandleFunc("GET /portal-api/apps/{id}/audit", h.portalAudit)
|
||||
h.mux.HandleFunc("POST /portal-api/apps/{id}/rotate-key", h.portalRotateKey)
|
||||
h.mux.HandleFunc("PUT /portal-api/account/password", h.portalChangePassword)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/apps", h.portalAdminListApps)
|
||||
h.mux.HandleFunc("POST /portal-api/admin/apps", h.portalAdminCreateApp)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}", h.portalAdminUpdateApp)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}/vehicles", h.portalAdminReplaceVehicles)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/vehicles", h.portalAdminVehicleCatalog)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/users", h.portalAdminListUsers)
|
||||
h.mux.HandleFunc("POST /portal-api/admin/users", h.portalAdminCreateUser)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/users/{id}", h.portalAdminUpdateUser)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/users/{id}/apps", h.portalAdminListUserApps)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/users/{id}/apps", h.portalAdminReplaceUserApps)
|
||||
}
|
||||
|
||||
func NewDataHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request QueryRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryHydrogen(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) mileage(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request QueryRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryMileage(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) mileageRange(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request MileageRangeRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
response, err := h.service.QueryMileageRange(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) totalMileage(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request TotalMileageQueryRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryTotalMileage(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) listApps(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ListApps(r.Context())
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) createApp(w http.ResponseWriter, r *http.Request) {
|
||||
var input AppInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CreateApp(r.Context(), input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) updateApp(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input AppInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateApp(r.Context(), id, input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) rotateKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RotateKey(r.Context(), id, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listVehicleGrants(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ListVehicleGrants(r.Context(), id)
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) replaceVehicleGrants(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request VehicleGrantRequest
|
||||
if !decodeAdminBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ReplaceVehicleGrants(r.Context(), id, request, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listPortalUsers(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.portal.ListUsers(r.Context())
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) createPortalUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input PortalUserInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.CreateUser(r.Context(), input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) updatePortalUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.UpdateUser(r.Context(), id, input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listPortalUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ListUserApps(r.Context(), id)
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) replacePortalUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request PortalUserAppRequest
|
||||
if !decodeAdminBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ReplaceUserApps(r.Context(), id, request, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var input PortalLoginRequest
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Login(r.Context(), input, requestRemoteAddress(r), r.UserAgent())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalLogout(w http.ResponseWriter, r *http.Request) {
|
||||
h.portal.Logout(r.Context(), externalBearer(r))
|
||||
h.writePortal(w, r, map[string]bool{"loggedOut": true}, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) portalSession(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
h.writePortal(w, r, session, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
h.writePortal(w, r, dataProducts(), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) portalApps(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Apps(r.Context(), session)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
appID, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Vehicles(r.Context(), session, appID)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAudit(w http.ResponseWriter, r *http.Request) {
|
||||
appID, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Audit(r.Context(), session, appID)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalRotateKey(w http.ResponseWriter, r *http.Request) {
|
||||
appID, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err == nil {
|
||||
err = h.portal.CanRotateKey(r.Context(), session, appID)
|
||||
}
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.service.RotateKey(r.Context(), appID, "portal:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
err = h.portal.ChangePassword(r.Context(), session, input.CurrentPassword, input.NewPassword)
|
||||
h.writePortal(w, r, map[string]bool{"changed": err == nil}, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminListApps(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ListApps(r.Context())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminCreateApp(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input AppInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CreateApp(r.Context(), input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminUpdateApp(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input AppInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateApp(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminReplaceVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input VehicleGrantRequest
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ReplaceVehicleGrants(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminVehicleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.VehicleCatalog(r.Context())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ListUsers(r.Context())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.CreateUser(r.Context(), input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.UpdateUser(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminListUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ListUserApps(r.Context(), id)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminReplaceUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserAppRequest
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ReplaceUserApps(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) requirePortalAdmin(w http.ResponseWriter, r *http.Request) (PortalSession, bool) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err == nil && session.UserType != "admin" {
|
||||
err = ErrForbidden
|
||||
}
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return PortalSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *Handler) writeAdmin(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||
traceID := externalTraceID(r)
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.WriteOK(w, traceID, data)
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID)
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "开放平台应用不存在", "", traceID)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", err.Error(), traceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) writePortal(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||
traceID := externalTraceID(r)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.WriteOK(w, traceID, data)
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-open-platform-portal"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "登录状态无效或已过期", "", traceID)
|
||||
case errors.Is(err, ErrForbidden):
|
||||
httpx.WriteError(w, http.StatusForbidden, "FORBIDDEN", "当前账号无此操作权限", "", traceID)
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID)
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "资源不存在", "", traceID)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", "", traceID)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeExternalBody(w http.ResponseWriter, r *http.Request, traceID string, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
message := "请求参数不正确"
|
||||
if strings.Contains(err.Error(), "protocolPriority") {
|
||||
message = "protocolPriority必须是非空字符串数组"
|
||||
}
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: message, TraceID: traceID})
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求体只能包含一个JSON对象", TraceID: traceID})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeAdminBody(w http.ResponseWriter, r *http.Request, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), externalTraceID(r))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodePortalBody(w http.ResponseWriter, r *http.Request, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", portalDecodeDetail(err), externalTraceID(r))
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求体只能包含一个JSON对象", "", externalTraceID(r))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func portalDecodeDetail(err error) string {
|
||||
var syntaxError *json.SyntaxError
|
||||
var typeError *json.UnmarshalTypeError
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return "请求内容不是有效的 JSON"
|
||||
case errors.As(err, &typeError):
|
||||
if typeError.Field != "" {
|
||||
return fmt.Sprintf("字段 %s 的数据类型不正确", typeError.Field)
|
||||
}
|
||||
return "请求字段的数据类型不正确"
|
||||
case errors.Is(err, io.EOF):
|
||||
return "请求内容不能为空"
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
return "请求包含未支持的字段 " + strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
default:
|
||||
return "请求内容无法解析"
|
||||
}
|
||||
}
|
||||
|
||||
func writeExternalError(w http.ResponseWriter, traceID string, err error) {
|
||||
response := ExternalResponse{TraceID: traceID}
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
status, response.Code, response.Message = http.StatusUnauthorized, "UNAUTHORIZED", "身份认证失败"
|
||||
case errors.Is(err, ErrForbidden):
|
||||
status, response.Code, response.Message = http.StatusForbidden, "FORBIDDEN", "无数据访问权限"
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
status, response.Code, response.Message = http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确"
|
||||
if strings.Contains(err.Error(), "invalid datetime") {
|
||||
response.Code, response.Message = "INVALID_DATETIME_FORMAT", "time格式必须为yyyy-MM-dd HH:mm:ss"
|
||||
} else if strings.Contains(err.Error(), "invalid date") {
|
||||
response.Code, response.Message = "INVALID_DATE_FORMAT", "date格式必须为yyyy-MM-dd"
|
||||
} else if strings.Contains(err.Error(), "protocolPriority must not be empty") {
|
||||
response.Message = "protocolPriority不能为空"
|
||||
} else if strings.Contains(err.Error(), "protocolPriority contains duplicate protocol") {
|
||||
response.Message = "protocolPriority不能包含重复协议"
|
||||
} else if strings.Contains(err.Error(), "protocolPriority contains unsupported protocol") {
|
||||
response.Message = "protocolPriority仅允许GB32960、MQTT、JT808"
|
||||
}
|
||||
default:
|
||||
response.Code, response.Message = "INTERNAL_ERROR", "服务内部异常"
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-open-platform"`)
|
||||
}
|
||||
writeExternal(w, status, response)
|
||||
}
|
||||
|
||||
func writeExternal(w http.ResponseWriter, status int, response any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func externalBearer(r *http.Request) string {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
|
||||
func externalTraceID(r *http.Request) string {
|
||||
if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 {
|
||||
return traceID
|
||||
}
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err == nil {
|
||||
return hex.EncodeToString(value[:])
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
func parseID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parsePortalID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func requestRemoteAddress(r *http.Request) string {
|
||||
for _, header := range []string{"X-Forwarded-For", "X-Real-Ip"} {
|
||||
if value := strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func dataProducts() []DataProduct {
|
||||
return []DataProduct{
|
||||
{
|
||||
Code: "daily_hydrogen", Name: "单日用氢量",
|
||||
Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: HydrogenQueryPath, Unit: "kg",
|
||||
},
|
||||
{
|
||||
Code: "daily_mileage", Name: "单日里程",
|
||||
Description: "按车牌和自然日查询日里程、累计里程、实际来源协议与数据时间,支持自定义协议优先级。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: MileageQueryPath, Unit: "km",
|
||||
},
|
||||
{
|
||||
Code: "mileage_range", Name: "区间日里程",
|
||||
Description: "按最长366天区间分页查询逐日里程,支持逐车逐日自定义协议优先级。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: MileageRangeQueryPath, Unit: "km",
|
||||
},
|
||||
{
|
||||
Code: "total_mileage_at_time", Name: "指定时刻总里程",
|
||||
Description: "按VIN和北京时间查询最近一条总里程、采集协议及记录时间差。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: TotalMileageQueryPath, Unit: "km",
|
||||
},
|
||||
}
|
||||
}
|
||||
330
vehicle-data-platform/apps/api/internal/openplatform/model.go
Normal file
330
vehicle-data-platform/apps/api/internal/openplatform/model.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusNormal = "NORMAL"
|
||||
StatusNoData = "NO_DATA"
|
||||
)
|
||||
|
||||
type QueryRequest struct {
|
||||
PlateNumbers []string `json:"plateNumbers"`
|
||||
Date string `json:"date"`
|
||||
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
|
||||
}
|
||||
|
||||
type MileageRangeRequest struct {
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
PlateNumbers []string `json:"plateNumbers"`
|
||||
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
|
||||
Cursor string `json:"cursor"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type ProtocolPriority struct {
|
||||
Values []string
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (p *ProtocolPriority) UnmarshalJSON(data []byte) error {
|
||||
p.Present = true
|
||||
if string(data) == "null" {
|
||||
p.Values = []string{}
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal(data, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Values = values
|
||||
return nil
|
||||
}
|
||||
|
||||
type HydrogenResult struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MileageResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DataTime *string `json:"dataTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
SourceProtocol *string `json:"sourceProtocol"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MileageRangeResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DataTime *string `json:"dataTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
SourceProtocol *string `json:"sourceProtocol"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TotalMileageQueryRequest struct {
|
||||
VIN string `json:"vin"`
|
||||
Time string `json:"time"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type TotalMileageResult struct {
|
||||
VIN string `json:"vin"`
|
||||
QueryTime string `json:"queryTime"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
ProtocolInput string `json:"protocolInput,omitempty"`
|
||||
MileageMeaning string `json:"mileageMeaning,omitempty"`
|
||||
RecordTime string `json:"recordTime,omitempty"`
|
||||
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
||||
SelectionPolicy string `json:"selectionPolicy"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TotalMileagePoint struct {
|
||||
VIN string
|
||||
Protocol string
|
||||
ObservedAt time.Time
|
||||
TotalMileageKm float64
|
||||
}
|
||||
|
||||
type ExternalResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
TraceID string `json:"traceId"`
|
||||
}
|
||||
|
||||
type MileageRangeResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []MileageRangeResult `json:"data"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
NextCursor *string `json:"nextCursor"`
|
||||
TraceID string `json:"traceId"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AppKeyPrefix string `json:"appKeyPrefix"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AppCreated struct {
|
||||
App
|
||||
AppKey string `json:"appKey"`
|
||||
}
|
||||
|
||||
type AppInput struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom string `json:"validFrom"`
|
||||
ValidTo string `json:"validTo"`
|
||||
}
|
||||
|
||||
type VehicleGrantInput struct {
|
||||
VIN string `json:"vin"`
|
||||
ValidFrom string `json:"validFrom"`
|
||||
ValidTo string `json:"validTo"`
|
||||
}
|
||||
|
||||
type VehicleGrantRequest struct {
|
||||
Vehicles []VehicleGrantInput `json:"vehicles"`
|
||||
}
|
||||
|
||||
type VehicleGrant struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
GrantedBy string `json:"grantedBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type VehicleCatalogItem struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
OEM string `json:"oem"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type AuthorizedVehicle struct {
|
||||
VIN string
|
||||
Plate string
|
||||
}
|
||||
|
||||
type AppCredential struct {
|
||||
ID uint64
|
||||
Name string
|
||||
}
|
||||
|
||||
type DailyHydrogen struct {
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
VIN string
|
||||
Date string
|
||||
Protocol string
|
||||
MileageKm float64
|
||||
TotalMileageKm float64
|
||||
DataTime string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type MileageSnapshot struct {
|
||||
ID string
|
||||
AppID uint64
|
||||
RequestHash []byte
|
||||
StartDate string
|
||||
EndDate string
|
||||
VehicleCount int
|
||||
ExpiresAt time.Time
|
||||
Vehicles []AuthorizedVehicle
|
||||
}
|
||||
|
||||
type HydrogenObservation struct {
|
||||
VIN string
|
||||
Source string
|
||||
ObservedAt time.Time
|
||||
MassKg float64
|
||||
TankCapacityLiter float64
|
||||
PressureMPa float64
|
||||
TemperatureC float64
|
||||
NoiseKg float64
|
||||
RefuelThresholdKg float64
|
||||
}
|
||||
|
||||
type HydrogenRateObservation struct {
|
||||
VIN string
|
||||
Source string
|
||||
ObservedAt time.Time
|
||||
Rate float64
|
||||
MileageKm float64
|
||||
}
|
||||
|
||||
type HydrogenRateDailyStat struct {
|
||||
VIN string
|
||||
Source string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type HydrogenDailyStat struct {
|
||||
VIN string
|
||||
Source string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
FirstMassKg float64
|
||||
LastMassKg float64
|
||||
SampleCount int
|
||||
RefuelCount int
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type PortalUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PortalUserInput struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom string `json:"validFrom"`
|
||||
ValidTo string `json:"validTo"`
|
||||
}
|
||||
|
||||
type PortalUserAppInput struct {
|
||||
AppID uint64 `json:"appId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type PortalUserAppRequest struct {
|
||||
Apps []PortalUserAppInput `json:"apps"`
|
||||
}
|
||||
|
||||
type PortalUserApp struct {
|
||||
AppID uint64 `json:"appId"`
|
||||
AppName string `json:"appName"`
|
||||
AppKeyPrefix string `json:"appKeyPrefix"`
|
||||
AppStatus string `json:"appStatus"`
|
||||
Role string `json:"role"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
}
|
||||
|
||||
type PortalSession struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserType string `json:"userType"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type PortalLoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session PortalSession `json:"session"`
|
||||
}
|
||||
|
||||
type PortalLoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type PortalAuditItem struct {
|
||||
TraceID string `json:"traceId"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Result string `json:"result"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DataProduct struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
611
vehicle-data-platform/apps/api/internal/openplatform/mysql.go
Normal file
611
vehicle-data-platform/apps/api/internal/openplatform/mysql.go
Normal file
@@ -0,0 +1,611 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MySQLRepository struct {
|
||||
db *sql.DB
|
||||
tdengine *sql.DB
|
||||
tdDatabase string
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) WithTDengine(db *sql.DB, database string) *MySQLRepository {
|
||||
if db == nil {
|
||||
panic("open platform TDengine database is required")
|
||||
}
|
||||
if database == "" {
|
||||
database = "lingniu_vehicle_ts"
|
||||
}
|
||||
for _, char := range database {
|
||||
if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '_' {
|
||||
panic("invalid TDengine database identifier")
|
||||
}
|
||||
}
|
||||
r.tdengine = db
|
||||
r.tdDatabase = database
|
||||
return r
|
||||
}
|
||||
|
||||
func NewMySQLRepository(db *sql.DB) *MySQLRepository {
|
||||
if db == nil {
|
||||
panic("open platform database is required")
|
||||
}
|
||||
return &MySQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) Authenticate(ctx context.Context, hash [sha256.Size]byte, now, dayStart, dayEnd time.Time) (AppCredential, error) {
|
||||
var app AppCredential
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,name
|
||||
FROM vehicle_open_app
|
||||
WHERE app_key_hash=?
|
||||
AND status='enabled'
|
||||
AND valid_from<=?
|
||||
AND (valid_to IS NULL OR valid_to>?)
|
||||
AND valid_from<=?
|
||||
AND (valid_to IS NULL OR valid_to>=?)`,
|
||||
hash[:], now, now, dayStart, dayEnd,
|
||||
).Scan(&app.ID, &app.Name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AppCredential{}, ErrUnauthorized
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
args := make([]any, 0, len(plates)+3)
|
||||
args = append(args, appID, dayStart, dayEnd)
|
||||
query := `
|
||||
SELECT UPPER(b.plate),MIN(g.vin)
|
||||
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 TRIM(COALESCE(b.plate,''))<>''`
|
||||
if len(plates) > 0 {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",")
|
||||
query += `
|
||||
AND UPPER(b.plate) IN (` + placeholders + `)`
|
||||
for _, plate := range plates {
|
||||
args = append(args, plate)
|
||||
}
|
||||
}
|
||||
query += `
|
||||
GROUP BY UPPER(b.plate)
|
||||
HAVING COUNT(DISTINCT g.vin)=1
|
||||
ORDER BY UPPER(b.plate)`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]AuthorizedVehicle, len(plates))
|
||||
for rows.Next() {
|
||||
var vehicle AuthorizedVehicle
|
||||
if err := rows.Scan(&vehicle.Plate, &vehicle.VIN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[vehicle.Plate] = vehicle
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) AuthorizedVIN(ctx context.Context, appID uint64, vin string, at time.Time) (bool, error) {
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM vehicle_open_app_vehicle
|
||||
WHERE app_id=? AND BINARY vin=BINARY ?
|
||||
AND valid_from<=?
|
||||
AND (valid_to IS NULL OR valid_to>?)`, appID, vin, at, at).Scan(&count)
|
||||
return count == 1, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.Time, protocols []string) (*TotalMileagePoint, error) {
|
||||
if r.tdengine == nil || r.tdDatabase == "" {
|
||||
return nil, errors.New("TDengine is not configured for total mileage query")
|
||||
}
|
||||
table := r.tdDatabase + ".vehicle_locations"
|
||||
timeLiteral := strings.ReplaceAll(at.Format(time.RFC3339), "'", "''")
|
||||
for _, protocol := range protocols {
|
||||
query := `SELECT CAST(ts AS BIGINT),total_mileage_km,protocol FROM ` + table +
|
||||
` WHERE vin='` + vin + `' AND protocol='` + protocol + `'` +
|
||||
` AND ts<='` + timeLiteral + `' AND total_mileage_km IS NOT NULL AND total_mileage_km>=0` +
|
||||
` ORDER BY ts DESC LIMIT 1`
|
||||
var timestampMS int64
|
||||
var point TotalMileagePoint
|
||||
err := r.tdengine.QueryRowContext(ctx, query).Scan(×tampMS, &point.TotalMileageKm, &point.Protocol)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.VIN = vin
|
||||
point.ObservedAt = time.UnixMilli(timestampMS)
|
||||
return &point, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyHydrogen{}, nil
|
||||
}
|
||||
query, args := inQuery(`
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
|
||||
FROM vehicle_open_daily_energy
|
||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]DailyHydrogen, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyHydrogen
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[value.VIN] = value
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date string, protocols []string) (map[string]DailyMileage, error) {
|
||||
values, err := r.DailyMileageRange(ctx, vins, date, date, protocols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]DailyMileage, len(values))
|
||||
for _, value := range values {
|
||||
out[value.VIN] = value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyMileage{}, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
query := `
|
||||
SELECT
|
||||
m.vin,
|
||||
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
||||
m.protocol,
|
||||
m.daily_mileage_km,
|
||||
m.latest_total_mileage_km,
|
||||
COALESCE(DATE_FORMAT((
|
||||
SELECT MAX(selected.latest_event_time)
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin=m.vin
|
||||
AND selected.stat_date=m.stat_date
|
||||
AND selected.protocol=m.protocol
|
||||
AND selected.is_selected=1
|
||||
AND selected.latest_event_time IS NOT NULL
|
||||
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
|
||||
FROM vehicle_daily_mileage m
|
||||
WHERE m.stat_date BETWEEN ? AND ?
|
||||
AND m.vin IN (` + placeholders + `)
|
||||
AND m.latest_total_mileage_km IS NOT NULL
|
||||
AND m.latest_total_mileage_km>=0
|
||||
AND m.daily_mileage_km>=0`
|
||||
args := make([]any, 0, len(vins)+2+len(protocols)*2)
|
||||
args = append(args, startDate, endDate)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if len(protocols) > 0 {
|
||||
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
|
||||
query += "\n AND m.protocol IN (" + protocolPlaceholders + ")\nORDER BY m.stat_date,m.vin,CASE m.protocol"
|
||||
for _, protocol := range protocols {
|
||||
args = append(args, protocol)
|
||||
}
|
||||
for index, protocol := range protocols {
|
||||
query += " WHEN ? THEN " + strconv.Itoa(index+1)
|
||||
args = append(args, protocol)
|
||||
}
|
||||
query += " ELSE 99 END,m.protocol"
|
||||
} else {
|
||||
query += `
|
||||
ORDER BY m.stat_date,m.vin,
|
||||
CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END,
|
||||
CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END,
|
||||
m.protocol`
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]DailyMileage, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyMileage
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := dailyMileageKey(value.VIN, value.Date)
|
||||
if _, exists := out[key]; !exists {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) LatestMileageBefore(ctx context.Context, vins []string, beforeDate string, protocols []string) (map[string]DailyMileage, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyMileage{}, nil
|
||||
}
|
||||
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
query := `
|
||||
SELECT
|
||||
m.vin,
|
||||
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
||||
m.protocol,
|
||||
m.daily_mileage_km,
|
||||
m.latest_total_mileage_km,
|
||||
COALESCE(DATE_FORMAT((
|
||||
SELECT MAX(selected.latest_event_time)
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin=m.vin
|
||||
AND selected.stat_date=m.stat_date
|
||||
AND selected.protocol=m.protocol
|
||||
AND selected.is_selected=1
|
||||
AND selected.latest_event_time IS NOT NULL
|
||||
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
|
||||
FROM vehicle_daily_mileage m
|
||||
JOIN (
|
||||
SELECT prior.vin,prior.protocol,MAX(prior.stat_date) AS stat_date
|
||||
FROM vehicle_daily_mileage prior
|
||||
WHERE prior.stat_date<?
|
||||
AND prior.vin IN (` + vinPlaceholders + `)
|
||||
AND prior.latest_total_mileage_km IS NOT NULL
|
||||
AND prior.latest_total_mileage_km>=0
|
||||
AND prior.daily_mileage_km>=0`
|
||||
args := make([]any, 0, len(vins)+1+len(protocols)*2)
|
||||
args = append(args, beforeDate)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if len(protocols) > 0 {
|
||||
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
|
||||
query += "\n AND prior.protocol IN (" + protocolPlaceholders + ")"
|
||||
for _, protocol := range protocols {
|
||||
args = append(args, protocol)
|
||||
}
|
||||
}
|
||||
query += `
|
||||
GROUP BY prior.vin,prior.protocol
|
||||
) latest
|
||||
ON latest.vin=m.vin
|
||||
AND latest.stat_date=m.stat_date
|
||||
AND latest.protocol=m.protocol`
|
||||
if len(protocols) > 0 {
|
||||
query += "\nORDER BY m.vin,CASE m.protocol"
|
||||
for index, protocol := range protocols {
|
||||
query += " WHEN ? THEN " + strconv.Itoa(index+1)
|
||||
args = append(args, protocol)
|
||||
}
|
||||
query += " ELSE 99 END,m.protocol,m.updated_at DESC"
|
||||
} else {
|
||||
query += `
|
||||
ORDER BY m.vin,
|
||||
CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END,
|
||||
CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END,
|
||||
m.protocol,
|
||||
m.updated_at DESC`
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]DailyMileage, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyMileage
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := out[value.VIN]; !exists {
|
||||
out[value.VIN] = value
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) CreateMileageSnapshot(ctx context.Context, snapshot MileageSnapshot) error {
|
||||
if len(snapshot.RequestHash) != sha256.Size || snapshot.ID == "" {
|
||||
return fmt.Errorf("%w: invalid snapshot", ErrInvalidRequest)
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_mileage_snapshot WHERE expires_at<=CURRENT_TIMESTAMP(3)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_mileage_snapshot
|
||||
(snapshot_id,app_id,request_hash,start_date,end_date,vehicle_count,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
snapshot.ID, snapshot.AppID, snapshot.RequestHash, snapshot.StartDate, snapshot.EndDate, len(snapshot.Vehicles), snapshot.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
const batchSize = 500
|
||||
for start := 0; start < len(snapshot.Vehicles); start += batchSize {
|
||||
end := start + batchSize
|
||||
if end > len(snapshot.Vehicles) {
|
||||
end = len(snapshot.Vehicles)
|
||||
}
|
||||
var query strings.Builder
|
||||
query.WriteString(`INSERT INTO vehicle_open_mileage_snapshot_vehicle(snapshot_id,ordinal,vin,plate) VALUES `)
|
||||
args := make([]any, 0, (end-start)*4)
|
||||
for index := start; index < end; index++ {
|
||||
if index > start {
|
||||
query.WriteByte(',')
|
||||
}
|
||||
query.WriteString("(?,?,?,?)")
|
||||
vehicle := snapshot.Vehicles[index]
|
||||
args = append(args, snapshot.ID, index, vehicle.VIN, vehicle.Plate)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) LoadMileageSnapshot(ctx context.Context, snapshotID string, appID uint64, now time.Time) (MileageSnapshot, error) {
|
||||
snapshot := MileageSnapshot{ID: snapshotID, AppID: appID}
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT request_hash,DATE_FORMAT(start_date,'%Y-%m-%d'),DATE_FORMAT(end_date,'%Y-%m-%d'),vehicle_count
|
||||
FROM vehicle_open_mileage_snapshot
|
||||
WHERE snapshot_id=? AND app_id=? AND expires_at>?`, snapshotID, appID, now).
|
||||
Scan(&snapshot.RequestHash, &snapshot.StartDate, &snapshot.EndDate, &snapshot.VehicleCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return MileageSnapshot{}, fmt.Errorf("%w: cursor expired or unavailable", ErrInvalidRequest)
|
||||
}
|
||||
if err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT vin,plate
|
||||
FROM vehicle_open_mileage_snapshot_vehicle
|
||||
WHERE snapshot_id=?
|
||||
ORDER BY ordinal`, snapshotID)
|
||||
if err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
snapshot.Vehicles = make([]AuthorizedVehicle, 0, snapshot.VehicleCount)
|
||||
for rows.Next() {
|
||||
var vehicle AuthorizedVehicle
|
||||
if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
snapshot.Vehicles = append(snapshot.Vehicles, vehicle)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
if len(snapshot.Vehicles) != snapshot.VehicleCount {
|
||||
return MileageSnapshot{}, errors.New("mileage snapshot vehicle count mismatch")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) Audit(ctx context.Context, appID uint64, endpoint, result, traceID string, requested int, detail string) error {
|
||||
var nullableApp any
|
||||
if appID > 0 {
|
||||
nullableApp = appID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_api_audit(trace_id,app_id,endpoint,result,requested_vehicle_count,detail)
|
||||
VALUES(?,?,?,?,?,?)`, traceID, nullableApp, endpoint, result, requested, truncate(detail, 512))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) CreateApp(ctx context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_app(name,app_key_hash,app_key_prefix,status,valid_from,valid_to,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, input.Name, hash[:], prefix, input.Status, from, nullableTime(to), actor, actor)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
_ = r.adminAudit(ctx, uint64(id), actor, "create_app", map[string]any{"name": input.Name, "validFrom": from, "validTo": to})
|
||||
return r.app(ctx, uint64(id))
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) ListApps(ctx context.Context) ([]App, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at
|
||||
FROM vehicle_open_app ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
apps := make([]App, 0)
|
||||
for rows.Next() {
|
||||
app, err := scanApp(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) UpdateApp(ctx context.Context, id uint64, input AppInput, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE vehicle_open_app SET name=?,status=?,valid_from=?,valid_to=?,updated_by=? WHERE id=?`,
|
||||
input.Name, input.Status, from, nullableTime(to), actor, id)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return App{}, ErrNotFound
|
||||
}
|
||||
_ = r.adminAudit(ctx, id, actor, "update_app", map[string]any{"name": input.Name, "status": input.Status, "validFrom": from, "validTo": to})
|
||||
return r.app(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) RotateKey(ctx context.Context, id uint64, hash [sha256.Size]byte, prefix, actor string) (App, error) {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE vehicle_open_app SET app_key_hash=?,app_key_prefix=?,updated_by=? WHERE id=?`, hash[:], prefix, actor, id)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return App{}, ErrNotFound
|
||||
}
|
||||
_ = r.adminAudit(ctx, id, actor, "rotate_key", map[string]any{"appKeyPrefix": prefix})
|
||||
return r.app(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) ReplaceVehicleGrants(ctx context.Context, appID uint64, grants []parsedGrant, actor string) ([]VehicleGrant, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=? FOR UPDATE`, appID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if len(grants) > 0 {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(grants)), ",")
|
||||
args := make([]any, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
args = append(args, grant.VIN)
|
||||
}
|
||||
var vinCount int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT vin) FROM vehicle_identity_binding WHERE vin IN (`+placeholders+`)`, args...).Scan(&vinCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vinCount != len(grants) {
|
||||
return nil, fmt.Errorf("%w: one or more VINs do not exist", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_app_vehicle WHERE app_id=?`, appID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, grant := range grants {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_app_vehicle(app_id,vin,valid_from,valid_to,granted_by)
|
||||
VALUES(?,?,?,?,?)`, appID, grant.VIN, grant.ValidFrom, nullableTime(grant.ValidTo), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]any{"count": len(grants)})
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json)
|
||||
VALUES(?,?,?,?)`, appID, actor, "replace_vehicle_grants", string(detail)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.ListVehicleGrants(ctx, appID)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) ListVehicleGrants(ctx context.Context, appID uint64) ([]VehicleGrant, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
FROM vehicle_open_app_vehicle g
|
||||
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE g.app_id=?
|
||||
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
ORDER BY g.vin`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
grants := make([]VehicleGrant, 0)
|
||||
for rows.Next() {
|
||||
var grant VehicleGrant
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
grant.ValidTo = &validTo.Time
|
||||
}
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
return grants, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) app(ctx context.Context, id uint64) (App, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at
|
||||
FROM vehicle_open_app WHERE id=?`, id)
|
||||
app, err := scanApp(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return App{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanApp(row scanner) (App, error) {
|
||||
var app App
|
||||
var validTo sql.NullTime
|
||||
err := row.Scan(&app.ID, &app.Name, &app.AppKeyPrefix, &app.Status, &app.ValidFrom, &validTo, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt)
|
||||
if validTo.Valid {
|
||||
app.ValidTo = &validTo.Time
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) adminAudit(ctx context.Context, appID uint64, actor, action string, detail any) error {
|
||||
encoded, _ := json.Marshal(detail)
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json) VALUES(?,?,?,?)`, appID, actor, action, encoded)
|
||||
return err
|
||||
}
|
||||
|
||||
func inQuery(template, first string, values []string) (string, []any) {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(values)), ",")
|
||||
args := make([]any, 0, len(values)+1)
|
||||
args = append(args, first)
|
||||
for _, value := range values {
|
||||
args = append(args, value)
|
||||
}
|
||||
return strings.Replace(template, "%s", placeholders, 1), args
|
||||
}
|
||||
|
||||
func nullableTime(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func truncate(value string, size int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= size {
|
||||
return value
|
||||
}
|
||||
return string(runes[:size])
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
|
||||
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
||||
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
|
||||
t.Fatalf("date format was corrupted: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "vin IN (?,?)") {
|
||||
t.Fatalf("VIN placeholders missing: %s", query)
|
||||
}
|
||||
if len(args) != 3 || args[0] != "2026-07-21" || args[1] != "VIN1" || args[2] != "VIN2" {
|
||||
t.Fatalf("unexpected args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedVehiclesUsesBinaryVINJoin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
||||
end := start.Add(24*time.Hour - time.Nanosecond)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
|
||||
WithArgs(uint64(1), start, end, "辽A00001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).AddRow("辽A00001", "LTEST000000000001"))
|
||||
|
||||
repository := NewMySQLRepository(db)
|
||||
vehicles, err := repository.AuthorizedVehicles(context.Background(), 1, []string{"辽A00001"}, start, end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vehicles["辽A00001"].VIN != "LTEST000000000001" {
|
||||
t.Fatalf("unexpected vehicles: %#v", vehicles)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
||||
end := start.Add(24 * time.Hour)
|
||||
mock.ExpectQuery("JOIN vehicle_identity_binding.*TRIM.*ORDER BY UPPER").
|
||||
WithArgs(uint64(7), start, end).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).
|
||||
AddRow("粤A12345", "LTEST32960VIN0001").
|
||||
AddRow("粤B67890", "LTEST32960VIN0002"))
|
||||
|
||||
vehicles, err := NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, nil, start, end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 2 || vehicles["粤A12345"].VIN != "LTEST32960VIN0001" || vehicles["粤B67890"].VIN != "LTEST32960VIN0002" {
|
||||
t.Fatalf("vehicles=%#v", vehicles)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0").
|
||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := values["LTEST32960VIN0001"]
|
||||
if first.MileageKm != 101.235 || first.TotalMileageKm != 12345.679 || first.DataTime != "2026-07-21T23:58:45+08:00" || first.UpdatedAt == "" {
|
||||
t.Fatalf("first=%#v", first)
|
||||
}
|
||||
if _, ok := values["LTEST32960VIN0002"]; ok {
|
||||
t.Fatalf("vehicle without a cumulative total must not be returned: %#v", values)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
|
||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00").
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := values["LTEST32960VIN0001"]
|
||||
if selected.Protocol != "JT808" || selected.MileageKm != 0 {
|
||||
t.Fatalf("selected=%#v", selected)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestMileageBeforeUsesRequestedProtocolPriority(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*SELECT prior.vin,prior.protocol,MAX\\(prior.stat_date\\).*FROM vehicle_daily_mileage prior\\s+WHERE prior.stat_date<\\?.*prior.protocol IN \\(\\?,\\?\\).*ORDER BY m.vin,CASE m.protocol").
|
||||
WithArgs("2026-07-22", "LTEST32960VIN0001", "JT808", "YUTONG_MQTT", "JT808", "YUTONG_MQTT").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-20", "JT808", 18.5, 9008.5, "2026-07-20T22:00:00+08:00", "2026-07-21T01:00:00+08:00").
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "YUTONG_MQTT", 20.0, 12020.0, "2026-07-21T23:00:00+08:00", "2026-07-22T01:00:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).LatestMileageBefore(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-22", []string{"JT808", "YUTONG_MQTT"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := values["LTEST32960VIN0001"]
|
||||
if selected.Protocol != "JT808" ||
|
||||
selected.MileageKm != 18.5 ||
|
||||
selected.TotalMileageKm != 9008.5 ||
|
||||
selected.DataTime != "2026-07-20T22:00:00+08:00" ||
|
||||
selected.UpdatedAt != "2026-07-21T01:00:00+08:00" {
|
||||
t.Fatalf("selected=%#v", selected)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testing.T) {
|
||||
mysqlDB, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, tdMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
at := time.Date(2026, 7, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
tdMock.ExpectQuery("protocol='GB32960'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}))
|
||||
tdMock.ExpectQuery("protocol='YUTONG_MQTT'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}).AddRow(at.Add(-15*time.Second).UnixMilli(), 12345.678, "YUTONG_MQTT"))
|
||||
repository := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts")
|
||||
point, err := repository.TotalMileage(context.Background(), "LA9GG68L2PBAF4790", at, []string{"GB32960", "YUTONG_MQTT", "JT808"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if point == nil || point.Protocol != "YUTONG_MQTT" || point.TotalMileageKm != 12345.678 || !point.ObservedAt.Equal(at.Add(-15*time.Second)) {
|
||||
t.Fatalf("point=%#v", point)
|
||||
}
|
||||
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
|
||||
WithArgs(uint64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "valid_from", "valid_to", "granted_by", "updated_at"}).
|
||||
AddRow("LTEST000000000001", "辽A00001", now, now.AddDate(1, 0, 0), "admin", now))
|
||||
|
||||
repository := NewMySQLRepository(db)
|
||||
grants, err := repository.ListVehicleGrants(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grants) != 1 || grants[0].Plate != "辽A00001" {
|
||||
t.Fatalf("unexpected grants: %#v", grants)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
675
vehicle-data-platform/apps/api/internal/openplatform/portal.go
Normal file
675
vehicle-data-platform/apps/api/internal/openplatform/portal.go
Normal file
@@ -0,0 +1,675 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
portalMaxLoginFailures = 5
|
||||
portalLoginLock = 15 * time.Minute
|
||||
)
|
||||
|
||||
var portalUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{3,64}$`)
|
||||
|
||||
type PortalService struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type portalCredential struct {
|
||||
PortalUser
|
||||
PasswordHash string
|
||||
FailedLoginCount int
|
||||
LockedUntil sql.NullTime
|
||||
UserType string
|
||||
}
|
||||
|
||||
func NewPortalService(db *sql.DB, sessionTTL time.Duration) *PortalService {
|
||||
if db == nil {
|
||||
panic("open platform portal database is required")
|
||||
}
|
||||
if sessionTTL <= 0 {
|
||||
sessionTTL = 12 * time.Hour
|
||||
}
|
||||
return &PortalService{db: db, sessionTTL: sessionTTL, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *PortalService) CreateUser(ctx context.Context, input PortalUserInput, actor string) (PortalUser, error) {
|
||||
from, to, err := validatePortalUserInput(&input, true)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
|
||||
return PortalUser{}, err
|
||||
} else if reserved {
|
||||
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user(
|
||||
username,display_name,password_hash,status,valid_from,valid_to,created_by,updated_by
|
||||
) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
input.Username, input.DisplayName, string(hash), input.Status, from, nullableTime(to), actor, actor)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
|
||||
}
|
||||
return PortalUser{}, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
_ = s.userAudit(ctx, uint64(id), actor, "user.create", "success", map[string]any{"username": input.Username}, "")
|
||||
return s.user(ctx, uint64(id))
|
||||
}
|
||||
|
||||
func (s *PortalService) ListUsers(ctx context.Context) ([]PortalUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
|
||||
FROM vehicle_open_user ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make([]PortalUser, 0)
|
||||
for rows.Next() {
|
||||
user, err := scanPortalUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) UpdateUser(ctx context.Context, id uint64, input PortalUserInput, actor string) (PortalUser, error) {
|
||||
from, to, err := validatePortalUserInput(&input, false)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
|
||||
return PortalUser{}, err
|
||||
} else if reserved {
|
||||
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
|
||||
}
|
||||
args := []any{input.Username, input.DisplayName, input.Status, from, nullableTime(to), actor}
|
||||
query := `UPDATE vehicle_open_user SET username=?,display_name=?,status=?,valid_from=?,valid_to=?,updated_by=?`
|
||||
if input.Password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL`
|
||||
args = append(args, string(hash))
|
||||
}
|
||||
query += ` WHERE id=?`
|
||||
args = append(args, id)
|
||||
result, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
|
||||
}
|
||||
return PortalUser{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return PortalUser{}, ErrNotFound
|
||||
}
|
||||
if input.Status == "disabled" || input.Password != "" {
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id)
|
||||
}
|
||||
_ = s.userAudit(ctx, id, actor, "user.update", "success", map[string]any{"status": input.Status, "passwordReset": input.Password != ""}, "")
|
||||
return s.user(ctx, id)
|
||||
}
|
||||
|
||||
func (s *PortalService) ReplaceUserApps(ctx context.Context, userID uint64, request PortalUserAppRequest, actor string) ([]PortalUserApp, error) {
|
||||
if len(request.Apps) > 100 {
|
||||
return nil, fmt.Errorf("%w: app memberships exceed 100", ErrInvalidRequest)
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, item := range request.Apps {
|
||||
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
|
||||
if item.AppID == 0 || seen[item.AppID] || !validPortalRole(item.Role) {
|
||||
return nil, fmt.Errorf("%w: invalid or duplicate app membership", ErrInvalidRequest)
|
||||
}
|
||||
seen[item.AppID] = true
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_user WHERE id=? FOR UPDATE`, userID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
for _, item := range request.Apps {
|
||||
var appExists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, item.AppID).Scan(&appExists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if appExists == 0 {
|
||||
return nil, fmt.Errorf("%w: app %d", ErrInvalidRequest, item.AppID)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_user_app WHERE user_id=?`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range request.Apps {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user_app(user_id,app_id,role,granted_by) VALUES(?,?,?,?)`,
|
||||
userID, item.AppID, strings.ToLower(strings.TrimSpace(item.Role)), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.userAudit(ctx, userID, actor, "user.apps.replace", "success", map[string]any{"count": len(request.Apps)}, "")
|
||||
return s.ListUserApps(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *PortalService) ListUserApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
|
||||
return s.portalApps(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *PortalService) Login(ctx context.Context, input PortalLoginRequest, remoteAddr, userAgent string) (PortalLoginResponse, error) {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
if !portalUsernamePattern.MatchString(input.Username) || len(input.Password) > 128 {
|
||||
return PortalLoginResponse{}, ErrUnauthorized
|
||||
}
|
||||
credential, err := s.loginCredential(ctx, input.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_ = s.userAudit(ctx, 0, input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddr)
|
||||
return PortalLoginResponse{}, ErrUnauthorized
|
||||
}
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
now := s.now()
|
||||
if credential.Status != "enabled" ||
|
||||
(credential.UserType != "admin" && (credential.ValidFrom.After(now) || (credential.ValidTo != nil && !credential.ValidTo.After(now)))) {
|
||||
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "disabled_or_expired"}, remoteAddr)
|
||||
return PortalLoginResponse{}, ErrForbidden
|
||||
}
|
||||
if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) {
|
||||
return PortalLoginResponse{}, fmt.Errorf("%w: account locked", ErrForbidden)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil {
|
||||
failures := credential.FailedLoginCount + 1
|
||||
var lockedUntil any
|
||||
if failures >= portalMaxLoginFailures {
|
||||
lockedUntil = now.Add(portalLoginLock)
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=?,locked_until=? WHERE id=?`, failures, lockedUntil, credential.ID)
|
||||
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddr)
|
||||
return PortalLoginResponse{}, ErrUnauthorized
|
||||
}
|
||||
rawToken, tokenHash, err := newPortalSessionToken()
|
||||
if err != nil {
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
sessionID, err := randomHexBytes(16)
|
||||
if err != nil {
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
expiresAt := now.Add(s.sessionTTL)
|
||||
var portalUserID, platformUserID any
|
||||
if credential.UserType == "admin" {
|
||||
platformUserID = credential.ID
|
||||
} else {
|
||||
portalUserID = credential.ID
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user_session(
|
||||
id,user_id,platform_user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent
|
||||
) VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
sessionID, portalUserID, platformUserID, tokenHash[:], now, expiresAt, now, truncate(remoteAddr, 96), truncate(userAgent, 255))
|
||||
if err != nil {
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
|
||||
_ = s.userAudit(ctx, auditPortalUserID(credential), credential.Username, "login", "success", map[string]any{"userType": credential.UserType}, remoteAddr)
|
||||
session := PortalSession{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, UserType: credential.UserType, ExpiresAt: expiresAt}
|
||||
return PortalLoginResponse{AccessToken: rawToken, ExpiresAt: expiresAt, Session: session}, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) Authenticate(ctx context.Context, rawToken string) (PortalSession, error) {
|
||||
if len(rawToken) != 64 {
|
||||
return PortalSession{}, ErrUnauthorized
|
||||
}
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
var session PortalSession
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(u.id,p.id),COALESCE(u.username,p.username),COALESCE(u.display_name,p.display_name),
|
||||
CASE WHEN p.id IS NOT NULL THEN 'admin' ELSE 'partner' END,se.expires_at
|
||||
FROM vehicle_open_user_session se
|
||||
LEFT JOIN vehicle_open_user u ON u.id=se.user_id
|
||||
LEFT JOIN platform_user p ON p.id=se.platform_user_id
|
||||
WHERE se.token_hash=?
|
||||
AND se.revoked_at IS NULL
|
||||
AND se.expires_at>NOW(3)
|
||||
AND ((u.id IS NOT NULL AND u.status='enabled' AND u.valid_from<=NOW(3)
|
||||
AND (u.valid_to IS NULL OR u.valid_to>NOW(3)))
|
||||
OR (p.id IS NOT NULL AND p.user_type='admin' AND p.status='enabled'
|
||||
AND p.auth_provider='local'))`, hash[:]).
|
||||
Scan(&session.UserID, &session.Username, &session.DisplayName, &session.UserType, &session.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PortalSession{}, ErrUnauthorized
|
||||
}
|
||||
if err != nil {
|
||||
return PortalSession{}, err
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET last_seen_at=NOW(3) WHERE token_hash=?`, hash[:])
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) Logout(ctx context.Context, rawToken string) {
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:])
|
||||
}
|
||||
|
||||
func (s *PortalService) ChangePassword(ctx context.Context, session PortalSession, currentPassword, newPassword string) error {
|
||||
if session.UserType == "admin" {
|
||||
return fmt.Errorf("%w: platform administrators must change passwords on the internal platform", ErrInvalidRequest)
|
||||
}
|
||||
if err := validatePortalPassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
var currentHash string
|
||||
table := credentialTable(session.UserType)
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM `+table+` WHERE id=? AND status='enabled'`, session.UserID).Scan(¤tHash); err != nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(currentPassword)) != nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(newPassword)) == nil {
|
||||
return fmt.Errorf("%w: password unchanged", ErrInvalidRequest)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET password_hash=?,password_changed_at=NOW(3),updated_by=? WHERE id=?`, string(hash), session.Username, session.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
identityColumn := "user_id"
|
||||
if session.UserType == "admin" {
|
||||
identityColumn = "platform_user_id"
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, session.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE `+identityColumn+`=? AND revoked_at IS NULL`, session.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
auditUserID := session.UserID
|
||||
if session.UserType == "admin" {
|
||||
auditUserID = 0
|
||||
}
|
||||
_ = s.userAudit(ctx, auditUserID, session.Username, "password.change", "success", map[string]any{"userType": session.UserType}, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PortalService) Apps(ctx context.Context, session PortalSession) ([]PortalUserApp, error) {
|
||||
if session.UserType == "admin" {
|
||||
return s.adminApps(ctx)
|
||||
}
|
||||
return s.portalApps(ctx, session.UserID)
|
||||
}
|
||||
|
||||
func (s *PortalService) Vehicles(ctx context.Context, session PortalSession, appID uint64) ([]VehicleGrant, error) {
|
||||
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
FROM vehicle_open_app_vehicle g
|
||||
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE g.app_id=?
|
||||
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
ORDER BY g.vin`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
grants := make([]VehicleGrant, 0)
|
||||
for rows.Next() {
|
||||
var grant VehicleGrant
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
grant.ValidTo = &validTo.Time
|
||||
}
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
return grants, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) VehicleCatalog(ctx context.Context) ([]VehicleCatalogItem, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT b.vin,
|
||||
COALESCE(MAX(NULLIF(b.plate,'')),''),
|
||||
COALESCE(MAX(NULLIF(b.oem,'')),'')
|
||||
FROM vehicle_identity_binding b
|
||||
WHERE b.vin IS NOT NULL AND b.vin<>''
|
||||
GROUP BY b.vin
|
||||
ORDER BY COALESCE(MAX(NULLIF(b.plate,'')),''),b.vin`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]VehicleCatalogItem, 0)
|
||||
for rows.Next() {
|
||||
var item VehicleCatalogItem
|
||||
if err := rows.Scan(&item.VIN, &item.Plate, &item.OEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = "available"
|
||||
item.Source = "车辆主数据"
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) Audit(ctx context.Context, session PortalSession, appID uint64) ([]PortalAuditItem, error) {
|
||||
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT trace_id,endpoint,result,requested_vehicle_count,created_at
|
||||
FROM vehicle_open_api_audit WHERE app_id=? ORDER BY id DESC LIMIT 100`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]PortalAuditItem, 0)
|
||||
for rows.Next() {
|
||||
var item PortalAuditItem
|
||||
if err := rows.Scan(&item.TraceID, &item.Endpoint, &item.Result, &item.VehicleCount, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) CanRotateKey(ctx context.Context, session PortalSession, appID uint64) error {
|
||||
role, err := s.MembershipRole(ctx, session, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PortalService) user(ctx context.Context, id uint64) (PortalUser, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
|
||||
FROM vehicle_open_user WHERE id=?`, id)
|
||||
user, err := scanPortalUser(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PortalUser{}, ErrNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *PortalService) credential(ctx context.Context, username string) (portalCredential, error) {
|
||||
var credential portalCredential
|
||||
var validTo, lastLogin, locked sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id,username,display_name,password_hash,status,valid_from,valid_to,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
|
||||
FROM vehicle_open_user WHERE username=?`, username).Scan(
|
||||
&credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash,
|
||||
&credential.Status, &credential.ValidFrom, &validTo, &credential.FailedLoginCount,
|
||||
&locked, &lastLogin, &credential.CreatedBy, &credential.CreatedAt, &credential.UpdatedAt,
|
||||
)
|
||||
if validTo.Valid {
|
||||
credential.ValidTo = &validTo.Time
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
credential.LastLoginAt = &lastLogin.Time
|
||||
}
|
||||
credential.LockedUntil = locked
|
||||
credential.UserType = "partner"
|
||||
return credential, err
|
||||
}
|
||||
|
||||
func (s *PortalService) loginCredential(ctx context.Context, username string) (portalCredential, error) {
|
||||
credential, err := s.credential(ctx, username)
|
||||
if err == nil || !errors.Is(err, sql.ErrNoRows) {
|
||||
return credential, err
|
||||
}
|
||||
var admin portalCredential
|
||||
var lastLogin, locked sql.NullTime
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT id,username,display_name,password_hash,status,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
|
||||
FROM platform_user
|
||||
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(
|
||||
&admin.ID, &admin.Username, &admin.DisplayName, &admin.PasswordHash,
|
||||
&admin.Status, &admin.FailedLoginCount, &locked, &lastLogin,
|
||||
&admin.CreatedBy, &admin.CreatedAt, &admin.UpdatedAt,
|
||||
)
|
||||
if lastLogin.Valid {
|
||||
admin.LastLoginAt = &lastLogin.Time
|
||||
}
|
||||
admin.LockedUntil = locked
|
||||
admin.UserType = "admin"
|
||||
return admin, err
|
||||
}
|
||||
|
||||
func (s *PortalService) platformAdminUsername(ctx context.Context, username string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM platform_user
|
||||
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (s *PortalService) portalApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT a.id,a.name,a.app_key_prefix,a.status,ua.role,a.valid_from,a.valid_to
|
||||
FROM vehicle_open_user_app ua
|
||||
JOIN vehicle_open_app a ON a.id=ua.app_id
|
||||
WHERE ua.user_id=? ORDER BY a.id DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
apps := make([]PortalUserApp, 0)
|
||||
for rows.Next() {
|
||||
var app PortalUserApp
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
app.ValidTo = &validTo.Time
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) adminApps(ctx context.Context) ([]PortalUserApp, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id,name,app_key_prefix,status,'owner',valid_from,valid_to
|
||||
FROM vehicle_open_app ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
apps := make([]PortalUserApp, 0)
|
||||
for rows.Next() {
|
||||
var app PortalUserApp
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
app.ValidTo = &validTo.Time
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) membershipRole(ctx context.Context, userID, appID uint64) (string, error) {
|
||||
var role string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT role FROM vehicle_open_user_app WHERE user_id=? AND app_id=?`, userID, appID).Scan(&role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (s *PortalService) MembershipRole(ctx context.Context, session PortalSession, appID uint64) (string, error) {
|
||||
if session.UserType == "admin" {
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, appID).Scan(&exists); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists == 0 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "owner", nil
|
||||
}
|
||||
return s.membershipRole(ctx, session.UserID, appID)
|
||||
}
|
||||
|
||||
func (s *PortalService) userAudit(ctx context.Context, userID uint64, actor, action, result string, detail any, remoteAddr string) error {
|
||||
var nullableUser any
|
||||
if userID > 0 {
|
||||
nullableUser = userID
|
||||
}
|
||||
var encoded any
|
||||
if detail != nil {
|
||||
value, _ := json.Marshal(detail)
|
||||
encoded = string(value)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user_audit(user_id,actor,action,result,detail_json,remote_addr)
|
||||
VALUES(?,?,?,?,?,?)`, nullableUser, truncate(actor, 96), action, result, encoded, truncate(remoteAddr, 96))
|
||||
return err
|
||||
}
|
||||
|
||||
func scanPortalUser(row scanner) (PortalUser, error) {
|
||||
var user PortalUser
|
||||
var validTo, lastLogin sql.NullTime
|
||||
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Status, &user.ValidFrom, &validTo, &lastLogin, &user.CreatedBy, &user.CreatedAt, &user.UpdatedAt)
|
||||
if validTo.Valid {
|
||||
user.ValidTo = &validTo.Time
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
user.LastLoginAt = &lastLogin.Time
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func validatePortalUserInput(input *PortalUserInput, passwordRequired bool) (time.Time, *time.Time, error) {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
||||
if input.Status == "" {
|
||||
input.Status = "enabled"
|
||||
}
|
||||
if !portalUsernamePattern.MatchString(input.Username) || input.DisplayName == "" || len([]rune(input.DisplayName)) > 96 {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid portal user", ErrInvalidRequest)
|
||||
}
|
||||
if input.Status != "enabled" && input.Status != "disabled" {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid user status", ErrInvalidRequest)
|
||||
}
|
||||
if passwordRequired || input.Password != "" {
|
||||
if err := validatePortalPassword(input.Password); err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
}
|
||||
return parseInterval(input.ValidFrom, input.ValidTo)
|
||||
}
|
||||
|
||||
func validatePortalPassword(password string) error {
|
||||
if len(password) < 12 || len(password) > 128 {
|
||||
return fmt.Errorf("%w: password must be 12-128 characters", ErrInvalidRequest)
|
||||
}
|
||||
var lower, upper, digit bool
|
||||
for _, value := range password {
|
||||
switch {
|
||||
case value >= 'a' && value <= 'z':
|
||||
lower = true
|
||||
case value >= 'A' && value <= 'Z':
|
||||
upper = true
|
||||
case value >= '0' && value <= '9':
|
||||
digit = true
|
||||
}
|
||||
}
|
||||
if !lower || !upper || !digit {
|
||||
return fmt.Errorf("%w: password requires upper, lower and digit", ErrInvalidRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPortalRole(role string) bool {
|
||||
return role == "owner" || role == "developer" || role == "viewer"
|
||||
}
|
||||
|
||||
func credentialTable(userType string) string {
|
||||
if userType == "admin" {
|
||||
return "platform_user"
|
||||
}
|
||||
return "vehicle_open_user"
|
||||
}
|
||||
|
||||
func auditPortalUserID(credential portalCredential) uint64 {
|
||||
if credential.UserType == "admin" {
|
||||
return 0
|
||||
}
|
||||
return credential.ID
|
||||
}
|
||||
|
||||
func newPortalSessionToken() (string, [sha256.Size]byte, error) {
|
||||
raw, err := randomHexBytes(32)
|
||||
if err != nil {
|
||||
return "", [sha256.Size]byte{}, err
|
||||
}
|
||||
return raw, sha256.Sum256([]byte(raw)), nil
|
||||
}
|
||||
|
||||
func randomHexBytes(size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestPortalLoginAcceptsEnabledInternalPlatformAdmin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Date(2026, 7, 20, 16, 0, 0, 0, time.Local)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("AdminPass2026"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectQuery(regexp.QuoteMeta("FROM vehicle_open_user WHERE username=?")).
|
||||
WithArgs("admin").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery("FROM platform_user").
|
||||
WithArgs("admin").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "username", "display_name", "password_hash", "status",
|
||||
"failed_login_count", "locked_until", "last_login_at",
|
||||
"created_by", "created_at", "updated_at",
|
||||
}).AddRow(7, "admin", "平台管理员", string(hash), "enabled", 0, nil, nil, "bootstrap", now, now))
|
||||
mock.ExpectExec("INSERT INTO vehicle_open_user_session").
|
||||
WithArgs(sqlmock.AnyArg(), nil, uint64(7), sqlmock.AnyArg(), now, now.Add(time.Hour), now, "127.0.0.1", "test-agent").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE platform_user SET failed_login_count=0").
|
||||
WithArgs(now, uint64(7)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO vehicle_open_user_audit").
|
||||
WithArgs(nil, "admin", "login", "success", sqlmock.AnyArg(), "127.0.0.1").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
service := NewPortalService(db, time.Hour)
|
||||
service.now = func() time.Time { return now }
|
||||
result, err := service.Login(context.Background(), PortalLoginRequest{
|
||||
Username: "admin",
|
||||
Password: "AdminPass2026",
|
||||
}, "127.0.0.1", "test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("admin login failed: %v", err)
|
||||
}
|
||||
if result.Session.UserType != "admin" || result.Session.Username != "admin" || len(result.AccessToken) != 64 {
|
||||
t.Fatalf("unexpected admin session: %+v", result.Session)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalAdminMembershipHasOwnerAccessToEveryApp(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM vehicle_open_app WHERE id=?")).
|
||||
WithArgs(uint64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
service := NewPortalService(db, time.Hour)
|
||||
role, err := service.MembershipRole(context.Background(), PortalSession{UserID: 7, UserType: "admin"}, 12)
|
||||
if err != nil || role != "owner" {
|
||||
t.Fatalf("role=%q err=%v", role, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalVehicleCatalogListsBoundVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT b.vin,").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem"}).
|
||||
AddRow("LNB00000000000001", "辽A00001", "羚牛").
|
||||
AddRow("LNB00000000000002", "", "羚牛"))
|
||||
|
||||
service := NewPortalService(db, time.Hour)
|
||||
items, err := service.VehicleCatalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].Plate != "辽A00001" || items[1].Source != "车辆主数据" {
|
||||
t.Fatalf("unexpected vehicle catalog: %+v", items)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
732
vehicle-data-platform/apps/api/internal/openplatform/service.go
Normal file
732
vehicle-data-platform/apps/api/internal/openplatform/service.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
|
||||
)
|
||||
|
||||
var appKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("open platform appKey unauthorized")
|
||||
ErrForbidden = errors.New("open platform vehicle forbidden")
|
||||
ErrInvalidRequest = errors.New("open platform invalid request")
|
||||
ErrNotFound = errors.New("open platform resource not found")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
|
||||
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
||||
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
|
||||
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
||||
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||
CreateMileageSnapshot(context.Context, MileageSnapshot) error
|
||||
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
||||
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
||||
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
||||
Audit(context.Context, uint64, string, string, string, int, string) error
|
||||
|
||||
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
|
||||
ListApps(context.Context) ([]App, error)
|
||||
UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error)
|
||||
RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error)
|
||||
ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error)
|
||||
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
type parsedGrant struct {
|
||||
VIN string
|
||||
ValidFrom time.Time
|
||||
ValidTo *time.Time
|
||||
}
|
||||
|
||||
func NewService(repository Repository) *Service {
|
||||
return &Service{
|
||||
repository: repository,
|
||||
now: time.Now,
|
||||
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, request QueryRequest) ([]HydrogenResult, error) {
|
||||
plates, date, start, end, err := s.validateQuery(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
values, err := s.repository.DailyHydrogen(ctx, vins, date)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := make([]HydrogenResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
// Sampling sufficiency is decided by the producer and persisted in
|
||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||
// with one transaction, while pressure-derived rows require two samples.
|
||||
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
|
||||
plates, date, start, end, err := s.validateQuery(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
missingVINs := missingMileageVINs(vins, values)
|
||||
carried := map[string]DailyMileage{}
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
results := make([]MileageResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, value.MileageKm)
|
||||
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, 0)
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
|
||||
request.Cursor = strings.TrimSpace(request.Cursor)
|
||||
plates, startDate, endDate, start, end, pageSize, protocols, err := s.validateMileageRange(request)
|
||||
if err != nil {
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
hash := mileageRangeRequestHash(startDate, endDate, plates, pageSize, protocols)
|
||||
var (
|
||||
app AppCredential
|
||||
snapshot MileageSnapshot
|
||||
offset int
|
||||
)
|
||||
if request.Cursor == "" {
|
||||
vehicles := map[string]AuthorizedVehicle{}
|
||||
app, vehicles, err = s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
snapshotID, idErr := newSnapshotID()
|
||||
if idErr != nil {
|
||||
return MileageRangeResponse{}, idErr
|
||||
}
|
||||
snapshot = MileageSnapshot{
|
||||
ID: snapshotID,
|
||||
AppID: app.ID,
|
||||
RequestHash: hash[:],
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
ExpiresAt: s.now().Add(2 * time.Hour),
|
||||
Vehicles: orderedVehicles(vehicles),
|
||||
}
|
||||
snapshot.VehicleCount = len(snapshot.Vehicles)
|
||||
if err = s.repository.CreateMileageSnapshot(ctx, snapshot); err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, len(snapshot.Vehicles), err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
} else {
|
||||
snapshotID, parsedOffset, cursorErr := parseMileageCursor(request.Cursor)
|
||||
if cursorErr != nil {
|
||||
return MileageRangeResponse{}, cursorErr
|
||||
}
|
||||
offset = parsedOffset
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return MileageRangeResponse{}, ErrUnauthorized
|
||||
}
|
||||
app, err = s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "mileage_range_query", "denied", traceID, len(plates), ErrUnauthorized.Error())
|
||||
return MileageRangeResponse{}, ErrUnauthorized
|
||||
}
|
||||
snapshot, err = s.repository.LoadMileageSnapshot(ctx, snapshotID, app.ID, s.now())
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
if !bytes.Equal(snapshot.RequestHash, hash[:]) || snapshot.StartDate != startDate || snapshot.EndDate != endDate {
|
||||
return MileageRangeResponse{}, fmt.Errorf("%w: cursor does not match request", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
dayCount := int(end.Sub(start).Hours() / 24)
|
||||
total := dayCount * snapshot.VehicleCount
|
||||
if offset < 0 || offset > total {
|
||||
return MileageRangeResponse{}, fmt.Errorf("%w: cursor offset out of range", ErrInvalidRequest)
|
||||
}
|
||||
pageEnd := offset + pageSize
|
||||
if pageEnd > total {
|
||||
pageEnd = total
|
||||
}
|
||||
positions := make([]mileageRangePosition, 0, pageEnd-offset)
|
||||
vinSet := make(map[string]struct{})
|
||||
var queryStart, queryEnd time.Time
|
||||
for index := offset; index < pageEnd; index++ {
|
||||
dayOffset := index / snapshot.VehicleCount
|
||||
vehicle := snapshot.Vehicles[index%snapshot.VehicleCount]
|
||||
date := start.AddDate(0, 0, dayOffset)
|
||||
if len(positions) == 0 {
|
||||
queryStart = date
|
||||
}
|
||||
queryEnd = date
|
||||
positions = append(positions, mileageRangePosition{vehicle: vehicle, date: date.Format("2006-01-02")})
|
||||
vinSet[vehicle.VIN] = struct{}{}
|
||||
}
|
||||
values := map[string]DailyMileage{}
|
||||
carried := map[string]DailyMileage{}
|
||||
if len(positions) > 0 {
|
||||
vins := make([]string, 0, len(vinSet))
|
||||
for vin := range vinSet {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
missingVINs := missingMileageRangeInitialVINs(positions, values)
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
if carried == nil {
|
||||
carried = map[string]DailyMileage{}
|
||||
}
|
||||
}
|
||||
}
|
||||
results := make([]MileageRangeResult, 0, len(positions))
|
||||
for _, position := range positions {
|
||||
item := MileageRangeResult{
|
||||
VIN: position.vehicle.VIN,
|
||||
PlateNumber: position.vehicle.Plate,
|
||||
Date: position.date,
|
||||
Status: StatusNoData,
|
||||
}
|
||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, value.MileageKm)
|
||||
carried[position.vehicle.VIN] = value
|
||||
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, 0)
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
response := MileageRangeResponse{
|
||||
Code: "SUCCESS",
|
||||
Message: "success",
|
||||
Data: results,
|
||||
SnapshotID: snapshot.ID,
|
||||
TraceID: traceID,
|
||||
}
|
||||
if pageEnd < total {
|
||||
cursor := mileageCursor(snapshot.ID, pageEnd)
|
||||
response.NextCursor = &cursor
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "success", traceID, snapshot.VehicleCount, "")
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type mileageRangePosition struct {
|
||||
vehicle AuthorizedVehicle
|
||||
date string
|
||||
}
|
||||
|
||||
func (s *Service) QueryTotalMileage(ctx context.Context, appKey, traceID string, request TotalMileageQueryRequest) (TotalMileageResult, error) {
|
||||
vin, queryTime, protocols, protocolInput, err := s.validateTotalMileageQuery(request)
|
||||
if err != nil {
|
||||
return TotalMileageResult{}, err
|
||||
}
|
||||
result := TotalMileageResult{VIN: vin, QueryTime: queryTime.Format("2006-01-02 15:04:05"), ProtocolInput: protocolInput, SelectionPolicy: strings.Join(vehicleprotocol.MileagePriority(), " > "), Status: StatusNoData}
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return TotalMileageResult{}, ErrUnauthorized
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), queryTime, queryTime)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "total_mileage_query", "denied", traceID, 1, ErrUnauthorized.Error())
|
||||
return TotalMileageResult{}, ErrUnauthorized
|
||||
}
|
||||
authorized, err := s.repository.AuthorizedVIN(ctx, app.ID, vin, queryTime)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
|
||||
return TotalMileageResult{}, err
|
||||
}
|
||||
if !authorized {
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "denied", traceID, 1, ErrForbidden.Error())
|
||||
return TotalMileageResult{}, ErrForbidden
|
||||
}
|
||||
point, err := s.repository.TotalMileage(ctx, vin, queryTime, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
|
||||
return TotalMileageResult{}, err
|
||||
}
|
||||
if point != nil {
|
||||
value := round3(point.TotalMileageKm)
|
||||
result.TotalMileageKm = &value
|
||||
result.Protocol = point.Protocol
|
||||
result.MileageMeaning = totalMileageMeaning(point.Protocol)
|
||||
result.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
difference := int64(queryTime.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
result.TimeDifferenceSeconds = &difference
|
||||
result.Status = StatusNormal
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "success", traceID, 1, "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateTotalMileageQuery(request TotalMileageQueryRequest) (string, time.Time, []string, string, error) {
|
||||
vin := strings.ToUpper(strings.TrimSpace(request.VIN))
|
||||
if !regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`).MatchString(vin) {
|
||||
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
|
||||
}
|
||||
queryTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.Time), s.location)
|
||||
if err != nil {
|
||||
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
|
||||
}
|
||||
input := strings.TrimSpace(request.Protocol)
|
||||
if input == "" {
|
||||
return vin, queryTime, vehicleprotocol.MileagePriority(), "", nil
|
||||
}
|
||||
canonical, ok := vehicleprotocol.Canonical(input)
|
||||
if !ok {
|
||||
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
|
||||
}
|
||||
return vin, queryTime, []string{canonical}, input, nil
|
||||
}
|
||||
|
||||
func totalMileageMeaning(protocol string) string {
|
||||
switch protocol {
|
||||
case vehicleprotocol.GB32960:
|
||||
return "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)"
|
||||
case vehicleprotocol.YutongMQTT:
|
||||
return "车辆仪表盘或车端控制器累计总里程(MQTT平台上报)"
|
||||
case vehicleprotocol.JT808:
|
||||
return "定位终端累计里程(GPS/终端侧计算,非车辆仪表盘里程)"
|
||||
default:
|
||||
return "车辆累计总里程"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) authorize(ctx context.Context, rawKey string, plates []string, start, end time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
|
||||
if !appKeyPattern.MatchString(rawKey) {
|
||||
return AppCredential{}, nil, ErrUnauthorized
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(rawKey))), s.now(), start, end)
|
||||
if err != nil {
|
||||
return AppCredential{}, nil, ErrUnauthorized
|
||||
}
|
||||
vehicles, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
|
||||
if err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
if len(plates) > 0 && len(vehicles) != len(plates) {
|
||||
return app, nil, ErrForbidden
|
||||
}
|
||||
return app, vehicles, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateQuery(request QueryRequest) ([]string, string, time.Time, time.Time, error) {
|
||||
plates, err := normalizePlates(request.PlateNumbers, 200)
|
||||
if err != nil {
|
||||
return nil, "", time.Time{}, time.Time{}, err
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.Date), s.location)
|
||||
if err != nil {
|
||||
return nil, "", time.Time{}, time.Time{}, fmt.Errorf("%w: invalid date", ErrInvalidRequest)
|
||||
}
|
||||
return plates, start.Format("2006-01-02"), start, start.AddDate(0, 0, 1), nil
|
||||
}
|
||||
|
||||
func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, string, string, time.Time, time.Time, int, []string, error) {
|
||||
plates, err := normalizePlates(request.PlateNumbers, 5000)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
|
||||
}
|
||||
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.StartDate), s.location)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid startDate", ErrInvalidRequest)
|
||||
}
|
||||
endInclusive, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.EndDate), s.location)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid endDate", ErrInvalidRequest)
|
||||
}
|
||||
if endInclusive.Before(start) {
|
||||
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)
|
||||
}
|
||||
pageSize := request.PageSize
|
||||
if pageSize == 0 {
|
||||
pageSize = 5000
|
||||
}
|
||||
if pageSize < 1 || pageSize > 5000 {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: pageSize must be between 1 and 5000", ErrInvalidRequest)
|
||||
}
|
||||
return plates, start.Format("2006-01-02"), endInclusive.Format("2006-01-02"), start, end, pageSize, protocols, nil
|
||||
}
|
||||
|
||||
func normalizeProtocolPriority(input ProtocolPriority) ([]string, error) {
|
||||
if !input.Present && input.Values == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(input.Values) == 0 {
|
||||
return nil, fmt.Errorf("%w: protocolPriority must not be empty", ErrInvalidRequest)
|
||||
}
|
||||
protocols := make([]string, 0, len(input.Values))
|
||||
seen := make(map[string]bool, len(input.Values))
|
||||
for _, protocol := range input.Values {
|
||||
if seen[protocol] {
|
||||
return nil, fmt.Errorf("%w: protocolPriority contains duplicate protocol", ErrInvalidRequest)
|
||||
}
|
||||
seen[protocol] = true
|
||||
switch protocol {
|
||||
case "GB32960", "JT808":
|
||||
protocols = append(protocols, protocol)
|
||||
case "MQTT":
|
||||
protocols = append(protocols, "YUTONG_MQTT")
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: protocolPriority contains unsupported protocol", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
return protocols, nil
|
||||
}
|
||||
|
||||
func externalMileageProtocol(protocol string) string {
|
||||
if protocol == "YUTONG_MQTT" {
|
||||
return "MQTT"
|
||||
}
|
||||
return protocol
|
||||
}
|
||||
|
||||
func normalizePlates(input []string, maximum int) ([]string, error) {
|
||||
if len(input) > maximum {
|
||||
return nil, fmt.Errorf("%w: plate numbers exceed %d", ErrInvalidRequest, maximum)
|
||||
}
|
||||
plates := make([]string, 0, len(input))
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range input {
|
||||
plate := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if plate == "" || len([]rune(plate)) > 32 {
|
||||
return nil, fmt.Errorf("%w: invalid plate", ErrInvalidRequest)
|
||||
}
|
||||
if seen[plate] {
|
||||
return nil, fmt.Errorf("%w: duplicate plate", ErrInvalidRequest)
|
||||
}
|
||||
seen[plate] = true
|
||||
plates = append(plates, plate)
|
||||
}
|
||||
return plates, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateApp(ctx context.Context, input AppInput, actor string) (AppCreated, error) {
|
||||
from, to, err := s.validateAppInput(&input)
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
key, hash, prefix, err := newAppKey()
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
app, err := s.repository.CreateApp(ctx, input, hash, prefix, from, to, actor)
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
return AppCreated{App: app, AppKey: key}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListApps(ctx context.Context) ([]App, error) {
|
||||
return s.repository.ListApps(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateApp(ctx context.Context, id uint64, input AppInput, actor string) (App, error) {
|
||||
from, to, err := s.validateAppInput(&input)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
return s.repository.UpdateApp(ctx, id, input, from, to, actor)
|
||||
}
|
||||
|
||||
func (s *Service) RotateKey(ctx context.Context, id uint64, actor string) (AppCreated, error) {
|
||||
key, hash, prefix, err := newAppKey()
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
app, err := s.repository.RotateKey(ctx, id, hash, prefix, actor)
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
return AppCreated{App: app, AppKey: key}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReplaceVehicleGrants(ctx context.Context, id uint64, request VehicleGrantRequest, actor string) ([]VehicleGrant, error) {
|
||||
if len(request.Vehicles) > 5000 {
|
||||
return nil, fmt.Errorf("%w: grants exceed 5000", ErrInvalidRequest)
|
||||
}
|
||||
grants := make([]parsedGrant, 0, len(request.Vehicles))
|
||||
seen := map[string]bool{}
|
||||
for _, input := range request.Vehicles {
|
||||
vin := strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if len(vin) != 17 || seen[vin] {
|
||||
return nil, fmt.Errorf("%w: invalid or duplicate VIN", ErrInvalidRequest)
|
||||
}
|
||||
seen[vin] = true
|
||||
from, to, err := parseInterval(input.ValidFrom, input.ValidTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants = append(grants, parsedGrant{VIN: vin, ValidFrom: from, ValidTo: to})
|
||||
}
|
||||
return s.repository.ReplaceVehicleGrants(ctx, id, grants, actor)
|
||||
}
|
||||
|
||||
func (s *Service) ListVehicleGrants(ctx context.Context, id uint64) ([]VehicleGrant, error) {
|
||||
return s.repository.ListVehicleGrants(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) validateAppInput(input *AppInput) (time.Time, *time.Time, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
||||
if input.Status == "" {
|
||||
input.Status = "enabled"
|
||||
}
|
||||
if input.Name == "" || len([]rune(input.Name)) > 96 || (input.Status != "enabled" && input.Status != "disabled") {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid app", ErrInvalidRequest)
|
||||
}
|
||||
return parseInterval(input.ValidFrom, input.ValidTo)
|
||||
}
|
||||
|
||||
func parseInterval(rawFrom, rawTo string) (time.Time, *time.Time, error) {
|
||||
from, err := time.Parse(time.RFC3339, strings.TrimSpace(rawFrom))
|
||||
if err != nil {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: validFrom must be RFC3339", ErrInvalidRequest)
|
||||
}
|
||||
var to *time.Time
|
||||
if strings.TrimSpace(rawTo) != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(rawTo))
|
||||
if err != nil || !parsed.After(from) {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid validTo", ErrInvalidRequest)
|
||||
}
|
||||
to = &parsed
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func newAppKey() (string, [sha256.Size]byte, string, error) {
|
||||
var bytes [16]byte
|
||||
if _, err := rand.Read(bytes[:]); err != nil {
|
||||
return "", [sha256.Size]byte{}, "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
key := hex.EncodeToString(bytes[:])
|
||||
return key, sha256.Sum256([]byte(key)), key[:8], nil
|
||||
}
|
||||
|
||||
func vehicleVINs(vehicles map[string]AuthorizedVehicle) []string {
|
||||
seen := map[string]bool{}
|
||||
vins := make([]string, 0, len(vehicles))
|
||||
for _, vehicle := range vehicles {
|
||||
if !seen[vehicle.VIN] {
|
||||
seen[vehicle.VIN] = true
|
||||
vins = append(vins, vehicle.VIN)
|
||||
}
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins
|
||||
}
|
||||
|
||||
func vehiclePlates(vehicles map[string]AuthorizedVehicle) []string {
|
||||
plates := make([]string, 0, len(vehicles))
|
||||
for plate := range vehicles {
|
||||
plates = append(plates, plate)
|
||||
}
|
||||
sort.Strings(plates)
|
||||
return plates
|
||||
}
|
||||
|
||||
func orderedVehicles(vehicles map[string]AuthorizedVehicle) []AuthorizedVehicle {
|
||||
plates := vehiclePlates(vehicles)
|
||||
out := make([]AuthorizedVehicle, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
out = append(out, vehicles[plate])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validDailyMileage(value DailyMileage) bool {
|
||||
return value.MileageKm >= 0 &&
|
||||
value.TotalMileageKm >= 0 &&
|
||||
(value.Protocol == "GB32960" || value.Protocol == "YUTONG_MQTT" || value.Protocol == "JT808") &&
|
||||
value.DataTime != "" &&
|
||||
value.UpdatedAt != ""
|
||||
}
|
||||
|
||||
func missingMileageVINs(vins []string, values map[string]DailyMileage) []string {
|
||||
missing := make([]string, 0)
|
||||
for _, vin := range vins {
|
||||
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string {
|
||||
seen := make(map[string]struct{}, len(positions))
|
||||
missing := make([]string, 0)
|
||||
for _, position := range positions {
|
||||
vin := position.vehicle.VIN
|
||||
if _, ok := seen[vin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
value, ok := values[dailyMileageKey(vin, position.date)]
|
||||
if !ok || !validDailyMileage(value) {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
||||
item.DailyMileageKm = &dailyMileage
|
||||
totalMileage := value.TotalMileageKm
|
||||
item.TotalMileageKm = &totalMileage
|
||||
dataTime := value.DataTime
|
||||
updatedAt := value.UpdatedAt
|
||||
item.DataTime = &dataTime
|
||||
item.UpdatedAt = &updatedAt
|
||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||
item.SourceProtocol = &sourceProtocol
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
|
||||
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
||||
item.DailyMileageKm = &dailyMileage
|
||||
totalMileage := value.TotalMileageKm
|
||||
item.TotalMileageKm = &totalMileage
|
||||
dataTime := value.DataTime
|
||||
updatedAt := value.UpdatedAt
|
||||
item.DataTime = &dataTime
|
||||
item.UpdatedAt = &updatedAt
|
||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||
item.SourceProtocol = &sourceProtocol
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
|
||||
func dailyMileageKey(vin, date string) string {
|
||||
return vin + "\x00" + date
|
||||
}
|
||||
|
||||
func mileageRangeRequestHash(startDate, endDate string, plates []string, pageSize int, protocols []string) [sha256.Size]byte {
|
||||
normalized := append([]string(nil), plates...)
|
||||
sort.Strings(normalized)
|
||||
return sha256.Sum256([]byte(startDate + "\x00" + endDate + "\x00" + strconv.Itoa(pageSize) + "\x00" + strings.Join(normalized, "\x00") + "\x01" + strings.Join(protocols, "\x00")))
|
||||
}
|
||||
|
||||
func newSnapshotID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func mileageCursor(snapshotID string, offset int) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(snapshotID + ":" + strconv.Itoa(offset)))
|
||||
}
|
||||
|
||||
func parseMileageCursor(cursor string) (string, int, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cursor))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
||||
}
|
||||
parts := strings.Split(string(decoded), ":")
|
||||
if len(parts) != 2 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(parts[0]) {
|
||||
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
||||
}
|
||||
offset, err := strconv.Atoi(parts[1])
|
||||
if err != nil || offset < 0 {
|
||||
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
||||
}
|
||||
return parts[0], offset, nil
|
||||
}
|
||||
|
||||
func round3(value float64) float64 {
|
||||
if value >= 0 {
|
||||
return float64(int64(value*1000+0.5)) / 1000
|
||||
}
|
||||
return float64(int64(value*1000-0.5)) / 1000
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
app AppCredential
|
||||
authErr error
|
||||
vehicles map[string]AuthorizedVehicle
|
||||
hydrogen map[string]DailyHydrogen
|
||||
mileage map[string]DailyMileage
|
||||
priorMileage map[string]DailyMileage
|
||||
authorizedVIN bool
|
||||
totalMileage *TotalMileagePoint
|
||||
audits []string
|
||||
createdHash [sha256.Size]byte
|
||||
createdPrefix string
|
||||
requestedPlates []string
|
||||
dailyVINs []string
|
||||
dailyProtocols []string
|
||||
rangeVINs []string
|
||||
priorVINs []string
|
||||
priorCalls int
|
||||
snapshot MileageSnapshot
|
||||
}
|
||||
|
||||
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
|
||||
return f.app, f.authErr
|
||||
}
|
||||
func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, _ time.Time, _ time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
f.requestedPlates = append([]string(nil), plates...)
|
||||
return f.vehicles, nil
|
||||
}
|
||||
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
return f.hydrogen, nil
|
||||
}
|
||||
func (f *fakeRepository) DailyMileage(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = append([]string(nil), protocols...)
|
||||
return f.mileage, nil
|
||||
}
|
||||
func (f *fakeRepository) DailyMileageRange(_ context.Context, vins []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.rangeVINs = append([]string(nil), vins...)
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = append([]string(nil), protocols...)
|
||||
return f.mileage, nil
|
||||
}
|
||||
func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.priorCalls++
|
||||
f.priorVINs = append([]string(nil), vins...)
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = append([]string(nil), protocols...)
|
||||
return f.priorMileage, nil
|
||||
}
|
||||
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
|
||||
f.snapshot = snapshot
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) LoadMileageSnapshot(_ context.Context, id string, appID uint64, _ time.Time) (MileageSnapshot, error) {
|
||||
if f.snapshot.ID != id || f.snapshot.AppID != appID {
|
||||
return MileageSnapshot{}, ErrInvalidRequest
|
||||
}
|
||||
return f.snapshot, nil
|
||||
}
|
||||
func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) {
|
||||
return f.authorizedVIN, nil
|
||||
}
|
||||
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
||||
return f.totalMileage, nil
|
||||
}
|
||||
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
||||
f.audits = append(f.audits, endpoint+":"+result)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
f.createdHash, f.createdPrefix = hash, prefix
|
||||
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
|
||||
}
|
||||
func (f *fakeRepository) ListApps(context.Context) ([]App, error) { return nil, nil }
|
||||
func (f *fakeRepository) UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error) {
|
||||
return App{}, nil
|
||||
}
|
||||
func (f *fakeRepository) RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error) {
|
||||
return App{}, nil
|
||||
}
|
||||
func (f *fakeRepository) ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRepository) ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
VIN: "LTEST32960VIN0001", Protocol: "GB32960", MileageKm: 101.235, TotalMileageKm: 12345.679,
|
||||
DataTime: "2026-07-01T23:59:00+08:00", UpdatedAt: "2026-07-02T00:01:00+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
||||
request := QueryRequest{PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01"}
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("hydrogen = %#v", hydrogen)
|
||||
}
|
||||
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mileage) != 2 || mileage[0].VIN != "LTEST32960VIN0001" || mileage[0].DailyMileageKm == nil || *mileage[0].DailyMileageKm != 101.235 || mileage[0].TotalMileageKm == nil || *mileage[0].TotalMileageKm != 12345.679 || mileage[0].DataTime == nil || mileage[0].UpdatedAt == nil || mileage[1].Status != StatusNoData || mileage[1].TotalMileageKm != nil || mileage[1].DataTime != nil {
|
||||
t.Fatalf("mileage = %#v", mileage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 8.5, QualityStatus: "OK"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0002": {
|
||||
VIN: "LTEST32960VIN0002", Protocol: "YUTONG_MQTT", MileageKm: 88.8, TotalMileageKm: 10088.8,
|
||||
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
request := QueryRequest{Date: "2026-07-01"}
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h-all", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repository.requestedPlates) != 0 || len(hydrogen) != 2 || hydrogen[0].PlateNumber != "粤A12345" || hydrogen[1].PlateNumber != "粤B67890" {
|
||||
t.Fatalf("requested=%v hydrogen=%#v", repository.requestedPlates, hydrogen)
|
||||
}
|
||||
if len(repository.dailyVINs) != 2 || repository.dailyVINs[0] != "LTEST32960VIN0001" || repository.dailyVINs[1] != "LTEST32960VIN0002" {
|
||||
t.Fatalf("daily VINs=%v", repository.dailyVINs)
|
||||
}
|
||||
|
||||
mileage, err := service.QueryMileage(context.Background(), key, "trace-m-all", QueryRequest{PlateNumbers: []string{}, Date: "2026-07-01"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mileage) != 2 || mileage[0].PlateNumber != "粤A12345" || mileage[1].PlateNumber != "粤B67890" || mileage[1].DailyMileageKm == nil {
|
||||
t.Fatalf("mileage=%#v", mileage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageProtocolPriorityValidationAndExternalSourceName(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", MileageKm: 0, TotalMileageKm: 12345,
|
||||
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
priority := ProtocolPriority{Values: []string{"JT808", "GB32960", "MQTT"}}
|
||||
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-priority", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01", ProtocolPriority: priority,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(repository.dailyProtocols, ",") != "JT808,GB32960,YUTONG_MQTT" {
|
||||
t.Fatalf("internal protocols=%v", repository.dailyProtocols)
|
||||
}
|
||||
if len(result) != 1 || result[0].DailyMileageKm == nil || *result[0].DailyMileageKm != 0 || result[0].SourceProtocol == nil || *result[0].SourceProtocol != "MQTT" {
|
||||
t.Fatalf("result=%#v", result)
|
||||
}
|
||||
|
||||
for _, values := range [][]string{{}, {"YUTONG_MQTT"}, {"GB32960", "GB32960"}, {"mqtt"}} {
|
||||
invalid := ProtocolPriority{Values: values, Present: true}
|
||||
_, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-invalid", QueryRequest{
|
||||
Date: "2026-07-01", ProtocolPriority: invalid,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("priority=%v error=%v", values, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing.T) {
|
||||
previous := DailyMileage{
|
||||
VIN: "LTEST32960VIN0001", Date: "2026-07-20", Protocol: "YUTONG_MQTT",
|
||||
MileageKm: 35.5, TotalMileageKm: 8888.8,
|
||||
DataTime: "2026-07-20T23:58:00+08:00", UpdatedAt: "2026-07-21T01:15:00+08:00",
|
||||
}
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{},
|
||||
priorMileage: map[string]DailyMileage{"LTEST32960VIN0001": previous},
|
||||
}
|
||||
service := NewService(repository)
|
||||
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-21",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := result[0]
|
||||
if item.Status != StatusNormal || item.DailyMileageKm == nil || *item.DailyMileageKm != 0 ||
|
||||
item.TotalMileageKm == nil || *item.TotalMileageKm != 8888.8 ||
|
||||
item.SourceProtocol == nil || *item.SourceProtocol != "MQTT" ||
|
||||
item.DataTime == nil || *item.DataTime != previous.DataTime ||
|
||||
item.UpdatedAt == nil || *item.UpdatedAt != previous.UpdatedAt {
|
||||
t.Fatalf("carried result=%#v", item)
|
||||
}
|
||||
|
||||
rangeResult, err := service.QueryMileageRange(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry-range", MileageRangeRequest{
|
||||
StartDate: "2026-07-21", EndDate: "2026-07-22", PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rangeResult.Data) != 2 {
|
||||
t.Fatalf("range=%#v", rangeResult)
|
||||
}
|
||||
for _, row := range rangeResult.Data {
|
||||
if row.DailyMileageKm == nil || *row.DailyMileageKm != 0 ||
|
||||
row.TotalMileageKm == nil || *row.TotalMileageKm != previous.TotalMileageKm ||
|
||||
row.UpdatedAt == nil || *row.UpdatedAt != previous.UpdatedAt {
|
||||
t.Fatalf("carried range row=%#v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"沪A00002": {VIN: "LTEST32960VIN0002", Plate: "沪A00002"},
|
||||
"沪A00001": {VIN: "LTEST32960VIN0001", Plate: "沪A00001"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
dailyMileageKey("LTEST32960VIN0001", "2026-07-01"): {
|
||||
VIN: "LTEST32960VIN0001", Date: "2026-07-01", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 12000,
|
||||
DataTime: "2026-07-01T23:58:45+08:00", UpdatedAt: "2026-07-02T05:10:00+08:00",
|
||||
},
|
||||
dailyMileageKey("LTEST32960VIN0001", "2026-07-02"): {
|
||||
VIN: "LTEST32960VIN0001", Date: "2026-07-02", Protocol: "GB32960", MileageKm: 10.25, TotalMileageKm: 12010.25,
|
||||
DataTime: "2026-07-02T20:00:00+08:00", UpdatedAt: "2026-07-02T20:00:01+08:00",
|
||||
},
|
||||
dailyMileageKey("LTEST32960VIN0002", "2026-07-02"): {
|
||||
VIN: "LTEST32960VIN0002", Date: "2026-07-02", Protocol: "JT808", MileageKm: 8.5, TotalMileageKm: 9008.5,
|
||||
DataTime: "2026-07-02T21:00:00+08:00", UpdatedAt: "2026-07-02T21:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
||||
request := MileageRangeRequest{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 3}
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
first, err := service.QueryMileageRange(context.Background(), key, "trace-range-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Data) != 3 || first.SnapshotID == "" || first.NextCursor == nil {
|
||||
t.Fatalf("first=%#v", first)
|
||||
}
|
||||
if first.Data[0].PlateNumber != "沪A00001" || first.Data[0].DailyMileageKm == nil || *first.Data[0].DailyMileageKm != 0 || first.Data[0].Status != StatusNormal {
|
||||
t.Fatalf("zero mileage row=%#v", first.Data[0])
|
||||
}
|
||||
if first.Data[1].PlateNumber != "沪A00002" || first.Data[1].Status != StatusNoData || first.Data[1].DailyMileageKm != nil || first.Data[1].DataTime != nil {
|
||||
t.Fatalf("no-data row=%#v", first.Data[1])
|
||||
}
|
||||
if first.Data[2].Date != "2026-07-02" || first.Data[2].PlateNumber != "沪A00001" {
|
||||
t.Fatalf("third row=%#v", first.Data[2])
|
||||
}
|
||||
|
||||
repository.vehicles = map[string]AuthorizedVehicle{}
|
||||
request.Cursor = *first.NextCursor
|
||||
changedPriority := ProtocolPriority{Values: []string{"JT808"}}
|
||||
changedRequest := request
|
||||
changedRequest.ProtocolPriority = changedPriority
|
||||
if _, err := service.QueryMileageRange(context.Background(), key, "trace-range-changed", changedRequest); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("changed protocol priority must invalidate cursor: %v", err)
|
||||
}
|
||||
second, err := service.QueryMileageRange(context.Background(), key, "trace-range-2", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.SnapshotID != first.SnapshotID || second.NextCursor != nil || len(second.Data) != 1 || second.Data[0].PlateNumber != "沪A00002" || second.Data[0].Date != "2026-07-02" {
|
||||
t.Fatalf("second=%#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeOnlyLoadsPriorMileageForVINsMissingAtTheirFirstPageDate(t *testing.T) {
|
||||
const (
|
||||
firstVIN = "LTEST32960VIN0001"
|
||||
secondVIN = "LTEST32960VIN0002"
|
||||
)
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"沪A00001": {VIN: firstVIN, Plate: "沪A00001"},
|
||||
"沪A00002": {VIN: secondVIN, Plate: "沪A00002"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
dailyMileageKey(firstVIN, "2026-07-27"): {
|
||||
VIN: firstVIN, Date: "2026-07-27", Protocol: "GB32960",
|
||||
MileageKm: 12.5, TotalMileageKm: 12012.5,
|
||||
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
|
||||
},
|
||||
},
|
||||
priorMileage: map[string]DailyMileage{
|
||||
secondVIN: {
|
||||
VIN: secondVIN, Date: "2026-07-26", Protocol: "JT808",
|
||||
MileageKm: 8, TotalMileageKm: 9008,
|
||||
DataTime: "2026-07-26T23:00:00+08:00", UpdatedAt: "2026-07-27T01:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := NewService(repository).QueryMileageRange(
|
||||
context.Background(),
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"trace-range-missing-only",
|
||||
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.priorCalls != 1 || len(repository.priorVINs) != 1 || repository.priorVINs[0] != secondVIN {
|
||||
t.Fatalf("prior calls=%d vins=%v, want only %s", repository.priorCalls, repository.priorVINs, secondVIN)
|
||||
}
|
||||
if len(result.Data) != 2 || result.Data[0].DailyMileageKm == nil || *result.Data[0].DailyMileageKm != 12.5 ||
|
||||
result.Data[1].DailyMileageKm == nil || *result.Data[1].DailyMileageKm != 0 {
|
||||
t.Fatalf("result=%#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testing.T) {
|
||||
const vin = "LTEST32960VIN0001"
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"沪A00001": {VIN: vin, Plate: "沪A00001"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
dailyMileageKey(vin, "2026-07-27"): {
|
||||
VIN: vin, Date: "2026-07-27", Protocol: "GB32960",
|
||||
MileageKm: 12.5, TotalMileageKm: 12012.5,
|
||||
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := NewService(repository).QueryMileageRange(
|
||||
context.Background(),
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"trace-range-no-prior",
|
||||
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.priorCalls != 0 || len(repository.priorVINs) != 0 {
|
||||
t.Fatalf("prior calls=%d vins=%v, want no lookup", repository.priorCalls, repository.priorVINs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
|
||||
service := NewService(&fakeRepository{})
|
||||
for _, request := range []MileageRangeRequest{
|
||||
{StartDate: "2025-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},
|
||||
} {
|
||||
if _, _, _, _, _, _, _, err := service.validateMileageRange(request); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("request=%#v error=%v", request, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMileageReturnsRecordTimeDifferenceAndProtocolMeaning(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7}, authorizedVIN: true,
|
||||
totalMileage: &TotalMileagePoint{VIN: "LA9GG68L2PBAF4790", Protocol: "GB32960", ObservedAt: time.Date(2026, 7, 21, 9, 29, 45, 0, location), TotalMileageKm: 12345.6784},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 7, 21, 12, 0, 0, 0, location) }
|
||||
result, err := service.QueryTotalMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-total", TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.TotalMileageKm == nil || *result.TotalMileageKm != 12345.678 || result.Protocol != "GB32960" || result.RecordTime != "2026-07-21 09:29:45" || result.TimeDifferenceSeconds == nil || *result.TimeDifferenceSeconds != 15 || result.Status != StatusNormal || !strings.Contains(result.MileageMeaning, "仪表盘") {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMileageCanonicalProtocolsAndStrictTime(t *testing.T) {
|
||||
service := NewService(&fakeRepository{})
|
||||
for _, input := range []string{"GB32960", "YUTONG_MQTT", "JT808"} {
|
||||
_, _, protocols, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: input})
|
||||
if err != nil || len(protocols) != 1 || protocols[0] != input {
|
||||
t.Fatalf("%s: protocols=%v err=%v", input, protocols, err)
|
||||
}
|
||||
}
|
||||
for _, alias := range []string{"32960", "mqtt", "808"} {
|
||||
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: alias}); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("alias %s should be rejected: %v", alias, err)
|
||||
}
|
||||
}
|
||||
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026/07/21 09:30"}); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFailsClosedWhenAnyVehicleIsNotGranted(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}},
|
||||
}
|
||||
_, err := NewService(repository).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01",
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAppReturns32CharacterKeyOnlyOnce(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
service := NewService(repository)
|
||||
created, err := service.CreateApp(context.Background(), AppInput{
|
||||
Name: "partner", ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !appKeyPattern.MatchString(created.AppKey) || len(created.AppKey) != 32 || created.AppKeyPrefix != created.AppKey[:8] {
|
||||
t.Fatalf("created = %#v", created)
|
||||
}
|
||||
if created.AppKey[12] != '4' || !strings.ContainsRune("89ab", rune(created.AppKey[16])) {
|
||||
t.Fatalf("appKey is not UUID v4: %s", created.AppKey)
|
||||
}
|
||||
if repository.createdHash != sha256.Sum256([]byte(created.AppKey)) || repository.createdPrefix != created.AppKey[:8] {
|
||||
t.Fatal("repository did not receive the appKey hash and prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
||||
repository := &fakeRepository{app: AppCredential{ID: 1}, vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
}, hydrogen: map[string]DailyHydrogen{}}
|
||||
handler := NewHandler(NewService(repository))
|
||||
request := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026-07-01"}`))
|
||||
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var body ExternalResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Code != "SUCCESS" || body.Message != "success" || len(body.TraceID) != 32 {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
|
||||
allRequest := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01"}`))
|
||||
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
allResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(allResponse, allRequest)
|
||||
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) {
|
||||
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
|
||||
}
|
||||
|
||||
rangeRequest := httptest.NewRequest(http.MethodPost, MileageRangeQueryPath, strings.NewReader(`{"startDate":"2026-07-01","endDate":"2026-07-01","pageSize":10}`))
|
||||
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
rangeResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rangeResponse, rangeRequest)
|
||||
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) {
|
||||
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
|
||||
}
|
||||
|
||||
badDate := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026/07/01"}`))
|
||||
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
badResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(badResponse, badDate)
|
||||
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) {
|
||||
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
|
||||
}
|
||||
|
||||
badProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":["GB32960","GB32960"]}`))
|
||||
badProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
badProtocolResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(badProtocolResponse, badProtocol)
|
||||
if badProtocolResponse.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) ||
|
||||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) {
|
||||
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
|
||||
}
|
||||
|
||||
nullProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":null}`))
|
||||
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
nullProtocolResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
|
||||
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) {
|
||||
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StandaloneConfig struct {
|
||||
StaticDir string
|
||||
SessionTTL time.Duration
|
||||
RequestTimeout time.Duration
|
||||
Release string
|
||||
TDengine *sql.DB
|
||||
TDengineDatabase string
|
||||
}
|
||||
|
||||
func NewStandaloneServer(db *sql.DB, cfg StandaloneConfig) http.Handler {
|
||||
repository := NewMySQLRepository(db)
|
||||
if cfg.TDengine != nil {
|
||||
repository.WithTDengine(cfg.TDengine, cfg.TDengineDatabase)
|
||||
}
|
||||
service := NewService(repository)
|
||||
portal := NewPortalService(db, cfg.SessionTTL)
|
||||
external := NewExternalHandler(service, portal)
|
||||
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeStandaloneJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "open-platform-api",
|
||||
"release": strings.TrimSpace(cfg.Release),
|
||||
})
|
||||
})
|
||||
api.Handle("/", external)
|
||||
|
||||
handler := standaloneStatic(cfg.StaticDir, api)
|
||||
handler = WithDocs(handler)
|
||||
handler = standaloneSecurityHeaders(handler)
|
||||
timeout := cfg.RequestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return http.TimeoutHandler(handler, timeout, `{"error":{"code":"REQUEST_TIMEOUT","message":"请求处理超时"}}`)
|
||||
}
|
||||
|
||||
func standaloneStatic(dir string, fallback http.Handler) http.Handler {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return fallback
|
||||
}
|
||||
files := http.FileServer(http.Dir(dir))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isStandaloneAPIRoute(r.URL.Path) {
|
||||
fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
clean := filepath.Clean("/" + r.URL.Path)
|
||||
path := filepath.Join(dir, clean)
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
if clean == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
} else if strings.HasPrefix(clean, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func isStandaloneAPIRoute(path string) bool {
|
||||
return path == "/healthz" ||
|
||||
strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/portal-api/") ||
|
||||
strings.HasPrefix(path, "/open-api/")
|
||||
}
|
||||
|
||||
func standaloneSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeStandaloneJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestPortalDecodeDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "empty", err: io.EOF, want: "请求内容不能为空"},
|
||||
{name: "syntax", err: &json.SyntaxError{Offset: 2}, want: "请求内容不是有效的 JSON"},
|
||||
{name: "type", err: &json.UnmarshalTypeError{Field: "validFrom"}, want: "字段 validFrom 的数据类型不正确"},
|
||||
{name: "unknown", err: errors.New(`json: unknown field "appId"`), want: `请求包含未支持的字段 "appId"`},
|
||||
{name: "other", err: errors.New("unexpected"), want: "请求内容无法解析"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := portalDecodeDetail(test.err); got != test.want {
|
||||
t.Fatalf("portalDecodeDetail() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneServerServesPortalDocsAndCatalog(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
staticDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<div id="root"></div>`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := NewStandaloneServer(db, StandaloneConfig{
|
||||
StaticDir: staticDir,
|
||||
SessionTTL: time.Hour,
|
||||
RequestTimeout: time.Second,
|
||||
Release: "test-release",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
content string
|
||||
contentType string
|
||||
}{
|
||||
{"/", `<div id="root"></div>`, "text/html"},
|
||||
{"/healthz", `"service":"open-platform-api"`, "application/json"},
|
||||
{"/portal-api/catalog", `"daily_hydrogen"`, "application/json"},
|
||||
{"/open-api/openapi.yaml", "openapi: 3.0.3", "application/yaml"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, item.path, nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), item.content) {
|
||||
t.Fatalf("path=%s status=%d body=%s", item.path, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if value := recorder.Header().Get("Content-Type"); !strings.Contains(value, item.contentType) {
|
||||
t.Fatalf("path=%s content-type=%s", item.path, value)
|
||||
}
|
||||
if recorder.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatalf("path=%s missing security headers", item.path)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandalonePortalSessionRequiresBearerToken(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
handler := NewStandaloneServer(db, StandaloneConfig{RequestTimeout: time.Second})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/portal-api/session", nil))
|
||||
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "UNAUTHORIZED") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalCredentialValidation(t *testing.T) {
|
||||
input := PortalUserInput{
|
||||
Username: "partner.dev", DisplayName: "合作伙伴开发者",
|
||||
Password: "StrongPass2026", Status: "enabled",
|
||||
ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
|
||||
}
|
||||
if _, _, err := validatePortalUserInput(&input, true); err != nil {
|
||||
t.Fatalf("valid portal user rejected: %v", err)
|
||||
}
|
||||
if err := validatePortalPassword("weak-password"); err == nil {
|
||||
t.Fatal("weak password should be rejected")
|
||||
}
|
||||
if !validPortalRole("owner") || !validPortalRole("developer") || !validPortalRole("viewer") || validPortalRole("admin") {
|
||||
t.Fatal("unexpected portal role validation")
|
||||
}
|
||||
raw, hash, err := newPortalSessionToken()
|
||||
if err != nil || len(raw) != 64 || hash == [32]byte{} {
|
||||
t.Fatalf("invalid session token raw=%d err=%v", len(raw), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var hydrogenMassFields = []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",
|
||||
}
|
||||
|
||||
func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat {
|
||||
if noiseKg <= 0 {
|
||||
noiseKg = 0.05
|
||||
}
|
||||
if maxDropKg <= noiseKg {
|
||||
maxDropKg = 20
|
||||
}
|
||||
grouped := map[string]map[string][]HydrogenObservation{}
|
||||
for _, observation := range observations {
|
||||
observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN))
|
||||
observation.Source = strings.TrimSpace(observation.Source)
|
||||
if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 {
|
||||
continue
|
||||
}
|
||||
if grouped[observation.VIN] == nil {
|
||||
grouped[observation.VIN] = map[string][]HydrogenObservation{}
|
||||
}
|
||||
grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation)
|
||||
}
|
||||
vins := make([]string, 0, len(grouped))
|
||||
for vin := range grouped {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
stats := make([]HydrogenDailyStat, 0, len(vins))
|
||||
for _, vin := range vins {
|
||||
var selected *HydrogenDailyStat
|
||||
for source, values := range grouped[vin] {
|
||||
candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg)
|
||||
if selected == nil || betterHydrogenStat(candidate, *selected) {
|
||||
copy := candidate
|
||||
selected = ©
|
||||
}
|
||||
}
|
||||
if selected != nil {
|
||||
stats = append(stats, *selected)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat {
|
||||
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) })
|
||||
stat := HydrogenDailyStat{
|
||||
VIN: vin, Source: source, Date: date,
|
||||
FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg,
|
||||
SampleCount: len(values), QualityStatus: "OK",
|
||||
}
|
||||
abnormalDrops := 0
|
||||
cycleMinimum := values[0].MassKg
|
||||
for index := 1; index < len(values); index++ {
|
||||
value := values[index]
|
||||
sampleNoise := noiseKg
|
||||
if value.NoiseKg > sampleNoise {
|
||||
sampleNoise = value.NoiseKg
|
||||
}
|
||||
refuelThreshold := math.Max(1, cycleMinimum*0.05)
|
||||
if value.RefuelThresholdKg > 0 {
|
||||
refuelThreshold = value.RefuelThresholdKg
|
||||
}
|
||||
delta := cycleMinimum - value.MassKg
|
||||
switch {
|
||||
case value.MassKg-cycleMinimum > refuelThreshold:
|
||||
stat.RefuelCount++
|
||||
cycleMinimum = value.MassKg
|
||||
case delta > sampleNoise && delta <= maxDropKg:
|
||||
stat.ConsumptionKg += delta
|
||||
cycleMinimum = value.MassKg
|
||||
case delta > maxDropKg:
|
||||
abnormalDrops++
|
||||
}
|
||||
}
|
||||
stat.ConsumptionKg = round3(stat.ConsumptionKg)
|
||||
if stat.SampleCount < 2 {
|
||||
stat.QualityStatus = "NO_DATA"
|
||||
stat.QualityReason = "有效车载氢量样本不足2条"
|
||||
} else if abnormalDrops > 0 {
|
||||
stat.QualityStatus = "SUSPECT"
|
||||
stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg)
|
||||
}
|
||||
return stat
|
||||
}
|
||||
|
||||
func betterHydrogenStat(candidate, current HydrogenDailyStat) bool {
|
||||
rank := func(status string) int {
|
||||
switch status {
|
||||
case "OK":
|
||||
return 0
|
||||
case "SUSPECT":
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if rank(candidate.QualityStatus) != rank(current.QualityStatus) {
|
||||
return rank(candidate.QualityStatus) < rank(current.QualityStatus)
|
||||
}
|
||||
if candidate.SampleCount != current.SampleCount {
|
||||
return candidate.SampleCount > current.SampleCount
|
||||
}
|
||||
return candidate.Source < current.Source
|
||||
}
|
||||
|
||||
func ExtractHydrogenMass(parsedJSON string) (float64, bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for _, key := range hydrogenMassFields {
|
||||
if value, ok := numericValue(fields[key]); ok && value >= 0 && value <= 200 {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ExtractHydrogenRateAndMileage(parsedJSON string) (float64, float64, bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
rate, rateOK := numericValue(fields["gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"])
|
||||
mileage, mileageOK := numericValue(fields["gb32960.vehicle.total_mileage_km"])
|
||||
return rate, mileage, rateOK && mileageOK && rate >= 0 && rate <= 50 && mileage > 0
|
||||
}
|
||||
|
||||
func BuildHydrogenRateDailyStats(observations []HydrogenRateObservation, date string, maxDeltaKm float64) []HydrogenRateDailyStat {
|
||||
if maxDeltaKm <= 0 {
|
||||
maxDeltaKm = 10
|
||||
}
|
||||
grouped := map[string]map[string][]HydrogenRateObservation{}
|
||||
for _, value := range observations {
|
||||
value.VIN = strings.ToUpper(strings.TrimSpace(value.VIN))
|
||||
if len(value.VIN) != 17 || value.Rate < 0 || value.Rate > 50 || value.MileageKm <= 0 {
|
||||
continue
|
||||
}
|
||||
if grouped[value.VIN] == nil {
|
||||
grouped[value.VIN] = map[string][]HydrogenRateObservation{}
|
||||
}
|
||||
grouped[value.VIN][strings.TrimSpace(value.Source)] = append(grouped[value.VIN][strings.TrimSpace(value.Source)], value)
|
||||
}
|
||||
result := make([]HydrogenRateDailyStat, 0, len(grouped))
|
||||
for vin, sources := range grouped {
|
||||
var best *HydrogenRateDailyStat
|
||||
for source, values := range sources {
|
||||
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) })
|
||||
stat := HydrogenRateDailyStat{VIN: vin, Source: source, Date: date, SampleCount: len(values), QualityStatus: "NO_DATA", QualityReason: "尚无有效行驶里程区间"}
|
||||
movement, abnormal := 0, 0
|
||||
for i := 1; i < len(values); i++ {
|
||||
delta := values[i].MileageKm - values[i-1].MileageKm
|
||||
if delta > 0 && delta <= maxDeltaKm {
|
||||
stat.ConsumptionKg += delta * (values[i-1].Rate + values[i].Rate) / 200
|
||||
movement++
|
||||
} else if delta < 0 || delta > maxDeltaKm {
|
||||
abnormal++
|
||||
}
|
||||
}
|
||||
stat.ConsumptionKg = round3(stat.ConsumptionKg)
|
||||
if abnormal > 0 {
|
||||
stat.QualityStatus, stat.QualityReason = "SUSPECT", "存在异常里程跳变"
|
||||
} else if movement > 0 {
|
||||
stat.QualityStatus, stat.QualityReason = "OK", "按里程区间积分百公里氢耗"
|
||||
}
|
||||
if best == nil || (stat.QualityStatus == "OK" && best.QualityStatus != "OK") || (stat.QualityStatus == best.QualityStatus && stat.SampleCount > best.SampleCount) {
|
||||
copy := stat
|
||||
best = ©
|
||||
}
|
||||
}
|
||||
if best != nil {
|
||||
result = append(result, *best)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN })
|
||||
return result
|
||||
}
|
||||
|
||||
func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND tank_capacity_l>0`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
capacities := map[string]float64{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var capacity float64
|
||||
if err := rows.Scan(&vin, &capacity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vin) == 17 && capacity > 0 && capacity <= 10000 {
|
||||
capacities[vin] = capacity
|
||||
}
|
||||
}
|
||||
return capacities, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) {
|
||||
database = strings.TrimSpace(database)
|
||||
if database == "" {
|
||||
database = "lingniu_vehicle_ts"
|
||||
}
|
||||
query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json
|
||||
FROM ` + database + `.raw_frames
|
||||
WHERE protocol='GB32960'
|
||||
AND ts>='` + quoteTDTime(start) + `'
|
||||
AND ts<'` + quoteTDTime(end) + `'
|
||||
AND parse_status='OK'
|
||||
ORDER BY vin,source_endpoint,ts`
|
||||
rows, err := tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
observations := make([]HydrogenObservation, 0)
|
||||
for rows.Next() {
|
||||
var vin, parsed string
|
||||
var source sql.NullString
|
||||
var unixMS int64
|
||||
if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
capacity, capacityOK := capacities[vin]
|
||||
pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed)
|
||||
if !capacityOK || !ok {
|
||||
continue
|
||||
}
|
||||
mass, ok := PressureHydrogenMassKg(pressure, temperature, capacity)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity)
|
||||
noise := math.Min(1, math.Max(0.05, mass-stepMass))
|
||||
observations = append(observations, HydrogenObservation{
|
||||
VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass,
|
||||
TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature,
|
||||
NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05),
|
||||
})
|
||||
}
|
||||
return observations, rows.Err()
|
||||
}
|
||||
|
||||
func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||
temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||
return pressure, temperature, pressureOK && temperatureOK
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// A pressure-based rebuild is authoritative for the whole day. Delete every
|
||||
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
|
||||
// for vehicles without valid pressure observations in this run.
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_daily_energy(
|
||||
vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg,
|
||||
sample_count,refuel_count,quality_status,quality_reason,calculated_at
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func quoteTDTime(value time.Time) string {
|
||||
return strings.ReplaceAll(value.Format(time.RFC3339Nano), "'", "''")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildHydrogenDailyStatsSumsDropsAndIgnoresRefuelAndNoise(t *testing.T) {
|
||||
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10.50},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Hour), MassKg: 10.30},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Hour), MassKg: 11.90},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(4 * time.Hour), MassKg: 9.98},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
stat := stats[0]
|
||||
if stat.ConsumptionKg != 2.1 || stat.RefuelCount != 1 || stat.SampleCount != 5 || stat.QualityStatus != "OK" {
|
||||
t.Fatalf("stat = %#v", stat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) {
|
||||
base := time.Now()
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) {
|
||||
base := time.Now()
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(time.Minute), MassKg: 20},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(3 * time.Minute), MassKg: 19.9},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 3 {
|
||||
t.Fatalf("stat = %#v", stats[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) {
|
||||
for _, encoded := range []string{
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"12.3"}`,
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":12.3}`,
|
||||
} {
|
||||
value, ok := ExtractHydrogenMass(encoded)
|
||||
if !ok || value != 12.3 {
|
||||
t.Fatalf("value=%v ok=%v for %s", value, ok, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPressureHydrogenMassMatchesGuangdongVehicle(t *testing.T) {
|
||||
mass, ok := PressureHydrogenMassKg(16.4, 34, 380)
|
||||
if !ok || mass < 4.47 || mass > 4.49 {
|
||||
t.Fatalf("mass=%.3f ok=%v", mass, ok)
|
||||
}
|
||||
pressure, temperature, ok := ExtractHydrogenPressureTemperature(`{
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"16.4",
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_c":34,
|
||||
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99.9
|
||||
}`)
|
||||
if !ok || pressure != 16.4 || temperature != 34 {
|
||||
t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ type accessUnresolvedIdentityStore interface {
|
||||
AccessUnresolvedIdentities(context.Context, AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error)
|
||||
}
|
||||
|
||||
type accessIdentityClaimStore interface {
|
||||
ClaimAccessIdentity(context.Context, string, AccessIdentityClaimInput) (AccessIdentityClaimResult, error)
|
||||
}
|
||||
|
||||
func defaultAccessThresholds(now time.Time) AccessThresholdConfig {
|
||||
return AccessThresholdConfig{
|
||||
Version: 1,
|
||||
@@ -114,6 +118,42 @@ func (s *Service) AccessUnresolvedIdentities(ctx context.Context, query AccessUn
|
||||
return store.AccessUnresolvedIdentities(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
identityID = strings.TrimSpace(identityID)
|
||||
if identityID == "" || len(identityID) > 128 {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_INVALID", Message: "待认领来源标识无效,请刷新后重试"}
|
||||
}
|
||||
input.VIN = strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if !validAccessClaimVIN(input.VIN) {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VIN_INVALID", Message: "请选择有效的权威 VIN"}
|
||||
}
|
||||
input.Note = strings.TrimSpace(input.Note)
|
||||
if len([]rune(input.Note)) > 500 {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_NOTE_TOO_LONG", Message: "认领说明不能超过 500 个字"}
|
||||
}
|
||||
input.Actor = strings.TrimSpace(input.Actor)
|
||||
if input.Actor == "" {
|
||||
input.Actor = "platform-admin"
|
||||
}
|
||||
store, ok := s.store.(accessIdentityClaimStore)
|
||||
if !ok {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_READ_ONLY", Message: "当前存储不支持来源身份认领"}
|
||||
}
|
||||
return store.ClaimAccessIdentity(ctx, identityID, input)
|
||||
}
|
||||
|
||||
func validAccessClaimVIN(vin string) bool {
|
||||
if len(vin) < 6 || len(vin) > 32 {
|
||||
return false
|
||||
}
|
||||
for _, char := range vin {
|
||||
if (char < 'A' || char > 'Z') && (char < '0' || char > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessSummary, error) {
|
||||
rows, config, err := s.accessRows(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
@@ -61,6 +61,102 @@ GREATEST(0,TIMESTAMPDIFF(SECOND,r.latest_seen_at,NOW())),
|
||||
return Page[AccessUnresolvedIdentity]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ClaimAccessIdentity(ctx context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
if err := s.ensureAccessSchema(ctx); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var phone, sourcePlate, manufacturer string
|
||||
err = tx.QueryRowContext(ctx, `SELECT r.phone,COALESCE(r.plate,''),COALESCE(r.manufacturer,'')
|
||||
FROM jt808_registration r
|
||||
LEFT JOIN vehicle_identity_binding b ON b.phone=r.phone
|
||||
WHERE SHA2(CONCAT('JT808:',r.phone),256)=?
|
||||
AND COALESCE(NULLIF(TRIM(b.vin),''),'')=''
|
||||
AND (r.vin IS NULL OR TRIM(r.vin)='' OR LOWER(TRIM(r.vin))='unknown')
|
||||
LIMIT 1 FOR UPDATE`, identityID).Scan(&phone, &sourcePlate, &manufacturer)
|
||||
if err == sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"}
|
||||
}
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
|
||||
var targetPlate, currentPhone string
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(plate,''),COALESCE(phone,'') FROM vehicle_identity_binding WHERE BINARY vin=BINARY ? FOR UPDATE`, input.VIN).Scan(&targetPlate, ¤tPhone)
|
||||
if err == sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"}
|
||||
}
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if currentPhone != "" && currentPhone != phone {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_TARGET_CONFLICT", Message: "所选车辆已绑定其他 JT808 终端,请先核对换绑关系"}
|
||||
}
|
||||
|
||||
var conflictingVIN string
|
||||
err = tx.QueryRowContext(ctx, `SELECT vin FROM vehicle_identity_binding WHERE phone=? AND BINARY vin<>BINARY ? LIMIT 1 FOR UPDATE`, phone, input.VIN).Scan(&conflictingVIN)
|
||||
if err == nil {
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_SOURCE_CONFLICT", Message: "该来源已绑定其他车辆,请刷新待办后核对"}
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE vehicle_identity_binding SET phone=?,updated_at=CURRENT_TIMESTAMP WHERE BINARY vin=BINARY ?`, phone, input.VIN); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE jt808_registration SET vin=? WHERE phone=?`, input.VIN, phone); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
|
||||
profileValues := make([]string, 7)
|
||||
err = tx.QueryRowContext(ctx, `SELECT COALESCE(brand_name,''),COALESCE(model_name,''),COALESCE(vehicle_type,''),COALESCE(company_name,''),COALESCE(operation_status,''),COALESCE(access_provider,''),COALESCE(DATE_FORMAT(first_access_at,'%Y-%m-%d %H:%i:%s'),'') FROM vehicle_profile WHERE BINARY vin=BINARY ?`, input.VIN).Scan(
|
||||
&profileValues[0], &profileValues[1], &profileValues[2], &profileValues[3], &profileValues[4], &profileValues[5], &profileValues[6],
|
||||
)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
missingLabels := []string{"车辆品牌", "车型", "车辆类型", "所属企业", "运营状态", "接入服务商", "首次接入"}
|
||||
missing := make([]string, 0, len(missingLabels))
|
||||
for index, label := range missingLabels {
|
||||
if strings.TrimSpace(profileValues[index]) == "" || (index == 4 && strings.EqualFold(profileValues[index], "unknown")) {
|
||||
missing = append(missing, label)
|
||||
}
|
||||
}
|
||||
note := input.Note
|
||||
if note == "" {
|
||||
note = "核对来源标识、车牌与厂家后绑定权威 VIN"
|
||||
}
|
||||
claimedAt := time.Now().Format(time.RFC3339)
|
||||
audit, err := tx.ExecContext(ctx, `INSERT INTO vehicle_access_identity_audit
|
||||
(identity_id,protocol,identifier_hash,identifier_masked,source_plate,manufacturer,vin,actor,note,claimed_at)
|
||||
VALUES (?,'JT808',?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`, identityID, identityID, maskAccessIdentifier(phone), sourcePlate, manufacturer, input.VIN, input.Actor, note)
|
||||
if err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
auditID, _ := audit.LastInsertId()
|
||||
if err = tx.Commit(); err != nil {
|
||||
return AccessIdentityClaimResult{}, err
|
||||
}
|
||||
return AccessIdentityClaimResult{
|
||||
IdentityID: identityID, Protocol: "JT808", IdentifierMasked: maskAccessIdentifier(phone), VIN: input.VIN,
|
||||
Plate: targetPlate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing,
|
||||
ClaimedBy: input.Actor, ClaimedAt: claimedAt, AuditID: auditID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func maskAccessIdentifier(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) < 7 {
|
||||
return "***"
|
||||
}
|
||||
return value[:3] + "****" + value[len(value)-4:]
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AccessEvidence(ctx context.Context) ([]AccessEvidenceRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
v.vin,
|
||||
@@ -166,6 +262,22 @@ func (s *ProductionStore) ensureAccessSchema(ctx context.Context) error {
|
||||
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_access_threshold_audit_version (version),
|
||||
KEY idx_access_threshold_audit_changed (changed_at)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
identity_id VARCHAR(128) NOT NULL,
|
||||
protocol VARCHAR(32) NOT NULL,
|
||||
identifier_hash VARCHAR(128) NOT NULL,
|
||||
identifier_masked VARCHAR(64) NOT NULL,
|
||||
source_plate VARCHAR(64) NOT NULL,
|
||||
manufacturer VARCHAR(128) NOT NULL,
|
||||
vin VARCHAR(32) NOT NULL,
|
||||
actor VARCHAR(128) NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
claimed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY idx_access_identity_vin (vin),
|
||||
KEY idx_access_identity_claimed (claimed_at),
|
||||
UNIQUE KEY uk_access_identity_claim (identity_id)
|
||||
)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
|
||||
@@ -212,6 +212,60 @@ func TestAccessUnresolvedIdentitiesRemainMaskedAndProtocolScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimAccessIdentityBindsExistingVehicleAndReturnsProfileFollowUp(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
result, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426", Actor: "access-admin", Note: "设备交付单核对通过"})
|
||||
if err != nil {
|
||||
t.Fatalf("claim access identity: %v", err)
|
||||
}
|
||||
if result.VIN != "LB9A32A24R0LS1426" || result.Plate != "粤AG18312" || result.IdentifierMasked != "138****0001" {
|
||||
t.Fatalf("claim result lost identity evidence: %+v", result)
|
||||
}
|
||||
if !result.ProfileComplete || len(result.ProfileMissingFields) != 0 || result.AuditID == 0 || result.ClaimedBy != "access-admin" {
|
||||
t.Fatalf("claim result must expose profile and audit state: %+v", result)
|
||||
}
|
||||
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "JT808", Limit: 20})
|
||||
if err != nil || page.Total != 0 || len(page.Items) != 0 {
|
||||
t.Fatalf("claimed identity must leave the pending queue: page=%+v err=%v", page, err)
|
||||
}
|
||||
if _, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426"}); err == nil {
|
||||
t.Fatal("repeated claim must fail as stale")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionClaimAccessIdentityUsesLockedBindingAndImmutableAudit(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(`(?s)INSERT IGNORE INTO vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`(?s)SELECT r\.phone.*SHA2\(CONCAT\('JT808:',r\.phone\),256\)=\?.*FOR UPDATE`).WithArgs("identity-hash").WillReturnRows(sqlmock.NewRows([]string{"phone", "plate", "manufacturer"}).AddRow("13800000001", "粤A00001", "测试终端"))
|
||||
mock.ExpectQuery(`(?s)SELECT COALESCE\(plate,''\),COALESCE\(phone,''\).*vehicle_identity_binding.*FOR UPDATE`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"plate", "phone"}).AddRow("粤A00001", ""))
|
||||
mock.ExpectQuery(`(?s)SELECT vin FROM vehicle_identity_binding WHERE phone=\?.*FOR UPDATE`).WithArgs("13800000001", "VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}))
|
||||
mock.ExpectExec(`UPDATE vehicle_identity_binding SET phone=\?,updated_at=CURRENT_TIMESTAMP`).WithArgs("13800000001", "VIN001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE jt808_registration SET vin=\? WHERE phone=\?`).WithArgs("VIN001", "13800000001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`(?s)SELECT COALESCE\(brand_name,''\).*FROM vehicle_profile`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"brand", "model", "type", "company", "status", "provider", "first_access"}).AddRow("品牌", "车型", "乘用车", "测试企业", "active", "测试服务商", "2026-07-01 08:00:00"))
|
||||
mock.ExpectExec(`(?s)INSERT INTO vehicle_access_identity_audit`).WithArgs("identity-hash", "identity-hash", "138****0001", "粤A00001", "测试终端", "VIN001", "access-admin", "核对通过").WillReturnResult(sqlmock.NewResult(42, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
result, err := store.ClaimAccessIdentity(t.Context(), "identity-hash", AccessIdentityClaimInput{VIN: "VIN001", Actor: "access-admin", Note: "核对通过"})
|
||||
if err != nil {
|
||||
t.Fatalf("production claim: %v", err)
|
||||
}
|
||||
if result.AuditID != 42 || !result.ProfileComplete || result.IdentifierMasked != "138****0001" {
|
||||
t.Fatalf("production claim result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionUnresolvedIdentityQueueExcludesBoundPhonesAndNeverReturnsRawIdentifier(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertStore interface {
|
||||
@@ -13,17 +15,26 @@ type alertStore interface {
|
||||
AlertEvents(context.Context, AlertQuery) (Page[AlertEvent], error)
|
||||
AlertEvent(context.Context, string) (AlertEvent, error)
|
||||
AlertRules(context.Context) ([]AlertRule, error)
|
||||
AlertRulePage(context.Context, AlertRuleQuery) (AlertRulePage, error)
|
||||
AlertRuleRevisions(context.Context, string) ([]AlertRuleRevision, error)
|
||||
SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error)
|
||||
SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error)
|
||||
SetAlertRuleArchived(context.Context, string, bool, AlertRuleLifecycleRequest) (AlertRule, error)
|
||||
ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error)
|
||||
AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error)
|
||||
MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error)
|
||||
RetryAlertNotification(context.Context, int64, AlertNotificationRetryRequest) (AlertNotificationRetryResult, error)
|
||||
AlertNotificationRetryAudits(context.Context, int64) ([]AlertNotificationRetryAudit, error)
|
||||
}
|
||||
|
||||
type alertEvaluatorStore interface {
|
||||
EvaluateAlerts(context.Context) (AlertEvaluationResult, error)
|
||||
}
|
||||
|
||||
type alertNotificationHealthStore interface {
|
||||
AlertNotificationDeliveryHealth(context.Context) (AlertNotificationDeliveryHealth, error)
|
||||
}
|
||||
|
||||
func (s *Service) alertStore() (alertStore, error) {
|
||||
store, ok := s.store.(alertStore)
|
||||
if !ok {
|
||||
@@ -58,7 +69,14 @@ func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[Alert
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
return store.AlertEvents(ctx, normalizeAlertQuery(query))
|
||||
page, err := store.AlertEvents(ctx, normalizeAlertQuery(query))
|
||||
if err != nil {
|
||||
return Page[AlertEvent]{}, err
|
||||
}
|
||||
for index := range page.Items {
|
||||
page.Items[index] = normalizeVehicleEvent(page.Items[index])
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) {
|
||||
@@ -69,7 +87,11 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
event, err := store.AlertEvent(ctx, strings.TrimSpace(id))
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
@@ -80,7 +102,176 @@ func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
return store.AlertRules(ctx)
|
||||
}
|
||||
|
||||
func normalizeAlertRuleQuery(query AlertRuleQuery) AlertRuleQuery {
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
if len([]rune(query.Keyword)) > 160 {
|
||||
query.Keyword = string([]rune(query.Keyword)[:160])
|
||||
}
|
||||
query.Protocol = strings.TrimSpace(query.Protocol)
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
if query.Status != "enabled" && query.Status != "disabled" {
|
||||
query.Status = "all"
|
||||
}
|
||||
query.Lifecycle = strings.ToLower(strings.TrimSpace(query.Lifecycle))
|
||||
if query.Lifecycle != "archived" {
|
||||
query.Lifecycle = "current"
|
||||
}
|
||||
if query.Limit != 20 && query.Limit != 50 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
query.Offset = query.Offset / query.Limit * query.Limit
|
||||
return query
|
||||
}
|
||||
|
||||
func (s *Service) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
return store.AlertRulePage(ctx, normalizeAlertRuleQuery(query))
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationConfig(ctx context.Context) (AlertNotificationConfig, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return AlertNotificationConfig{}, err
|
||||
}
|
||||
return NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig), nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
store, ok := s.store.(alertNotificationHealthStore)
|
||||
if !ok {
|
||||
return AlertNotificationDeliveryHealth{}, fmt.Errorf("store does not provide notification delivery health")
|
||||
}
|
||||
health, err := store.AlertNotificationDeliveryHealth(ctx)
|
||||
if err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig)
|
||||
configured := map[string]AlertNotificationChannelCapability{}
|
||||
for _, channel := range config.Channels {
|
||||
configured[channel.Channel] = channel
|
||||
}
|
||||
byChannel := map[string]AlertNotificationChannelHealth{}
|
||||
for _, channel := range health.Channels {
|
||||
byChannel[channel.Channel] = channel
|
||||
}
|
||||
health.Channels = make([]AlertNotificationChannelHealth, 0, len(config.Channels))
|
||||
for _, capability := range config.Channels {
|
||||
channel := byChannel[capability.Channel]
|
||||
channel.Channel = capability.Channel
|
||||
channel.Label = capability.Label
|
||||
channel.Configured = capability.Configured
|
||||
health.Channels = append(health.Channels, channel)
|
||||
}
|
||||
if health.AsOf == "" {
|
||||
health.AsOf = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (s *Service) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.AlertRuleRevisions(ctx, id)
|
||||
}
|
||||
|
||||
func alertRuleInputFromRule(rule AlertRule) AlertRuleInput {
|
||||
return AlertRuleInput{
|
||||
ID: rule.ID, Name: rule.Name, Description: rule.Description, TriggerType: rule.TriggerType,
|
||||
FenceName: rule.FenceName, FenceLongitude: rule.FenceLongitude, FenceLatitude: rule.FenceLatitude, FenceRadiusM: rule.FenceRadiusM,
|
||||
Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator,
|
||||
Threshold: rule.Threshold, ThresholdHigh: rule.ThresholdHigh, BooleanThreshold: rule.BooleanThreshold,
|
||||
DurationSec: rule.DurationSec, RecoveryOperator: rule.RecoveryOperator, RecoveryThreshold: rule.RecoveryThreshold,
|
||||
RepeatIntervalSec: rule.RepeatIntervalSec, ScopeProtocols: append([]string(nil), rule.ScopeProtocols...), ScopeVINs: append([]string(nil), rule.ScopeVINs...),
|
||||
ScopeOEMs: append([]string(nil), rule.ScopeOEMs...), ScopeModels: append([]string(nil), rule.ScopeModels...), ScopeCompanies: append([]string(nil), rule.ScopeCompanies...),
|
||||
NotificationChannels: append([]string(nil), rule.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), rule.NotificationTargets...), Enabled: rule.Enabled, Version: rule.Version,
|
||||
}
|
||||
}
|
||||
|
||||
func alertRuleConfigurationSignature(input AlertRuleInput) string {
|
||||
input.Version = 0
|
||||
input.Actor = ""
|
||||
input.AuditAction = ""
|
||||
value, _ := json.Marshal(input)
|
||||
return string(value)
|
||||
}
|
||||
|
||||
func (s *Service) RollbackAlertRule(ctx context.Context, id string, request AlertRuleRollbackRequest) (AlertRule, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || request.TargetVersion <= 0 || request.CurrentVersion <= 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_VERSION_REQUIRED", Message: "自动化 ID、当前版本和目标版本不能为空"}
|
||||
}
|
||||
if request.TargetVersion == request.CurrentVersion {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_TARGET_INVALID", Message: "目标版本必须与当前版本不同"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
rules, err := store.AlertRules(ctx)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
var current *AlertRule
|
||||
for index := range rules {
|
||||
if rules[index].ID == id {
|
||||
current = &rules[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
if current.Version != request.CurrentVersion {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
revisions, err := store.AlertRuleRevisions(ctx, id)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
var target *AlertRule
|
||||
for index := range revisions {
|
||||
if revisions[index].Version == request.TargetVersion {
|
||||
target = &revisions[index].Snapshot
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_REVISION_NOT_FOUND", Message: "目标版本不存在或已被清理"}
|
||||
}
|
||||
next := alertRuleInputFromRule(*target)
|
||||
next.ID = id
|
||||
next.Version = current.Version
|
||||
next.Enabled = current.Enabled
|
||||
next.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
next.AuditAction = "rollback"
|
||||
if alertRuleConfigurationSignature(next) == alertRuleConfigurationSignature(alertRuleInputFromRule(*current)) {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_NO_CHANGES", Message: "目标版本与当前配置相同,无需恢复"}
|
||||
}
|
||||
return s.SaveAlertRule(ctx, next)
|
||||
}
|
||||
|
||||
func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
normalizeAlertRuleTrigger(&input)
|
||||
definitions, err := s.metricDefinitions(ctx)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
@@ -105,7 +296,12 @@ func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (Aler
|
||||
if err := validateAlertRuleScopes(input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels)
|
||||
if err := normalizeAlertNotificationTargets(&input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err := s.validateAlertNotificationTargets(input.NotificationTargets); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if strings.EqualFold(input.Operator, "changed") {
|
||||
input.DurationSec = 0
|
||||
}
|
||||
@@ -144,6 +340,9 @@ func validateAlertRuleScopes(input AlertRuleInput) error {
|
||||
}
|
||||
|
||||
func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if strings.TrimSpace(id) == "" || update.Version <= 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则 ID 和版本不能为空"}
|
||||
}
|
||||
@@ -155,6 +354,40 @@ func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update Ale
|
||||
return store.SetAlertRuleEnabled(ctx, strings.TrimSpace(id), update)
|
||||
}
|
||||
|
||||
func normalizeAlertRuleLifecycleRequest(request AlertRuleLifecycleRequest) (AlertRuleLifecycleRequest, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if request.Version <= 0 {
|
||||
return request, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则版本不能为空"}
|
||||
}
|
||||
if len([]rune(request.Reason)) < 4 {
|
||||
return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
||||
}
|
||||
if len([]rune(request.Reason)) > 200 {
|
||||
return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 200 字"}
|
||||
}
|
||||
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"}
|
||||
}
|
||||
normalized, err := normalizeAlertRuleLifecycleRequest(request)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
return store.SetAlertRuleArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
|
||||
if strings.TrimSpace(id) == "" || request.Version <= 0 {
|
||||
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_REQUIRED", Message: "事件 ID 和版本不能为空"}
|
||||
@@ -171,7 +404,73 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
return normalizeVehicleEvent(event), nil
|
||||
}
|
||||
|
||||
func normalizeVehicleEvent(event AlertEvent) AlertEvent {
|
||||
event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator)
|
||||
switch strings.ToLower(strings.TrimSpace(event.Status)) {
|
||||
case "processing":
|
||||
event.ExecutionState = "processing"
|
||||
case "recovered":
|
||||
event.ExecutionState = "recovered"
|
||||
case "closed":
|
||||
event.ExecutionState = "completed"
|
||||
case "ignored":
|
||||
event.ExecutionState = "ignored"
|
||||
default:
|
||||
event.ExecutionState = "pending"
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func canonicalVehicleEvent(triggerType, metric, operator string) (string, string) {
|
||||
triggerType = strings.ToLower(strings.TrimSpace(triggerType))
|
||||
metric = strings.ToLower(strings.TrimSpace(metric))
|
||||
operator = strings.ToLower(strings.TrimSpace(operator))
|
||||
if triggerType == "geofence" {
|
||||
action := map[string]string{"enter": "entered", "exit": "exited", "inside": "inside", "outside": "outside"}[operator]
|
||||
if action == "" {
|
||||
action = "changed"
|
||||
}
|
||||
return "geofence", "vehicle.geofence." + action
|
||||
}
|
||||
if triggerType == "offline" || metric == "freshness_sec" {
|
||||
return "connectivity", "vehicle.connectivity.offline"
|
||||
}
|
||||
if triggerType == "stationary" {
|
||||
return "telemetry", "vehicle.motion.stationary"
|
||||
}
|
||||
switch metric {
|
||||
case "soc_percent":
|
||||
if operator == "lt" || operator == "lte" {
|
||||
return "telemetry", "vehicle.telemetry.soc_low"
|
||||
}
|
||||
case "speed_kmh":
|
||||
if operator == "gt" || operator == "gte" {
|
||||
return "telemetry", "vehicle.motion.speed_high"
|
||||
}
|
||||
case "alarm_active":
|
||||
return "safety", "vehicle.safety.alarm_activated"
|
||||
case "hydrogen_concentration_percent":
|
||||
return "safety", "vehicle.safety.hydrogen_concentration_high"
|
||||
case "daily_mileage_km":
|
||||
return "business", "vehicle.mileage.daily_completed"
|
||||
}
|
||||
if strings.Contains(metric, "hydrogen") {
|
||||
return "safety", "vehicle.safety." + metric
|
||||
}
|
||||
if strings.Contains(metric, "mileage") || strings.HasPrefix(metric, "daily_") {
|
||||
return "business", "vehicle.business." + metric
|
||||
}
|
||||
if metric == "" {
|
||||
metric = "changed"
|
||||
}
|
||||
return "telemetry", "vehicle.telemetry." + metric
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
|
||||
@@ -184,6 +483,23 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio
|
||||
if query.Offset < 0 {
|
||||
query.Offset = 0
|
||||
}
|
||||
query.Search = strings.TrimSpace(query.Search)
|
||||
searchRunes := []rune(query.Search)
|
||||
if len(searchRunes) > 160 {
|
||||
query.Search = string(searchRunes[:160])
|
||||
}
|
||||
query.DeliveryStatus = strings.ToLower(strings.TrimSpace(query.DeliveryStatus))
|
||||
switch query.DeliveryStatus {
|
||||
case "", "failed", "queued", "delivered":
|
||||
default:
|
||||
return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||||
query.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(query.AllowedVINs) == 0 {
|
||||
return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
@@ -196,6 +512,12 @@ 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" {
|
||||
request.AllowedVINs = alertNotificationAllowedVINs(principal)
|
||||
if len(request.AllowedVINs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -203,6 +525,106 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
|
||||
return store.MarkAlertNotificationsRead(ctx, request)
|
||||
}
|
||||
|
||||
func alertNotificationAllowedVINs(principal Principal) []string {
|
||||
seen := map[string]struct{}{}
|
||||
allowed := make([]string, 0, len(principal.VehicleVINs)+len(principal.VehicleGrants))
|
||||
for _, value := range principal.VehicleVINs {
|
||||
vin := strings.ToUpper(strings.TrimSpace(value))
|
||||
if vin == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
allowed = append(allowed, vin)
|
||||
}
|
||||
for _, grant := range principal.VehicleGrants {
|
||||
vin := strings.ToUpper(strings.TrimSpace(grant.VIN))
|
||||
if vin == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[vin]; exists {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
allowed = append(allowed, vin)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func alertNotificationRetryActor(ctx context.Context) (string, error) {
|
||||
principal, ok := PrincipalFromContext(ctx)
|
||||
if !ok {
|
||||
return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_AUTH_REQUIRED", Message: "通知重试需要已认证的操作账号"}
|
||||
}
|
||||
role := strings.ToLower(strings.TrimSpace(principal.Role))
|
||||
if role != "admin" && role != "operator" {
|
||||
return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_FORBIDDEN", Message: "当前账号无权重新投递通知"}
|
||||
}
|
||||
return firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "platform-operator"), nil
|
||||
}
|
||||
|
||||
func validAlertNotificationRetryKey(value string) bool {
|
||||
if len(value) < 16 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
actor, err := alertNotificationRetryActor(ctx)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if id <= 0 {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"}
|
||||
}
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
reasonRunes := []rune(request.Reason)
|
||||
if len(reasonRunes) < 4 || len(reasonRunes) > 200 {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_REASON_INVALID", Message: "请填写 4 到 200 个字符的重试原因"}
|
||||
}
|
||||
request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey)
|
||||
if !validAlertNotificationRetryKey(request.IdempotencyKey) {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_INVALID", Message: "重试请求键无效,请重新打开确认窗口"}
|
||||
}
|
||||
if request.ExpectedAttemptCount <= 0 || request.ExpectedAttemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_INVALID", Message: "通知尝试次数无效或已经达到上限"}
|
||||
}
|
||||
request.Actor = actor
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
return store.RetryAlertNotification(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
if err := authorizeInternalOperations(ctx, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id <= 0 {
|
||||
return nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"}
|
||||
}
|
||||
store, err := s.alertStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store.AlertNotificationRetryAudits(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) {
|
||||
store, ok := s.store.(alertEvaluatorStore)
|
||||
if !ok {
|
||||
@@ -237,6 +659,10 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if severity != "critical" && severity != "major" && severity != "minor" {
|
||||
return clientError{Code: "ALERT_RULE_SEVERITY_INVALID", Message: "告警级别仅支持 critical、major、minor"}
|
||||
}
|
||||
triggerType := normalizedAlertTriggerType(input.TriggerType, input.Metric)
|
||||
if triggerType != "metric" && triggerType != "geofence" && triggerType != "stationary" && triggerType != "offline" {
|
||||
return clientError{Code: "ALERT_RULE_TRIGGER_TYPE_INVALID", Message: "自动化触发类型无效"}
|
||||
}
|
||||
valueType := strings.ToLower(strings.TrimSpace(input.ValueType))
|
||||
if valueType != "numeric" && valueType != "boolean" {
|
||||
return clientError{Code: "ALERT_RULE_VALUE_TYPE_INVALID", Message: "规则值类型仅支持 numeric 或 boolean"}
|
||||
@@ -244,6 +670,37 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if strings.TrimSpace(input.Metric) == "" {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_REQUIRED", Message: "规则指标不能为空"}
|
||||
}
|
||||
operator := strings.ToLower(strings.TrimSpace(input.Operator))
|
||||
if triggerType == "geofence" {
|
||||
if len(input.ScopeProtocols) != 1 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_PROTOCOL_REQUIRED", Message: "电子围栏必须指定唯一定位协议,避免多数据源坐标漂移或重复触发"}
|
||||
}
|
||||
if valueType != "numeric" || !strings.EqualFold(input.Metric, "geofence_distance_m") {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_TYPE_INVALID", Message: "电子围栏规则配置不完整"}
|
||||
}
|
||||
if operator != "enter" && operator != "exit" && operator != "inside" && operator != "outside" {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_MODE_INVALID", Message: "电子围栏仅支持进入、离开、围栏内或围栏外"}
|
||||
}
|
||||
if strings.TrimSpace(input.FenceName) == "" || len([]rune(strings.TrimSpace(input.FenceName))) > 80 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_NAME_INVALID", Message: "围栏名称不能为空且不能超过 80 字"}
|
||||
}
|
||||
if input.FenceLongitude < -180 || input.FenceLongitude > 180 || input.FenceLatitude < -90 || input.FenceLatitude > 90 || (input.FenceLongitude == 0 && input.FenceLatitude == 0) {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_CENTER_INVALID", Message: "围栏中心坐标无效"}
|
||||
}
|
||||
if input.FenceRadiusM < 50 || input.FenceRadiusM > 100000 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_RADIUS_INVALID", Message: "围栏半径须在 50 米到 100 公里之间"}
|
||||
}
|
||||
if (operator == "enter" || operator == "exit") && input.DurationSec != 0 {
|
||||
return clientError{Code: "ALERT_RULE_GEOFENCE_DURATION_INVALID", Message: "进入或离开围栏按状态变化立即触发,不能配置持续时间"}
|
||||
}
|
||||
if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "重复间隔超出允许范围"}
|
||||
}
|
||||
if input.Version < 0 {
|
||||
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var metric *MetricDefinition
|
||||
definitions := catalog
|
||||
if len(definitions) == 0 {
|
||||
@@ -259,13 +716,12 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if metric == nil || !metric.Alertable {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"}
|
||||
}
|
||||
if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported {
|
||||
if !alertMetricDefinitionSupported(*metric) {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"}
|
||||
}
|
||||
if metric.ValueType != valueType {
|
||||
return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"}
|
||||
}
|
||||
operator := strings.ToLower(strings.TrimSpace(input.Operator))
|
||||
allowed := map[string]bool{"gt": true, "gte": true, "lt": true, "lte": true, "eq": true, "neq": true, "between": true, "outside": true, "changed": true}
|
||||
if !allowed[operator] {
|
||||
return clientError{Code: "ALERT_RULE_OPERATOR_INVALID", Message: "规则比较符无效"}
|
||||
@@ -279,15 +735,81 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
|
||||
if valueType == "boolean" && operator != "changed" && input.BooleanThreshold == nil {
|
||||
return clientError{Code: "ALERT_RULE_BOOLEAN_REQUIRED", Message: "布尔规则必须配置目标值"}
|
||||
}
|
||||
if input.DurationSec < 0 || input.DurationSec > 86400 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
if input.DurationSec < 0 || input.DurationSec > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
|
||||
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"}
|
||||
}
|
||||
if triggerType == "stationary" && (!strings.EqualFold(input.Metric, "speed_kmh") || (operator != "lt" && operator != "lte") || input.Threshold < 0 || input.Threshold > 10 || input.DurationSec < 300) {
|
||||
return clientError{Code: "ALERT_RULE_STATIONARY_INVALID", Message: "长时间静止须使用 0–10 km/h 的速度上限且持续至少 5 分钟"}
|
||||
}
|
||||
if triggerType == "offline" && (!strings.EqualFold(input.Metric, "freshness_sec") || (operator != "gt" && operator != "gte") || input.Threshold < 60) {
|
||||
return clientError{Code: "ALERT_RULE_OFFLINE_INVALID", Message: "长时间离线须配置至少 60 秒的离线阈值"}
|
||||
}
|
||||
if input.Version < 0 {
|
||||
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedAlertTriggerType(triggerType, metric string) string {
|
||||
triggerType = strings.ToLower(strings.TrimSpace(triggerType))
|
||||
if triggerType != "" {
|
||||
return triggerType
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(metric), "freshness_sec") {
|
||||
return "offline"
|
||||
}
|
||||
return "metric"
|
||||
}
|
||||
|
||||
func normalizeAlertRuleTrigger(input *AlertRuleInput) {
|
||||
input.TriggerType = normalizedAlertTriggerType(input.TriggerType, input.Metric)
|
||||
switch input.TriggerType {
|
||||
case "geofence":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "geofence_distance_m"
|
||||
input.Threshold = input.FenceRadiusM
|
||||
input.ThresholdHigh = 0
|
||||
input.BooleanThreshold = nil
|
||||
input.RecoveryOperator = ""
|
||||
input.RecoveryThreshold = 0
|
||||
if strings.EqualFold(input.Operator, "enter") || strings.EqualFold(input.Operator, "exit") {
|
||||
input.DurationSec = 0
|
||||
}
|
||||
case "stationary":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "speed_kmh"
|
||||
if input.Operator != "lt" && input.Operator != "lte" {
|
||||
input.Operator = "lte"
|
||||
}
|
||||
input.RecoveryOperator = "gt"
|
||||
input.RecoveryThreshold = input.Threshold
|
||||
case "offline":
|
||||
input.ValueType = "numeric"
|
||||
input.Metric = "freshness_sec"
|
||||
if input.Operator != "gt" && input.Operator != "gte" {
|
||||
input.Operator = "gt"
|
||||
}
|
||||
input.DurationSec = 0
|
||||
input.RecoveryOperator = "lte"
|
||||
input.RecoveryThreshold = input.Threshold
|
||||
}
|
||||
}
|
||||
|
||||
func alertMetricDefinitionSupported(metric MetricDefinition) bool {
|
||||
if _, supported := alertMetricValue(metric.Key, alertEvaluationEvidence{}); supported {
|
||||
return true
|
||||
}
|
||||
for protocol, source := range metric.SourceFields {
|
||||
switch strings.ToUpper(strings.TrimSpace(protocol)) {
|
||||
case "GB32960", "JT808", "YUTONG_MQTT":
|
||||
if strings.TrimSpace(source) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeAlertChannels(values []string) []string {
|
||||
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
|
||||
out := make([]string, 0, len(values)+1)
|
||||
@@ -299,12 +821,163 @@ func normalizeAlertChannels(values []string) []string {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
if !seen["in_app"] {
|
||||
out = append([]string{"in_app"}, out...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func alertNotificationTargetIDValid(value string) bool {
|
||||
if len(value) < 2 || len(value) > 96 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') {
|
||||
continue
|
||||
}
|
||||
switch char {
|
||||
case '-', '_', '.', ':':
|
||||
continue
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeAlertNotificationTargets(input *AlertRuleInput) error {
|
||||
if len(input.NotificationTargets) > 20 {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_LIMIT", Message: "单条自动化最多配置 20 个通知目标"}
|
||||
}
|
||||
if len(input.NotificationTargets) == 0 {
|
||||
channels := normalizeAlertChannels(input.NotificationChannels)
|
||||
if len(channels) == 0 {
|
||||
input.NotificationChannels = []string{}
|
||||
input.NotificationTargets = []AlertNotificationTarget{}
|
||||
return nil
|
||||
}
|
||||
for _, channel := range channels {
|
||||
if channel != "in_app" {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_REQUIRED", Message: "短信、邮件和企业通讯必须选择明确的通知目标"}
|
||||
}
|
||||
}
|
||||
input.NotificationChannels = []string{"in_app"}
|
||||
input.NotificationTargets = []AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}
|
||||
return nil
|
||||
}
|
||||
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
|
||||
targets := make([]AlertNotificationTarget, 0, len(input.NotificationTargets)+1)
|
||||
seen := map[string]bool{}
|
||||
hasInApp := false
|
||||
for _, target := range input.NotificationTargets {
|
||||
target.Channel = strings.ToLower(strings.TrimSpace(target.Channel))
|
||||
target.RecipientID = strings.TrimSpace(target.RecipientID)
|
||||
target.Label = strings.TrimSpace(target.Label)
|
||||
if !allowed[target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_INVALID", Message: "通知渠道无效"}
|
||||
}
|
||||
if !alertNotificationTargetIDValid(target.RecipientID) {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_INVALID", Message: "通知目标标识无效"}
|
||||
}
|
||||
if target.Label == "" || len([]rune(target.Label)) > 80 {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_LABEL_INVALID", Message: "通知目标名称不能为空且不能超过 80 字"}
|
||||
}
|
||||
key := target.Channel + "\x00" + target.RecipientID
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
hasInApp = hasInApp || target.Channel == "in_app"
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if len(targets) > 0 && !hasInApp {
|
||||
targets = append([]AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}, targets...)
|
||||
}
|
||||
channels := make([]string, 0, len(targets))
|
||||
channelSeen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
if !channelSeen[target.Channel] {
|
||||
channelSeen[target.Channel] = true
|
||||
channels = append(channels, target.Channel)
|
||||
}
|
||||
}
|
||||
input.NotificationTargets = targets
|
||||
input.NotificationChannels = channels
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeAlertNotificationConfig(config AlertNotificationConfig) AlertNotificationConfig {
|
||||
allowed := map[string]string{"in_app": "站内信", "sms": "短信", "email": "邮件", "wecom": "企业通讯"}
|
||||
channelConfigured := map[string]bool{"in_app": true}
|
||||
for _, channel := range config.Channels {
|
||||
key := strings.ToLower(strings.TrimSpace(channel.Channel))
|
||||
if _, ok := allowed[key]; ok {
|
||||
channelConfigured[key] = channel.Configured || key == "in_app"
|
||||
}
|
||||
}
|
||||
config.Channels = make([]AlertNotificationChannelCapability, 0, len(allowed))
|
||||
for _, key := range []string{"in_app", "sms", "email", "wecom"} {
|
||||
config.Channels = append(config.Channels, AlertNotificationChannelCapability{Channel: key, Label: allowed[key], Configured: channelConfigured[key]})
|
||||
}
|
||||
targets := make([]AlertNotificationTargetOption, 0, len(config.Targets)+1)
|
||||
seen := map[string]bool{}
|
||||
for _, target := range config.Targets {
|
||||
target.ID = strings.TrimSpace(target.ID)
|
||||
target.Label = strings.TrimSpace(target.Label)
|
||||
if !alertNotificationTargetIDValid(target.ID) || target.Label == "" || len([]rune(target.Label)) > 80 || seen[target.ID] {
|
||||
continue
|
||||
}
|
||||
channels := make([]string, 0, len(target.Channels))
|
||||
for _, raw := range target.Channels {
|
||||
channel := strings.ToLower(strings.TrimSpace(raw))
|
||||
if _, ok := allowed[channel]; ok && !containsString(channels, channel) {
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
if len(channels) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[target.ID] = true
|
||||
target.Channels = channels
|
||||
targets = append(targets, target)
|
||||
}
|
||||
if !seen["platform-operators"] {
|
||||
targets = append([]AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}, targets...)
|
||||
}
|
||||
config.Targets = targets
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *Service) validateAlertNotificationTargets(targets []AlertNotificationTarget) error {
|
||||
config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig)
|
||||
capabilities := map[string]bool{}
|
||||
for _, channel := range config.Channels {
|
||||
capabilities[channel.Channel] = channel.Configured
|
||||
}
|
||||
options := map[string]map[string]bool{}
|
||||
for _, target := range config.Targets {
|
||||
options[target.ID] = map[string]bool{}
|
||||
for _, channel := range target.Channels {
|
||||
options[target.ID][channel] = true
|
||||
}
|
||||
}
|
||||
for _, target := range targets {
|
||||
if target.Channel == "in_app" {
|
||||
continue
|
||||
}
|
||||
if !capabilities[target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_UNAVAILABLE", Message: "所选外部通知渠道尚未配置发送网关"}
|
||||
}
|
||||
if len(options) > 0 && !options[target.RecipientID][target.Channel] {
|
||||
return clientError{Code: "ALERT_NOTIFICATION_TARGET_UNAVAILABLE", Message: "所选通知目标不支持当前渠道"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanUniqueStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -19,6 +19,7 @@ type alertEvaluationEvidence struct {
|
||||
SpeedKmh, SOCPercent, Longitude, Latitude float64
|
||||
AlarmFlag int64
|
||||
FreshnessSec, DataDelaySec int
|
||||
HasLocation bool
|
||||
}
|
||||
|
||||
type alertCandidateState struct {
|
||||
@@ -49,11 +50,11 @@ const (
|
||||
alertObservationLate
|
||||
)
|
||||
|
||||
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
|
||||
const alertEventInsertSQL = `INSERT INTO vehicle_alert_event(id,fingerprint,rule_id,rule_name,rule_version,severity,trigger_type,status,vin,plate,protocol,metric,operator,trigger_value,threshold_value,threshold_high,unit,duration_sec,location_text,longitude,latitude,source_event_id,event_at,received_at,triggered_at) VALUES(?,?,?,?,?,?,?,'unprocessed',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP(3))`
|
||||
|
||||
func buildAlertEventInsert(id, fingerprint string, rule AlertRule, item alertEvaluationEvidence, value float64) (string, []any) {
|
||||
return alertEventInsertSQL, []any{
|
||||
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity,
|
||||
id, fingerprint, rule.ID, rule.Name, rule.Version, rule.Severity, normalizedAlertTriggerType(rule.TriggerType, rule.Metric),
|
||||
item.VIN, item.Plate, item.Protocol, rule.Metric, rule.Operator, value,
|
||||
rule.Threshold, rule.ThresholdHigh, alertMetricUnit(rule.Metric), rule.DurationSec,
|
||||
item.Location, item.Longitude, item.Latitude, item.SourceEventID,
|
||||
@@ -118,7 +119,7 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
if !alertRuleInScope(rule, item) {
|
||||
continue
|
||||
}
|
||||
value, supported := alertMetricValue(rule.Metric, item)
|
||||
value, supported := alertRuleMetricValue(rule, item)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
@@ -138,22 +139,15 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
previous, exists := ruleStates[fingerprint]
|
||||
if exists && !observedAt.After(previous.LastObservedAt) {
|
||||
previous, stateExists := ruleStates[fingerprint]
|
||||
matched, normalized, stateful := alertRuleMatches(rule, value, previous, stateExists)
|
||||
if stateful {
|
||||
if stateExists && !observedAt.After(previous.LastObservedAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched = exists && previous.LastValue != normalized
|
||||
stateUpserts = append(stateUpserts, alertRuleStateUpsert{RuleID: rule.ID, VIN: item.VIN, Protocol: item.Protocol, LastValue: normalized, ObservedAt: observedAt})
|
||||
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
|
||||
} else {
|
||||
matched = compareAlertRuleValue(value, rule)
|
||||
}
|
||||
if matched {
|
||||
if len(activeEvents[fingerprint]) > 0 {
|
||||
@@ -193,12 +187,12 @@ func (s *ProductionStore) EvaluateAlerts(ctx context.Context) (AlertEvaluationRe
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_event_action(event_id,action,from_status,to_status,actor,note) VALUES(?,'trigger','','unprocessed','alert-evaluator','规则命中并达到持续时间')`, id); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, channel := range rule.NotificationChannels {
|
||||
for _, target := range rule.NotificationTargets {
|
||||
delivery := "reserved"
|
||||
if channel == "in_app" {
|
||||
delivery = "created"
|
||||
if target.Channel == "in_app" {
|
||||
delivery = "sent"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s:%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,recipient,recipient_ref,delivery_status) VALUES(?,?,?,?,?,?,?,?)`, id, alertNotificationTitle(rule), alertNotificationContent(rule, item, value, unit), rule.Severity, target.Channel, target.Label, target.RecipientID, delivery); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
@@ -454,6 +448,7 @@ func (s *ProductionStore) alertEvaluationEvidence(ctx context.Context) ([]alertE
|
||||
if delay.Valid {
|
||||
item.DataDelaySec = int(delay.Int64)
|
||||
}
|
||||
item.HasLocation = validAlertCoordinate(item.Longitude, item.Latitude)
|
||||
items = append(items, item)
|
||||
if len(items) > alertEvaluationVehicleLimit {
|
||||
return nil, fmt.Errorf("alert evaluation evidence exceeds safety limit %d", alertEvaluationVehicleLimit)
|
||||
@@ -506,6 +501,95 @@ func alertMetricValue(metric string, item alertEvaluationEvidence) (float64, boo
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func alertRuleMetricValue(rule AlertRule, item alertEvaluationEvidence) (float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
if !item.HasLocation || !validAlertCoordinate(rule.FenceLongitude, rule.FenceLatitude) || rule.FenceRadiusM <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return geofenceDistanceMeters(rule.FenceLongitude, rule.FenceLatitude, item.Longitude, item.Latitude), true
|
||||
}
|
||||
return alertMetricValue(rule.Metric, item)
|
||||
}
|
||||
|
||||
func alertRuleMatches(rule AlertRule, value float64, previous alertRuleState, previousExists bool) (bool, float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
inside := value <= rule.FenceRadiusM
|
||||
normalized := 0.0
|
||||
if inside {
|
||||
normalized = 1
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(rule.Operator)) {
|
||||
case "enter":
|
||||
return previousExists && previous.LastValue == 0 && inside, normalized, true
|
||||
case "exit":
|
||||
return previousExists && previous.LastValue != 0 && !inside, normalized, true
|
||||
case "inside":
|
||||
return inside, normalized, true
|
||||
case "outside":
|
||||
return !inside, normalized, true
|
||||
default:
|
||||
return false, normalized, true
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
return previousExists && previous.LastValue != normalized, normalized, true
|
||||
}
|
||||
return compareAlertRuleValue(value, rule), 0, false
|
||||
}
|
||||
|
||||
func validAlertCoordinate(longitude, latitude float64) bool {
|
||||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||
}
|
||||
|
||||
func geofenceDistanceMeters(centerLongitude, centerLatitude, longitude, latitude float64) float64 {
|
||||
const earthRadiusM = 6371008.8
|
||||
toRadians := math.Pi / 180
|
||||
lat1, lat2 := centerLatitude*toRadians, latitude*toRadians
|
||||
dLat := (latitude - centerLatitude) * toRadians
|
||||
dLon := (longitude - centerLongitude) * toRadians
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusM * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
func alertNotificationTitle(rule AlertRule) string {
|
||||
if strings.EqualFold(rule.Severity, "critical") {
|
||||
return "【高优先级】" + rule.Name
|
||||
}
|
||||
return rule.Name
|
||||
}
|
||||
|
||||
func alertNotificationContent(rule AlertRule, item alertEvaluationEvidence, value float64, unit string) string {
|
||||
vehicle := firstNonEmpty(item.Plate, item.VIN)
|
||||
switch normalizedAlertTriggerType(rule.TriggerType, rule.Metric) {
|
||||
case "geofence":
|
||||
mode := map[string]string{"enter": "进入", "exit": "离开", "inside": "位于", "outside": "持续位于围栏外"}[strings.ToLower(rule.Operator)]
|
||||
return fmt.Sprintf("%s %s电子围栏“%s”,距中心 %.0f m", vehicle, mode, rule.FenceName, value)
|
||||
case "stationary":
|
||||
return fmt.Sprintf("%s 已低速静止 %s,当前速度 %.2f km/h", vehicle, formatAlertDurationGo(rule.DurationSec), value)
|
||||
case "offline":
|
||||
return fmt.Sprintf("%s 已离线 %s", vehicle, formatAlertDurationGo(int(value)))
|
||||
default:
|
||||
return fmt.Sprintf("%s 触发%s:%.2f %s", vehicle, rule.Name, value, unit)
|
||||
}
|
||||
}
|
||||
|
||||
func formatAlertDurationGo(seconds int) string {
|
||||
if seconds%86400 == 0 && seconds >= 86400 {
|
||||
return fmt.Sprintf("%d 天", seconds/86400)
|
||||
}
|
||||
if seconds%3600 == 0 && seconds >= 3600 {
|
||||
return fmt.Sprintf("%d 小时", seconds/3600)
|
||||
}
|
||||
if seconds%60 == 0 && seconds >= 60 {
|
||||
return fmt.Sprintf("%d 分钟", seconds/60)
|
||||
}
|
||||
return fmt.Sprintf("%d 秒", seconds)
|
||||
}
|
||||
func compareAlertValue(value float64, operator string, threshold float64) bool {
|
||||
switch strings.ToLower(operator) {
|
||||
case "gt":
|
||||
@@ -542,6 +626,30 @@ func alertMetricUnit(metric string) string {
|
||||
return "%"
|
||||
case "freshness_sec", "data_delay_sec":
|
||||
return "秒"
|
||||
case "total_mileage_km":
|
||||
return "km"
|
||||
case "total_voltage_v", "fuel_cell_voltage_v", "max_cell_voltage_v", "min_cell_voltage_v":
|
||||
return "V"
|
||||
case "total_current_a", "fuel_cell_current_a":
|
||||
return "A"
|
||||
case "insulation_kohm":
|
||||
return "kΩ"
|
||||
case "hydrogen_consumption_kg_per_100km":
|
||||
return "kg/100km"
|
||||
case "hydrogen_concentration_percent":
|
||||
return "%"
|
||||
case "hydrogen_pressure_mpa":
|
||||
return "MPa"
|
||||
case "hydrogen_temperature_c", "max_battery_temperature_c", "min_battery_temperature_c":
|
||||
return "℃"
|
||||
case "geofence_distance_m":
|
||||
return "m"
|
||||
case "engine_speed_rpm":
|
||||
return "rpm"
|
||||
case "gnss_satellite_count":
|
||||
return "颗"
|
||||
case "fuel_l":
|
||||
return "L"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package platform
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -11,25 +12,55 @@ func (m *MockStore) seedAlertCenter() {
|
||||
now := time.Now()
|
||||
boolTrue := true
|
||||
m.alertRules = []AlertRule{
|
||||
{ID: "rule-speeding", Name: "持续超速告警", Description: "速度持续高于阈值", Severity: "critical", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 60, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808", "GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-offline", Name: "离线超时", Description: "车辆超过阈值未上报", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, DurationSec: 0, RecoveryOperator: "lte", RecoveryThreshold: 300, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
|
||||
{ID: "rule-alarm", Name: "协议告警位", Description: "原始协议告警位非零", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, DurationSec: 0, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-geofence", Name: "车辆驶出运营围栏", Description: "车辆从深圳运营区内移动到围栏外", TriggerType: "geofence", FenceName: "深圳运营区", FenceLongitude: 114.057868, FenceLatitude: 22.543099, FenceRadiusM: 5000, Severity: "critical", ValueType: "numeric", Metric: "geofence_distance_m", Operator: "exit", Threshold: 5000, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 3, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-low-soc", Name: "SOC 低于 20%", Description: "动力电池 SOC 持续低于 20%", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "soc_percent", Operator: "lt", Threshold: 20, DurationSec: 300, RecoveryOperator: "gte", RecoveryThreshold: 25, RepeatIntervalSec: 3600, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-24 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-offline", Name: "车辆在线状态处理", Description: "车辆离线时创建事件,恢复上报后自动完成", TriggerType: "offline", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, RecoveryOperator: "lte", RecoveryThreshold: 3600, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
|
||||
{ID: "rule-speeding", Name: "车辆急加速", Description: "速度变化率超过运营阈值", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 10, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-mileage", Name: "里程数据完成日结", Description: "记录车辆每日里程结算完成事件", TriggerType: "metric", Severity: "minor", ValueType: "numeric", Metric: "daily_mileage_km", Operator: "gte", Threshold: 0, RepeatIntervalSec: 86400, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-12 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "rule-alarm", Name: "最高氢浓度超限", Description: "燃料电池系统最高氢浓度超过安全阈值", TriggerType: "metric", Severity: "critical", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
|
||||
}
|
||||
for i := range m.alertRules {
|
||||
m.alertRules[i].ScopeOEMs = []string{}
|
||||
m.alertRules[i].ScopeModels = []string{}
|
||||
m.alertRules[i].ScopeCompanies = []string{}
|
||||
input := alertRuleInputFromRule(m.alertRules[i])
|
||||
if err := normalizeAlertNotificationTargets(&input); err == nil {
|
||||
m.alertRules[i].NotificationChannels = input.NotificationChannels
|
||||
m.alertRules[i].NotificationTargets = input.NotificationTargets
|
||||
}
|
||||
}
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
for _, rule := range m.alertRules {
|
||||
m.alertRuleRevisions[rule.ID] = []AlertRuleRevision{{RuleID: rule.ID, Version: rule.Version, Actor: rule.UpdatedBy, Action: "create", CreatedAt: rule.UpdatedAt, Snapshot: rule}}
|
||||
}
|
||||
locations := []string{"广东省深圳市南山区科技南路", "广东省广州市白云区机场高速", "广东省东莞市南城街道", "广东省佛山市顺德区伦教街道", "上海市临港新片区", "四川省成都市高新区"}
|
||||
statuses := []string{"unprocessed", "unprocessed", "processing", "recovered", "closed", "ignored"}
|
||||
severities := []string{"critical", "critical", "major", "major", "minor", "minor"}
|
||||
severities := []string{"critical", "major", "major", "major", "minor", "critical"}
|
||||
plates := []string{"粤AG18312", "粤B7C526", "粤C9D872", "粤E6P987", "豫A88888", "川AHTWO1"}
|
||||
vins := []string{"LB9A32A24R0LS1426", "LFP23A98V2P012345", "LS5A3A5E8N0123456", "LJ12BA3R1N0456789", "LMRKH9AC2R1004087", "LNXNEGRR7SR318212"}
|
||||
eventSpecs := []struct {
|
||||
ruleID, name, triggerType, protocol, metric, operator, unit string
|
||||
value, threshold float64
|
||||
duration int
|
||||
}{
|
||||
{"rule-geofence", "车辆驶出运营围栏", "geofence", "JT808", "geofence_distance_m", "exit", "m", 5230, 5000, 0},
|
||||
{"rule-low-soc", "SOC 低于 20%", "metric", "GB32960", "soc_percent", "lt", "%", 18, 20, 300},
|
||||
{"rule-speeding", "车辆急加速", "metric", "JT808", "speed_kmh", "gt", "km/h", 92, 80, 10},
|
||||
{"rule-offline", "车辆恢复在线", "offline", "YUTONG_MQTT", "freshness_sec", "gt", "s", 22, 3600, 0},
|
||||
{"rule-mileage", "里程数据完成日结", "metric", "GB32960", "daily_mileage_km", "gte", "km", 186.4, 0, 0},
|
||||
{"rule-alarm", "最高氢浓度超限", "metric", "GB32960", "alarm_active", "eq", "", 1, 1, 0},
|
||||
}
|
||||
for i := range statuses {
|
||||
triggered := now.Add(-time.Duration(8+i*11) * time.Minute)
|
||||
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: "rule-speeding", RuleName: "持续超速告警", RuleVersion: 2, Severity: severities[i], Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: []string{"JT808", "JT808", "JT808", "GB32960", "YUTONG_MQTT", "GB32960"}[i], Metric: "speed_kmh", Operator: "gt", TriggerValue: 96 - float64(i*3), Threshold: 80, Unit: "km/h", DurationSec: 60, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
|
||||
spec := eventSpecs[i]
|
||||
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: spec.ruleID, RuleName: spec.name, RuleVersion: 2, Severity: severities[i], TriggerType: spec.triggerType, Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: spec.protocol, Metric: spec.metric, Operator: spec.operator, TriggerValue: spec.value, Threshold: spec.threshold, Unit: spec.unit, DurationSec: spec.duration, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
|
||||
if statuses[i] == "processing" {
|
||||
event.Handler = "张三"
|
||||
}
|
||||
if statuses[i] == "recovered" || statuses[i] == "closed" || statuses[i] == "ignored" {
|
||||
event.RecoveredAt = triggered.Add(5 * time.Minute).Format(time.RFC3339)
|
||||
}
|
||||
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "alert-evaluator", Note: "规则命中并达到持续时间", CreatedAt: triggered.Format(time.RFC3339)}}
|
||||
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "event-evaluator", Note: "标准事件已匹配自动化", CreatedAt: triggered.Format(time.RFC3339)}}
|
||||
if statuses[i] != "unprocessed" {
|
||||
event.Actions = append(event.Actions, AlertAction{ID: int64(20 + i), Action: statuses[i], FromStatus: "unprocessed", ToStatus: statuses[i], Actor: firstNonEmpty(event.Handler, "platform-admin"), CreatedAt: triggered.Add(time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
@@ -37,9 +68,17 @@ func (m *MockStore) seedAlertCenter() {
|
||||
}
|
||||
m.nextAlertActionID = 100
|
||||
for i := 0; i < 9; i++ {
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 触发告警,请及时处理", Severity: severities[i%len(severities)], Channel: "in_app", Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 的车辆事件已执行通知动作", Severity: severities[i%len(severities)], Channel: "in_app", DeliveryStatus: "delivered", AttemptCount: 1, MaxAttempts: alertNotificationMaxAttempts, Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
|
||||
}
|
||||
m.nextNotificationID = 10
|
||||
m.alertNotifications = append(m.alertNotifications, AlertNotification{
|
||||
ID: 10, EventID: m.alertEvents[1].ID, Title: "SOC 低电量短信通知", Content: "粤B7C526 的 SOC 已低于 20%,短信网关连接超时",
|
||||
Severity: "major", Channel: "sms", Recipient: "夜班负责人", RecipientID: "night-shift", DeliveryStatus: "failed", AttemptCount: 2,
|
||||
VehiclePlate: "粤B7C526", VehicleVIN: m.alertEvents[1].VIN, Protocol: m.alertEvents[1].Protocol,
|
||||
CreatedAt: now.Add(-12 * time.Minute).Format(time.RFC3339), LastAttemptAt: now.Add(-10 * time.Minute).Format(time.RFC3339),
|
||||
LastError: "短信网关连接超时(provider_timeout)", RetryAvailable: true, MaxAttempts: alertNotificationMaxAttempts,
|
||||
})
|
||||
m.nextNotificationID = 11
|
||||
m.nextNotificationRetryAuditID = 0
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSummary, error) {
|
||||
@@ -66,7 +105,7 @@ func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSumm
|
||||
}
|
||||
}
|
||||
for _, item := range m.alertNotifications {
|
||||
if !item.Read {
|
||||
if item.Channel == "in_app" && !item.Read {
|
||||
result.UnreadNotifications++
|
||||
}
|
||||
}
|
||||
@@ -161,7 +200,99 @@ func (m *MockStore) AlertEvent(_ context.Context, id string) (AlertEvent, error)
|
||||
func (m *MockStore) AlertRules(context.Context) ([]AlertRule, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
return append([]AlertRule(nil), m.alertRules...), nil
|
||||
items := make([]AlertRule, 0, len(m.alertRules))
|
||||
for _, rule := range m.alertRules {
|
||||
if rule.ArchivedAt == "" {
|
||||
items = append(items, rule)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertRulePage(_ context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
summary := AlertRuleLibrarySummary{}
|
||||
items := make([]AlertRule, 0, query.Limit)
|
||||
search := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
for _, rule := range m.alertRules {
|
||||
archived := rule.ArchivedAt != ""
|
||||
if archived {
|
||||
summary.Archived++
|
||||
} else {
|
||||
summary.Current++
|
||||
if rule.Enabled {
|
||||
summary.Enabled++
|
||||
} else {
|
||||
summary.Disabled++
|
||||
}
|
||||
}
|
||||
if (query.Lifecycle == "archived") != archived {
|
||||
continue
|
||||
}
|
||||
if query.Status == "enabled" && !rule.Enabled || query.Status == "disabled" && rule.Enabled {
|
||||
continue
|
||||
}
|
||||
if query.Protocol != "" && len(rule.ScopeProtocols) > 0 {
|
||||
matched := false
|
||||
for _, protocol := range rule.ScopeProtocols {
|
||||
if protocol == query.Protocol {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if search != "" {
|
||||
haystack := strings.ToLower(strings.Join([]string{
|
||||
rule.Name, rule.Description, rule.Metric, strings.Join(rule.ScopeVINs, " "),
|
||||
strings.Join(rule.ScopeOEMs, " "), strings.Join(rule.ScopeModels, " "),
|
||||
strings.Join(rule.ScopeCompanies, " "), rule.ArchiveReason,
|
||||
}, " "))
|
||||
if !strings.Contains(haystack, search) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
items = append(items, rule)
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if query.Lifecycle == "archived" {
|
||||
if items[i].ArchivedAt != items[j].ArchivedAt {
|
||||
return items[i].ArchivedAt > items[j].ArchivedAt
|
||||
}
|
||||
} else if items[i].Enabled != items[j].Enabled {
|
||||
return items[i].Enabled
|
||||
}
|
||||
return items[i].ID < items[j].ID
|
||||
})
|
||||
total := len(items)
|
||||
start := min(query.Offset, total)
|
||||
end := min(start+query.Limit, total)
|
||||
return AlertRulePage{Items: append([]AlertRule(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertRuleRevisions(_ context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items, ok := m.alertRuleRevisions[id]
|
||||
if !ok {
|
||||
return nil, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
return append([]AlertRuleRevision(nil), items...), nil
|
||||
}
|
||||
|
||||
func (m *MockStore) recordAlertRuleRevision(rule AlertRule, actor, action string, reasons ...string) {
|
||||
if m.alertRuleRevisions == nil {
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
}
|
||||
reason := ""
|
||||
if len(reasons) > 0 {
|
||||
reason = reasons[0]
|
||||
}
|
||||
revision := AlertRuleRevision{RuleID: rule.ID, Version: rule.Version, Actor: actor, Action: action, Reason: reason, CreatedAt: rule.UpdatedAt, Snapshot: rule}
|
||||
m.alertRuleRevisions[rule.ID] = append([]AlertRuleRevision{revision}, m.alertRuleRevisions[rule.ID]...)
|
||||
}
|
||||
|
||||
func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
@@ -172,6 +303,9 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
if current.ID != input.ID {
|
||||
continue
|
||||
}
|
||||
if current.ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if input.Version != current.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
@@ -182,6 +316,7 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
next.UpdatedAt = now
|
||||
next.UpdatedBy = input.Actor
|
||||
m.alertRules[i] = next
|
||||
m.recordAlertRuleRevision(next, input.Actor, firstNonEmpty(input.AuditAction, "update"))
|
||||
return next, nil
|
||||
}
|
||||
if input.Version != 0 {
|
||||
@@ -194,11 +329,15 @@ func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (Aler
|
||||
next.CreatedBy = input.Actor
|
||||
next.UpdatedBy = input.Actor
|
||||
m.alertRules = append(m.alertRules, next)
|
||||
if m.alertRuleRevisions == nil {
|
||||
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
|
||||
}
|
||||
m.recordAlertRuleRevision(next, input.Actor, "create")
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func ruleFromInput(input AlertRuleInput) AlertRule {
|
||||
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), Enabled: input.Enabled}
|
||||
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, TriggerType: input.TriggerType, FenceName: input.FenceName, FenceLongitude: input.FenceLongitude, FenceLatitude: input.FenceLatitude, FenceRadiusM: input.FenceRadiusM, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), input.NotificationTargets...), Enabled: input.Enabled}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
|
||||
@@ -206,6 +345,9 @@ func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update Ale
|
||||
defer m.alertMu.Unlock()
|
||||
for i := range m.alertRules {
|
||||
if m.alertRules[i].ID == id {
|
||||
if m.alertRules[i].ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if m.alertRules[i].Version != update.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
@@ -213,12 +355,56 @@ func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update Ale
|
||||
m.alertRules[i].Version++
|
||||
m.alertRules[i].UpdatedBy = update.Actor
|
||||
m.alertRules[i].UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
m.recordAlertRuleRevision(m.alertRules[i], update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled])
|
||||
return m.alertRules[i], nil
|
||||
}
|
||||
}
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "规则不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetAlertRuleArchived(_ context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
for i := range m.alertRules {
|
||||
rule := &m.alertRules[i]
|
||||
if rule.ID != id {
|
||||
continue
|
||||
}
|
||||
if rule.Version != request.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
if rule.ArchivedAt != "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
|
||||
}
|
||||
if rule.Enabled {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
|
||||
}
|
||||
rule.ArchivedAt = time.Now().Format(time.RFC3339)
|
||||
rule.ArchivedBy = request.Actor
|
||||
rule.ArchiveReason = request.Reason
|
||||
} else {
|
||||
if rule.ArchivedAt == "" {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
|
||||
}
|
||||
rule.ArchivedAt = ""
|
||||
rule.ArchivedBy = ""
|
||||
rule.ArchiveReason = ""
|
||||
rule.Enabled = false
|
||||
}
|
||||
rule.Version++
|
||||
rule.UpdatedBy = request.Actor
|
||||
rule.UpdatedAt = time.Now().Format(time.RFC3339)
|
||||
action := "restore"
|
||||
if archived {
|
||||
action = "archive"
|
||||
}
|
||||
m.recordAlertRuleRevision(*rule, request.Actor, action, request.Reason)
|
||||
return *rule, nil
|
||||
}
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) ActOnAlert(_ context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
@@ -265,10 +451,46 @@ func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificatio
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items := make([]AlertNotification, 0, len(m.alertNotifications))
|
||||
search := strings.ToLower(query.Search)
|
||||
for _, item := range m.alertNotifications {
|
||||
if !query.UnreadOnly || !item.Read {
|
||||
items = append(items, item)
|
||||
if len(query.AllowedVINs) > 0 {
|
||||
allowed := false
|
||||
for _, vin := range query.AllowedVINs {
|
||||
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(item.VehicleVIN)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if query.UnreadOnly && (item.Channel != "in_app" || item.Read) {
|
||||
continue
|
||||
}
|
||||
deliveryStatus := strings.ToLower(strings.TrimSpace(item.DeliveryStatus))
|
||||
if deliveryStatus == "sent" {
|
||||
deliveryStatus = "delivered"
|
||||
}
|
||||
if deliveryStatus == "created" || deliveryStatus == "reserved" {
|
||||
deliveryStatus = "queued"
|
||||
}
|
||||
if deliveryStatus == "" {
|
||||
deliveryStatus = "delivered"
|
||||
}
|
||||
if query.DeliveryStatus != "" && deliveryStatus != query.DeliveryStatus {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(strings.ToLower(strings.Join([]string{item.Title, item.Content, item.EventID, item.VehiclePlate, item.VehicleVIN, item.Recipient, item.Protocol}, "\n")), search) {
|
||||
continue
|
||||
}
|
||||
item.DeliveryStatus = deliveryStatus
|
||||
if item.AttemptCount <= 0 {
|
||||
item.AttemptCount = 1
|
||||
}
|
||||
item.MaxAttempts = alertNotificationMaxAttempts
|
||||
item.RetryAvailable = deliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
|
||||
items = append(items, item)
|
||||
}
|
||||
total := len(items)
|
||||
start := query.Offset
|
||||
@@ -282,6 +504,48 @@ func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificatio
|
||||
return Page[AlertNotification]{Items: append([]AlertNotification(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertNotificationDeliveryHealth(_ context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
byChannel := map[string]*AlertNotificationChannelHealth{}
|
||||
for _, notification := range m.alertNotifications {
|
||||
if notification.Channel == "in_app" {
|
||||
continue
|
||||
}
|
||||
channel := byChannel[notification.Channel]
|
||||
if channel == nil {
|
||||
channel = &AlertNotificationChannelHealth{Channel: notification.Channel}
|
||||
byChannel[notification.Channel] = channel
|
||||
}
|
||||
status := strings.ToLower(strings.TrimSpace(notification.DeliveryStatus))
|
||||
if status == "created" || status == "reserved" || status == "queued" {
|
||||
channel.Queued++
|
||||
health.Queued++
|
||||
if channel.OldestQueuedAt == "" || notification.CreatedAt < channel.OldestQueuedAt {
|
||||
channel.OldestQueuedAt = notification.CreatedAt
|
||||
}
|
||||
if health.OldestQueuedAt == "" || notification.CreatedAt < health.OldestQueuedAt {
|
||||
health.OldestQueuedAt = notification.CreatedAt
|
||||
}
|
||||
}
|
||||
if status == "failed" {
|
||||
channel.Failed++
|
||||
health.Failed++
|
||||
if notification.AttemptCount >= alertNotificationMaxAttempts {
|
||||
channel.DeadLetter++
|
||||
health.DeadLetter++
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"sms", "email", "wecom"} {
|
||||
if channel := byChannel[key]; channel != nil {
|
||||
health.Channels = append(health.Channels, *channel)
|
||||
}
|
||||
}
|
||||
return health, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertNotificationReadRequest) (int, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
@@ -292,7 +556,19 @@ func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertN
|
||||
count := 0
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
for i := range m.alertNotifications {
|
||||
if ids[m.alertNotifications[i].ID] && !m.alertNotifications[i].Read {
|
||||
if ids[m.alertNotifications[i].ID] && m.alertNotifications[i].Channel == "in_app" && !m.alertNotifications[i].Read {
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
allowed := false
|
||||
for _, vin := range request.AllowedVINs {
|
||||
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(m.alertNotifications[i].VehicleVIN)) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
continue
|
||||
}
|
||||
}
|
||||
m.alertNotifications[i].Read = true
|
||||
m.alertNotifications[i].ReadAt = now
|
||||
count++
|
||||
@@ -301,6 +577,85 @@ func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertN
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) RetryAlertNotification(_ context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
m.alertMu.Lock()
|
||||
defer m.alertMu.Unlock()
|
||||
for _, audit := range m.alertNotificationRetryAudits {
|
||||
if audit.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if audit.IdempotencyKey == request.IdempotencyKey {
|
||||
if audit.NotificationID != id {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
|
||||
}
|
||||
for _, notification := range m.alertNotifications {
|
||||
if notification.ID == id {
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: audit, Idempotent: true}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range m.alertNotifications {
|
||||
notification := &m.alertNotifications[index]
|
||||
if notification.ID != id {
|
||||
continue
|
||||
}
|
||||
attemptCount := notification.AttemptCount
|
||||
if attemptCount <= 0 {
|
||||
attemptCount = 1
|
||||
}
|
||||
if notification.DeliveryStatus != "failed" {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
|
||||
}
|
||||
if attemptCount != request.ExpectedAttemptCount {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
|
||||
}
|
||||
if attemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
|
||||
}
|
||||
nextStatus := "queued"
|
||||
auditNextStatus := "reserved"
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if notification.Channel == "in_app" {
|
||||
nextStatus = "delivered"
|
||||
auditNextStatus = "sent"
|
||||
notification.DeliveredAt = now
|
||||
notification.ProviderMessageID = "in_app:" + strconv.FormatInt(notification.ID, 10) + ":retry:" + strconv.Itoa(attemptCount+1)
|
||||
} else {
|
||||
notification.ProviderMessageID = ""
|
||||
notification.DeliveredAt = ""
|
||||
}
|
||||
notification.DeliveryStatus = nextStatus
|
||||
notification.AttemptCount = attemptCount + 1
|
||||
notification.LastAttemptAt = now
|
||||
notification.RetryRequestedBy = request.Actor
|
||||
notification.RetryRequestedAt = now
|
||||
notification.RetryAvailable = false
|
||||
notification.MaxAttempts = alertNotificationMaxAttempts
|
||||
m.nextNotificationRetryAuditID++
|
||||
audit := AlertNotificationRetryAudit{
|
||||
ID: m.nextNotificationRetryAuditID, NotificationID: id, IdempotencyKey: request.IdempotencyKey, Actor: request.Actor,
|
||||
Reason: request.Reason, PreviousStatus: "failed", NextStatus: auditNextStatus,
|
||||
AttemptCount: notification.AttemptCount, RequestedAt: now,
|
||||
}
|
||||
m.alertNotificationRetryAudits = append([]AlertNotificationRetryAudit{audit}, m.alertNotificationRetryAudits...)
|
||||
return AlertNotificationRetryResult{Notification: *notification, Receipt: audit}, nil
|
||||
}
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) AlertNotificationRetryAudits(_ context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
m.alertMu.RLock()
|
||||
defer m.alertMu.RUnlock()
|
||||
items := make([]AlertNotificationRetryAudit, 0)
|
||||
for _, audit := range m.alertNotificationRetryAudits {
|
||||
if audit.NotificationID == id {
|
||||
items = append(items, audit)
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) EvaluateAlerts(context.Context) (AlertEvaluationResult, error) {
|
||||
return AlertEvaluationResult{RulesEvaluated: len(m.alertRules), VehiclesScanned: len(m.vehicles), AsOf: time.Now().Format(time.RFC3339)}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AlertNotificationDispatchItem struct {
|
||||
ID int64 `json:"notificationId"`
|
||||
EventID string `json:"eventId"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Severity string `json:"severity"`
|
||||
Channel string `json:"channel"`
|
||||
RecipientID string `json:"recipientId"`
|
||||
Recipient string `json:"recipient"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
VehiclePlate string `json:"vehiclePlate"`
|
||||
VehicleVIN string `json:"vehicleVin"`
|
||||
Protocol string `json:"protocol"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
LeaseToken string `json:"-"`
|
||||
}
|
||||
|
||||
type AlertNotificationDispatchReceipt struct {
|
||||
MessageID string
|
||||
}
|
||||
|
||||
type AlertNotificationDispatchBatchResult struct {
|
||||
Claimed int
|
||||
Sent int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type AlertNotificationGateway interface {
|
||||
Send(context.Context, AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error)
|
||||
}
|
||||
|
||||
type alertNotificationDispatchStore interface {
|
||||
ClaimAlertNotifications(context.Context, string, []string, int, time.Duration) ([]AlertNotificationDispatchItem, error)
|
||||
CompleteAlertNotificationDispatch(context.Context, AlertNotificationDispatchItem, AlertNotificationDispatchReceipt, error) error
|
||||
}
|
||||
|
||||
type AlertNotificationDispatcher struct {
|
||||
store alertNotificationDispatchStore
|
||||
gateways map[string]AlertNotificationGateway
|
||||
channels []string
|
||||
batchSize int
|
||||
lease time.Duration
|
||||
workerID string
|
||||
}
|
||||
|
||||
func NewAlertNotificationDispatcher(store alertNotificationDispatchStore, gateways map[string]AlertNotificationGateway, batchSize int, lease time.Duration, workerID string) *AlertNotificationDispatcher {
|
||||
normalized := map[string]AlertNotificationGateway{}
|
||||
channels := make([]string, 0, len(gateways))
|
||||
for _, channel := range []string{"sms", "email", "wecom"} {
|
||||
if gateway := gateways[channel]; gateway != nil {
|
||||
normalized[channel] = gateway
|
||||
channels = append(channels, channel)
|
||||
}
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 20
|
||||
}
|
||||
if batchSize > 200 {
|
||||
batchSize = 200
|
||||
}
|
||||
if lease < 5*time.Second {
|
||||
lease = 30 * time.Second
|
||||
}
|
||||
if lease > 5*time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
workerID = "notification-dispatcher"
|
||||
}
|
||||
return &AlertNotificationDispatcher{store: store, gateways: normalized, channels: channels, batchSize: batchSize, lease: lease, workerID: workerID}
|
||||
}
|
||||
|
||||
func (d *AlertNotificationDispatcher) RunOnce(ctx context.Context) (AlertNotificationDispatchBatchResult, error) {
|
||||
if len(d.channels) == 0 {
|
||||
return AlertNotificationDispatchBatchResult{}, nil
|
||||
}
|
||||
token, err := alertNotificationLeaseToken(d.workerID)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchBatchResult{}, err
|
||||
}
|
||||
items, err := d.store.ClaimAlertNotifications(ctx, token, d.channels, d.batchSize, d.lease)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchBatchResult{}, err
|
||||
}
|
||||
result := AlertNotificationDispatchBatchResult{Claimed: len(items)}
|
||||
var completionErrors []string
|
||||
for _, item := range items {
|
||||
receipt, sendErr := d.gateways[item.Channel].Send(ctx, item)
|
||||
if sendErr == nil {
|
||||
result.Sent++
|
||||
} else {
|
||||
result.Failed++
|
||||
}
|
||||
if completeErr := d.store.CompleteAlertNotificationDispatch(ctx, item, receipt, sendErr); completeErr != nil {
|
||||
completionErrors = append(completionErrors, completeErr.Error())
|
||||
}
|
||||
}
|
||||
if len(completionErrors) > 0 {
|
||||
return result, fmt.Errorf("complete notification dispatch: %s", strings.Join(completionErrors, "; "))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func alertNotificationLeaseToken(workerID string) (string, error) {
|
||||
buf := make([]byte, 12)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := strings.Map(func(char rune) rune {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '-' || char == '_' || char == '.' || char == ':' {
|
||||
return char
|
||||
}
|
||||
return '-'
|
||||
}, workerID) + ":" + hex.EncodeToString(buf)
|
||||
if len(token) > 96 {
|
||||
token = token[len(token)-96:]
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ClaimAlertNotifications(ctx context.Context, leaseToken string, channels []string, limit int, lease time.Duration) ([]AlertNotificationDispatchItem, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !alertNotificationTargetIDValid(leaseToken) {
|
||||
return nil, fmt.Errorf("invalid notification lease token")
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
return nil, fmt.Errorf("notification dispatch batch size must be between 1 and 200")
|
||||
}
|
||||
allowed := map[string]bool{"sms": true, "email": true, "wecom": true}
|
||||
cleanChannels := make([]string, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
channel = strings.ToLower(strings.TrimSpace(channel))
|
||||
if allowed[channel] && !containsString(cleanChannels, channel) {
|
||||
cleanChannels = append(cleanChannels, channel)
|
||||
}
|
||||
}
|
||||
if len(cleanChannels) == 0 {
|
||||
return []AlertNotificationDispatchItem{}, nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
placeholders := make([]string, len(cleanChannels))
|
||||
args := make([]any, 0, len(cleanChannels)+1)
|
||||
for index, channel := range cleanChannels {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, channel)
|
||||
}
|
||||
args = append(args, limit)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,n.recipient,n.recipient_ref,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,''),DATE_FORMAT(n.created_at,'`+alertSQLTimestampFormat+`')
|
||||
FROM vehicle_alert_notification n
|
||||
LEFT JOIN vehicle_alert_event e ON e.id=n.event_id
|
||||
WHERE n.delivery_status='reserved'
|
||||
AND n.channel IN (`+strings.Join(placeholders, ",")+`)
|
||||
AND n.recipient_ref<>''
|
||||
AND (n.lease_expires_at IS NULL OR n.lease_expires_at<=NOW(3))
|
||||
ORDER BY n.created_at,n.id
|
||||
LIMIT ? FOR UPDATE SKIP LOCKED`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]AlertNotificationDispatchItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var item AlertNotificationDispatchItem
|
||||
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient, &item.RecipientID, &item.AttemptCount, &item.VehiclePlate, &item.VehicleVIN, &item.Protocol, &item.CreatedAt); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
item.LeaseToken = leaseToken
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaseSeconds := int(lease.Round(time.Second) / time.Second)
|
||||
if leaseSeconds < 5 {
|
||||
leaseSeconds = 30
|
||||
}
|
||||
for _, item := range items {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET lease_token=?,lease_expires_at=DATE_ADD(NOW(3),INTERVAL ? SECOND),last_attempt_at=NOW(3) WHERE id=? AND delivery_status='reserved'`, leaseToken, leaseSeconds, item.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) CompleteAlertNotificationDispatch(ctx context.Context, item AlertNotificationDispatchItem, receipt AlertNotificationDispatchReceipt, sendErr error) error {
|
||||
if item.ID <= 0 || item.LeaseToken == "" {
|
||||
return fmt.Errorf("notification dispatch receipt is missing its lease")
|
||||
}
|
||||
var result sql.Result
|
||||
var err error
|
||||
if sendErr == nil {
|
||||
messageID := strings.TrimSpace(receipt.MessageID)
|
||||
if len(messageID) > 160 {
|
||||
messageID = messageID[:160]
|
||||
}
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',provider_message_id=?,delivered_at=NOW(3),last_error='',lease_token='',lease_expires_at=NULL WHERE id=? AND delivery_status='reserved' AND lease_token=?`, messageID, item.ID, item.LeaseToken)
|
||||
} else {
|
||||
lastError := sanitizeAlertNotificationDispatchError(sendErr)
|
||||
result, err = s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='failed',last_error=?,delivered_at=NULL,lease_token='',lease_expires_at=NULL WHERE id=? AND delivery_status='reserved' AND lease_token=?`, lastError, item.ID, item.LeaseToken)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if updated != 1 {
|
||||
return fmt.Errorf("notification %d lease expired or was replaced", item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeAlertNotificationDispatchError(err error) string {
|
||||
value := strings.Join(strings.Fields(err.Error()), " ")
|
||||
if value == "" {
|
||||
value = "notification gateway failed without details"
|
||||
}
|
||||
if len([]rune(value)) > 500 {
|
||||
value = string([]rune(value)[:500])
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type HTTPAlertNotificationGateway struct {
|
||||
endpoint string
|
||||
secret []byte
|
||||
client *http.Client
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHTTPAlertNotificationGateway(endpoint, secret string, timeout time.Duration) (*HTTPAlertNotificationGateway, error) {
|
||||
endpoint = strings.TrimSpace(endpoint)
|
||||
secret = strings.TrimSpace(secret)
|
||||
if endpoint == "" || secret == "" {
|
||||
return nil, fmt.Errorf("notification gateway endpoint and signing secret are required")
|
||||
}
|
||||
if timeout < time.Second {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
if timeout > 30*time.Second {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &HTTPAlertNotificationGateway{endpoint: endpoint, secret: []byte(secret), client: &http.Client{Timeout: timeout}, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (g *HTTPAlertNotificationGateway) Send(ctx context.Context, item AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error) {
|
||||
body, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, err
|
||||
}
|
||||
timestamp := strconv.FormatInt(g.now().UnixMilli(), 10)
|
||||
mac := hmac.New(sha256.New, g.secret)
|
||||
_, _ = mac.Write([]byte(timestamp))
|
||||
_, _ = mac.Write([]byte("\n"))
|
||||
_, _ = mac.Write(body)
|
||||
signature := hex.EncodeToString(mac.Sum(nil))
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Lingniu-Timestamp", timestamp)
|
||||
request.Header.Set("X-Lingniu-Signature", "sha256="+signature)
|
||||
request.Header.Set("X-Lingniu-Notification-ID", strconv.FormatInt(item.ID, 10))
|
||||
request.Header.Set("X-Lingniu-Idempotency-Key", fmt.Sprintf("notification:%d:attempt:%d", item.ID, item.AttemptCount))
|
||||
response, err := g.client.Do(request)
|
||||
if err != nil {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway request failed: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
if readErr != nil {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway response read failed: %w", readErr)
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
detail := strings.TrimSpace(string(responseBody))
|
||||
if len(detail) > 200 {
|
||||
detail = detail[:200]
|
||||
}
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway returned HTTP %d%s", response.StatusCode, map[bool]string{true: ": " + detail, false: ""}[detail != ""])
|
||||
}
|
||||
var payload struct {
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &payload)
|
||||
if payload.MessageID == "" {
|
||||
payload.MessageID = response.Header.Get("X-Provider-Message-ID")
|
||||
}
|
||||
payload.MessageID = strings.TrimSpace(payload.MessageID)
|
||||
if payload.MessageID == "" {
|
||||
return AlertNotificationDispatchReceipt{}, fmt.Errorf("notification gateway accepted the request without a message id")
|
||||
}
|
||||
return AlertNotificationDispatchReceipt{MessageID: payload.MessageID}, nil
|
||||
}
|
||||
|
||||
var _ alertNotificationDispatchStore = (*ProductionStore)(nil)
|
||||
@@ -0,0 +1,133 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type alertNotificationDispatchStoreStub struct {
|
||||
items []AlertNotificationDispatchItem
|
||||
claimedWith []string
|
||||
completed []AlertNotificationDispatchItem
|
||||
sendErrors []error
|
||||
}
|
||||
|
||||
func (s *alertNotificationDispatchStoreStub) ClaimAlertNotifications(_ context.Context, token string, channels []string, _ int, _ time.Duration) ([]AlertNotificationDispatchItem, error) {
|
||||
s.claimedWith = append([]string(nil), channels...)
|
||||
items := make([]AlertNotificationDispatchItem, len(s.items))
|
||||
copy(items, s.items)
|
||||
for index := range items {
|
||||
items[index].LeaseToken = token
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *alertNotificationDispatchStoreStub) CompleteAlertNotificationDispatch(_ context.Context, item AlertNotificationDispatchItem, _ AlertNotificationDispatchReceipt, sendErr error) error {
|
||||
s.completed = append(s.completed, item)
|
||||
s.sendErrors = append(s.sendErrors, sendErr)
|
||||
return nil
|
||||
}
|
||||
|
||||
type alertNotificationGatewayStub struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (g alertNotificationGatewayStub) Send(_ context.Context, item AlertNotificationDispatchItem) (AlertNotificationDispatchReceipt, error) {
|
||||
return AlertNotificationDispatchReceipt{MessageID: "provider-" + item.EventID}, g.err
|
||||
}
|
||||
|
||||
func TestAlertNotificationDispatcherUsesLeasesAndPersistsRealOutcomes(t *testing.T) {
|
||||
store := &alertNotificationDispatchStoreStub{items: []AlertNotificationDispatchItem{
|
||||
{ID: 11, EventID: "event-11", Channel: "sms", RecipientID: "night-shift", AttemptCount: 1},
|
||||
{ID: 12, EventID: "event-12", Channel: "email", RecipientID: "data-platform", AttemptCount: 2},
|
||||
}}
|
||||
dispatcher := NewAlertNotificationDispatcher(store, map[string]AlertNotificationGateway{
|
||||
"sms": alertNotificationGatewayStub{},
|
||||
"email": alertNotificationGatewayStub{err: errors.New("provider timeout")},
|
||||
}, 20, 30*time.Second, "worker-a")
|
||||
|
||||
result, err := dispatcher.RunOnce(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Claimed != 2 || result.Sent != 1 || result.Failed != 1 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if len(store.completed) != 2 || store.completed[0].LeaseToken == "" || store.completed[1].LeaseToken == "" {
|
||||
t.Fatalf("claimed items must retain their leases: %+v", store.completed)
|
||||
}
|
||||
if store.sendErrors[0] != nil || store.sendErrors[1] == nil {
|
||||
t.Fatalf("real gateway outcomes were not preserved: %+v", store.sendErrors)
|
||||
}
|
||||
if len(store.claimedWith) != 2 || store.claimedWith[0] != "sms" || store.claimedWith[1] != "email" {
|
||||
t.Fatalf("unexpected claimed channels: %+v", store.claimedWith)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAlertNotificationGatewaySignsIdempotentPayload(t *testing.T) {
|
||||
const secret = "notification-secret"
|
||||
now := time.Date(2026, 7, 23, 8, 30, 0, 0, time.UTC)
|
||||
var received AlertNotificationDispatchItem
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(request.Header.Get("X-Lingniu-Timestamp") + "\n"))
|
||||
_, _ = mac.Write(body)
|
||||
if request.Header.Get("X-Lingniu-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {
|
||||
t.Errorf("invalid signature: %s", request.Header.Get("X-Lingniu-Signature"))
|
||||
}
|
||||
if request.Header.Get("X-Lingniu-Idempotency-Key") != "notification:42:attempt:2" {
|
||||
t.Errorf("invalid idempotency key: %s", request.Header.Get("X-Lingniu-Idempotency-Key"))
|
||||
}
|
||||
if err := json.Unmarshal(body, &received); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
_, _ = writer.Write([]byte(`{"messageId":"sms-provider-42"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
gateway, err := NewHTTPAlertNotificationGateway(server.URL, secret, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gateway.now = func() time.Time { return now }
|
||||
receipt, err := gateway.Send(t.Context(), AlertNotificationDispatchItem{
|
||||
ID: 42, EventID: "event-42", Title: "低电量", Content: "SOC 低于 20%", Channel: "sms",
|
||||
RecipientID: "night-shift", Recipient: "夜班负责人", AttemptCount: 2, LeaseToken: "must-not-leak",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt.MessageID != "sms-provider-42" || received.RecipientID != "night-shift" || received.LeaseToken != "" {
|
||||
t.Fatalf("unexpected gateway receipt or payload: receipt=%+v payload=%+v", receipt, received)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPAlertNotificationGatewayRejectsAmbiguousSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer server.Close()
|
||||
gateway, err := NewHTTPAlertNotificationGateway(server.URL, "secret", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = gateway.Send(t.Context(), AlertNotificationDispatchItem{ID: 7, Channel: "sms", RecipientID: "night-shift", AttemptCount: 1})
|
||||
if err == nil {
|
||||
t.Fatal("2xx without provider message id must not be marked sent")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -106,16 +107,20 @@ func buildAlertWhere(query AlertQuery) (string, []any) {
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.status,e.vin,e.plate,e.protocol,
|
||||
const alertSQLTimestampFormat = `%Y-%m-%dT%H:%i:%s.%f+08:00`
|
||||
|
||||
const alertEventSelect = `SELECT e.id,e.rule_id,e.rule_name,e.rule_version,e.severity,e.trigger_type,e.status,e.vin,e.plate,e.protocol,
|
||||
e.metric,e.operator,e.trigger_value,e.threshold_value,e.threshold_high,e.unit,e.duration_sec,e.location_text,e.longitude,e.latitude,
|
||||
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),COALESCE(DATE_FORMAT(e.received_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),
|
||||
DATE_FORMAT(e.triggered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(e.recovered_at,'%Y-%m-%dT%H:%i:%s.%fZ'),''),e.handler,e.version
|
||||
e.source_event_id,COALESCE(DATE_FORMAT(e.event_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(e.received_at,'` + alertSQLTimestampFormat + `'),''),
|
||||
DATE_FORMAT(e.triggered_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(e.recovered_at,'` + alertSQLTimestampFormat + `'),''),e.handler,e.version
|
||||
FROM vehicle_alert_event e `
|
||||
|
||||
const alertActionSelect = `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`
|
||||
|
||||
func scanAlertEvent(scanner interface{ Scan(...any) error }) (AlertEvent, error) {
|
||||
var event AlertEvent
|
||||
var longitude, latitude sql.NullFloat64
|
||||
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
|
||||
err := scanner.Scan(&event.ID, &event.RuleID, &event.RuleName, &event.RuleVersion, &event.Severity, &event.TriggerType, &event.Status, &event.VIN, &event.Plate, &event.Protocol,
|
||||
&event.Metric, &event.Operator, &event.TriggerValue, &event.Threshold, &event.ThresholdHigh, &event.Unit, &event.DurationSec, &event.Location, &longitude, &latitude,
|
||||
&event.SourceEventID, &event.EventAt, &event.ReceivedAt, &event.TriggeredAt, &event.RecoveredAt, &event.Handler, &event.Version)
|
||||
if longitude.Valid {
|
||||
@@ -164,7 +169,7 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,action,from_status,to_status,actor,note,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_event_action WHERE event_id=? ORDER BY created_at,id`, id)
|
||||
rows, err := s.db.QueryContext(ctx, alertActionSelect, id)
|
||||
if err != nil {
|
||||
return AlertEvent{}, err
|
||||
}
|
||||
@@ -179,15 +184,73 @@ func (s *ProductionStore) AlertEvent(ctx context.Context, id string) (AlertEvent
|
||||
return event, rows.Err()
|
||||
}
|
||||
|
||||
const alertRuleSelect = `SELECT id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%fZ') FROM vehicle_alert_rule `
|
||||
const alertRuleSelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_rule `
|
||||
|
||||
const alertRuleLibrarySelect = `SELECT id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,
|
||||
recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,COALESCE(scope_oems_json,'[]'),COALESCE(scope_models_json,'[]'),COALESCE(scope_companies_json,'[]'),notification_channels_json,COALESCE(notification_targets_json,'[]'),enabled,version,
|
||||
created_by,updated_by,DATE_FORMAT(created_at,'` + alertSQLTimestampFormat + `'),DATE_FORMAT(updated_at,'` + alertSQLTimestampFormat + `'),COALESCE(archived_by,''),COALESCE(DATE_FORMAT(archived_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(archive_reason,'') FROM vehicle_alert_rule `
|
||||
|
||||
const alertNotificationSelect = `SELECT n.id,n.event_id,n.title,n.content,n.severity,n.channel,COALESCE(n.recipient,''),COALESCE(n.recipient_ref,''),n.delivery_status,GREATEST(COALESCE(n.attempt_count,1),1),COALESCE(n.provider_message_id,''),n.is_read,DATE_FORMAT(n.created_at,'` + alertSQLTimestampFormat + `'),COALESCE(DATE_FORMAT(n.delivered_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(DATE_FORMAT(n.read_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.last_error,''),COALESCE(DATE_FORMAT(n.last_attempt_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(n.retry_requested_by,''),COALESCE(DATE_FORMAT(n.retry_requested_at,'` + alertSQLTimestampFormat + `'),''),COALESCE(e.plate,''),COALESCE(e.vin,''),COALESCE(e.protocol,'') FROM vehicle_alert_notification n LEFT JOIN vehicle_alert_event e ON e.id=n.event_id WHERE `
|
||||
|
||||
const alertNotificationRetryAuditSelect = `SELECT id,notification_id,actor,reason,previous_status,next_status,attempt_count,DATE_FORMAT(requested_at,'` + alertSQLTimestampFormat + `') FROM vehicle_alert_notification_retry_audit WHERE `
|
||||
|
||||
const alertNotificationMaxAttempts = 3
|
||||
|
||||
type alertRowScanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanAlertNotification(scanner alertRowScanner) (AlertNotification, error) {
|
||||
var item AlertNotification
|
||||
var deliveryStatus string
|
||||
err := scanner.Scan(
|
||||
&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Recipient,
|
||||
&item.RecipientID, &deliveryStatus, &item.AttemptCount, &item.ProviderMessageID, &item.Read, &item.CreatedAt, &item.DeliveredAt, &item.ReadAt,
|
||||
&item.LastError, &item.LastAttemptAt, &item.RetryRequestedBy, &item.RetryRequestedAt,
|
||||
&item.VehiclePlate, &item.VehicleVIN, &item.Protocol,
|
||||
)
|
||||
if err != nil {
|
||||
return AlertNotification{}, err
|
||||
}
|
||||
switch deliveryStatus {
|
||||
case "failed":
|
||||
item.DeliveryStatus = "failed"
|
||||
case "created", "reserved":
|
||||
item.DeliveryStatus = "queued"
|
||||
default:
|
||||
item.DeliveryStatus = "delivered"
|
||||
}
|
||||
if item.Recipient == "" {
|
||||
if item.Channel == "in_app" {
|
||||
item.Recipient = "运营值班组"
|
||||
} else {
|
||||
item.Recipient = "规则通知目标"
|
||||
}
|
||||
}
|
||||
if item.ProviderMessageID == "" && item.Channel == "in_app" {
|
||||
item.ProviderMessageID = fmt.Sprintf("in_app:%d", item.ID)
|
||||
}
|
||||
if item.DeliveryStatus == "delivered" && item.DeliveredAt == "" {
|
||||
item.DeliveredAt = item.CreatedAt
|
||||
}
|
||||
item.MaxAttempts = alertNotificationMaxAttempts
|
||||
item.RetryAvailable = item.DeliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanAlertNotificationRetryAudit(scanner alertRowScanner) (AlertNotificationRetryAudit, error) {
|
||||
var item AlertNotificationRetryAudit
|
||||
err := scanner.Scan(&item.ID, &item.NotificationID, &item.Actor, &item.Reason, &item.PreviousStatus, &item.NextStatus, &item.AttemptCount, &item.RequestedAt)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
var rule AlertRule
|
||||
var boolean sql.NullBool
|
||||
var protocols, vins, oems, models, companies, channels string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
var protocols, vins, oems, models, companies, channels, targets string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt)
|
||||
if boolean.Valid {
|
||||
rule.BooleanThreshold = &boolean.Bool
|
||||
}
|
||||
@@ -210,15 +273,58 @@ func scanAlertRule(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
if e := json.Unmarshal([]byte(channels), &rule.NotificationChannels); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
if e := json.Unmarshal([]byte(targets), &rule.NotificationTargets); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
input := alertRuleInputFromRule(rule)
|
||||
if e := normalizeAlertNotificationTargets(&input); e != nil {
|
||||
return AlertRule{}, e
|
||||
}
|
||||
rule.NotificationChannels = input.NotificationChannels
|
||||
rule.NotificationTargets = input.NotificationTargets
|
||||
}
|
||||
return rule, err
|
||||
}
|
||||
|
||||
func scanAlertRuleLibrary(scanner interface{ Scan(...any) error }) (AlertRule, error) {
|
||||
var rule AlertRule
|
||||
var boolean sql.NullBool
|
||||
var protocols, vins, oems, models, companies, channels, targets string
|
||||
err := scanner.Scan(&rule.ID, &rule.Name, &rule.Description, &rule.TriggerType, &rule.FenceName, &rule.FenceLongitude, &rule.FenceLatitude, &rule.FenceRadiusM, &rule.Severity, &rule.ValueType, &rule.Metric, &rule.Operator, &rule.Threshold, &rule.ThresholdHigh, &boolean, &rule.DurationSec, &rule.RecoveryOperator, &rule.RecoveryThreshold, &rule.RepeatIntervalSec, &protocols, &vins, &oems, &models, &companies, &channels, &targets, &rule.Enabled, &rule.Version, &rule.CreatedBy, &rule.UpdatedBy, &rule.CreatedAt, &rule.UpdatedAt, &rule.ArchivedBy, &rule.ArchivedAt, &rule.ArchiveReason)
|
||||
if boolean.Valid {
|
||||
rule.BooleanThreshold = &boolean.Bool
|
||||
}
|
||||
if err != nil {
|
||||
return rule, err
|
||||
}
|
||||
for _, item := range []struct {
|
||||
raw string
|
||||
target *[]string
|
||||
}{
|
||||
{protocols, &rule.ScopeProtocols}, {vins, &rule.ScopeVINs}, {oems, &rule.ScopeOEMs},
|
||||
{models, &rule.ScopeModels}, {companies, &rule.ScopeCompanies}, {channels, &rule.NotificationChannels},
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(item.raw), item.target); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal([]byte(targets), &rule.NotificationTargets); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
input := alertRuleInputFromRule(rule)
|
||||
if err := normalizeAlertNotificationTargets(&input); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
rule.NotificationChannels = input.NotificationChannels
|
||||
rule.NotificationTargets = input.NotificationTargets
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`ORDER BY enabled DESC,severity,name`)
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleSelect+`WHERE archived_at IS NULL ORDER BY enabled DESC,severity,name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -234,39 +340,138 @@ func (s *ProductionStore) AlertRules(ctx context.Context) ([]AlertRule, error) {
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, error) {
|
||||
func buildAlertRuleWhere(query AlertRuleQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.Lifecycle == "archived" {
|
||||
where = append(where, "archived_at IS NOT NULL")
|
||||
} else {
|
||||
where = append(where, "archived_at IS NULL")
|
||||
}
|
||||
if query.Status == "enabled" {
|
||||
where = append(where, "enabled=1")
|
||||
} else if query.Status == "disabled" {
|
||||
where = append(where, "enabled=0")
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
where = append(where, "(JSON_LENGTH(scope_protocols_json)=0 OR JSON_CONTAINS(scope_protocols_json,?))")
|
||||
encoded, _ := json.Marshal(query.Protocol)
|
||||
args = append(args, string(encoded))
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
like := "%" + query.Keyword + "%"
|
||||
where = append(where, "(name LIKE ? OR description LIKE ? OR metric LIKE ? OR scope_vins_json LIKE ? OR scope_oems_json LIKE ? OR scope_models_json LIKE ? OR scope_companies_json LIKE ? OR archive_reason LIKE ?)")
|
||||
for range 8 {
|
||||
args = append(args, like)
|
||||
}
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
var summary AlertRuleLibrarySummary
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=1 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NULL AND enabled=0 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END),0)
|
||||
FROM vehicle_alert_rule`).Scan(&summary.Current, &summary.Enabled, &summary.Disabled, &summary.Archived); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
where, args := buildAlertRuleWhere(query)
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_rule WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
order := `enabled DESC,severity,updated_at DESC,id`
|
||||
if query.Lifecycle == "archived" {
|
||||
order = `archived_at DESC,id`
|
||||
}
|
||||
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, alertRuleLibrarySelect+`WHERE `+where+` ORDER BY `+order+` LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AlertRule, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
item, err := scanAlertRuleLibrary(rows)
|
||||
if err != nil {
|
||||
return AlertRulePage{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return AlertRulePage{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT rule_id,rule_version,actor,action,COALESCE(reason,''),snapshot_json,DATE_FORMAT(created_at,'`+alertSQLTimestampFormat+`') FROM vehicle_alert_rule_audit WHERE rule_id=? ORDER BY rule_version DESC,id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AlertRuleRevision{}
|
||||
for rows.Next() {
|
||||
var item AlertRuleRevision
|
||||
var snapshot string
|
||||
if err := rows.Scan(&item.RuleID, &item.Version, &item.Actor, &item.Action, &item.Reason, &snapshot, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(snapshot), &item.Snapshot); err != nil {
|
||||
return nil, fmt.Errorf("parse alert rule revision %d: %w", item.Version, err)
|
||||
}
|
||||
item.Snapshot.ID = item.RuleID
|
||||
item.Snapshot.Version = item.Version
|
||||
item.Snapshot.UpdatedBy = item.Actor
|
||||
item.Snapshot.UpdatedAt = item.CreatedAt
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func marshalAlertRuleLists(input AlertRuleInput) (string, string, string, string, string, string, string, error) {
|
||||
p, e := json.Marshal(input.ScopeProtocols)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
v, e := json.Marshal(input.ScopeVINs)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
o, e := json.Marshal(input.ScopeOEMs)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
m, e := json.Marshal(input.ScopeModels)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
co, e := json.Marshal(input.ScopeCompanies)
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", e
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
c, e := json.Marshal(input.NotificationChannels)
|
||||
return string(p), string(v), string(o), string(m), string(co), string(c), e
|
||||
if e != nil {
|
||||
return "", "", "", "", "", "", "", e
|
||||
}
|
||||
t, e := json.Marshal(input.NotificationTargets)
|
||||
return string(p), string(v), string(o), string(m), string(co), string(c), string(t), e
|
||||
}
|
||||
|
||||
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
|
||||
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`
|
||||
const alertRuleInsertSQL = `INSERT INTO vehicle_alert_rule(id,name,description,trigger_type,fence_name,fence_longitude,fence_latitude,fence_radius_m,severity,value_type,metric,operator,threshold_value,threshold_high,boolean_threshold,duration_sec,recovery_operator,recovery_threshold,repeat_interval_sec,scope_protocols_json,scope_vins_json,scope_oems_json,scope_models_json,scope_companies_json,notification_channels_json,notification_targets_json,enabled,version,created_by,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`
|
||||
const alertRuleUpdateSQL = `UPDATE vehicle_alert_rule SET name=?,description=?,trigger_type=?,fence_name=?,fence_longitude=?,fence_latitude=?,fence_radius_m=?,severity=?,value_type=?,metric=?,operator=?,threshold_value=?,threshold_high=?,boolean_threshold=?,duration_sec=?,recovery_operator=?,recovery_threshold=?,repeat_interval_sec=?,scope_protocols_json=?,scope_vins_json=?,scope_oems_json=?,scope_models_json=?,scope_companies_json=?,notification_channels_json=?,notification_targets_json=?,enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`
|
||||
|
||||
func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
p, v, o, m, co, c, err := marshalAlertRuleLists(input)
|
||||
p, v, o, m, co, c, t, err := marshalAlertRuleLists(input)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
@@ -276,20 +481,24 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var current int
|
||||
err = tx.QueryRowContext(ctx, `SELECT version FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t)
|
||||
var archivedAt sql.NullTime
|
||||
err = tx.QueryRowContext(ctx, `SELECT version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(¤t, &archivedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
if input.Version != 0 {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.Actor)
|
||||
_, err = tx.ExecContext(ctx, alertRuleInsertSQL, input.ID, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.Actor)
|
||||
current = 0
|
||||
} else if err != nil {
|
||||
return AlertRule{}, err
|
||||
} else {
|
||||
if archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
|
||||
}
|
||||
if current != input.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, input.Enabled, input.Actor, input.ID, current)
|
||||
res, e := tx.ExecContext(ctx, alertRuleUpdateSQL, input.Name, input.Description, input.TriggerType, input.FenceName, input.FenceLongitude, input.FenceLatitude, input.FenceRadiusM, strings.ToLower(input.Severity), strings.ToLower(input.ValueType), input.Metric, strings.ToLower(input.Operator), input.Threshold, input.ThresholdHigh, input.BooleanThreshold, input.DurationSec, strings.ToLower(input.RecoveryOperator), input.RecoveryThreshold, input.RepeatIntervalSec, p, v, o, m, co, c, t, input.Enabled, input.Actor, input.ID, current)
|
||||
err = e
|
||||
if err == nil {
|
||||
n, _ := res.RowsAffected()
|
||||
@@ -301,8 +510,11 @@ func (s *ProductionStore) SaveAlertRule(ctx context.Context, input AlertRuleInpu
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
snapshot, _ := json.Marshal(input)
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, input.ID, current+1, input.Actor, firstNonEmpty(map[bool]string{true: "create", false: "update"}[current == 0], "update"), string(snapshot)); err != nil {
|
||||
auditSnapshot := input
|
||||
auditSnapshot.Version = current + 1
|
||||
snapshot, _ := json.Marshal(auditSnapshot)
|
||||
action := firstNonEmpty(strings.TrimSpace(input.AuditAction), map[bool]string{true: "create", false: "update"}[current == 0], "update")
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, input.ID, current+1, input.Actor, action, "", string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -320,7 +532,7 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up
|
||||
return AlertRule{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`, update.Enabled, update.Actor, id, update.Version)
|
||||
res, err := tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, update.Enabled, update.Actor, id, update.Version)
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
@@ -341,7 +553,70 @@ func (s *ProductionStore) SetAlertRuleEnabled(ctx context.Context, id string, up
|
||||
}
|
||||
}
|
||||
snapshot, _ := json.Marshal(rule)
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], string(snapshot)); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled], "", string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var enabled bool
|
||||
var version int
|
||||
var archivedAt sql.NullTime
|
||||
if err = tx.QueryRowContext(ctx, `SELECT enabled,version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, id).Scan(&enabled, &version, &archivedAt); err == sql.ErrNoRows {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
|
||||
} else if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if version != request.Version {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
if archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
|
||||
}
|
||||
if enabled {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`, request.Actor, request.Reason, request.Actor, id, version)
|
||||
} else {
|
||||
if !archivedAt.Valid {
|
||||
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_rule SET archived_at=NULL,archived_by='',archive_reason='',enabled=0,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NOT NULL`, request.Actor, id, version)
|
||||
}
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if archived {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_candidate WHERE rule_id=?`, id); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`, id); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
}
|
||||
rule, err := scanAlertRuleLibrary(tx.QueryRowContext(ctx, alertRuleLibrarySelect+`WHERE id=?`, id))
|
||||
if err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
snapshot, _ := json.Marshal(rule)
|
||||
action := "restore"
|
||||
if archived {
|
||||
action = "archive"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`, id, rule.Version, request.Actor, action, request.Reason, string(snapshot)); err != nil {
|
||||
return AlertRule{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
@@ -412,23 +687,46 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
where := "channel='in_app'"
|
||||
conditions := []string{"1=1"}
|
||||
args := []any{}
|
||||
if query.UnreadOnly {
|
||||
where += " AND is_read=0"
|
||||
conditions = append(conditions, "n.channel='in_app'", "n.is_read=0")
|
||||
}
|
||||
if len(query.AllowedVINs) > 0 {
|
||||
placeholders := make([]string, len(query.AllowedVINs))
|
||||
for index, vin := range query.AllowedVINs {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
conditions = append(conditions, "e.vin IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
if query.DeliveryStatus == "failed" {
|
||||
conditions = append(conditions, "n.delivery_status='failed'")
|
||||
} else if query.DeliveryStatus == "queued" {
|
||||
conditions = append(conditions, "n.delivery_status IN ('created','reserved')")
|
||||
} else if query.DeliveryStatus == "delivered" {
|
||||
conditions = append(conditions, "n.delivery_status NOT IN ('created','reserved','failed')")
|
||||
}
|
||||
if query.Search != "" {
|
||||
like := "%" + query.Search + "%"
|
||||
conditions = append(conditions, "(n.title LIKE ? OR n.content LIKE ? OR n.event_id LIKE ? OR e.plate LIKE ? OR e.vin LIKE ? OR e.protocol LIKE ?)")
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
where := strings.Join(conditions, " AND ")
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_alert_notification WHERE `+where).Scan(&total); err != nil {
|
||||
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 `+where, args...).Scan(&total); err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,event_id,title,content,severity,channel,is_read,DATE_FORMAT(created_at,'%Y-%m-%dT%H:%i:%s.%fZ'),COALESCE(DATE_FORMAT(read_at,'%Y-%m-%dT%H:%i:%s.%fZ'),'') FROM vehicle_alert_notification WHERE `+where+` ORDER BY created_at DESC,id DESC LIMIT ? OFFSET ?`, query.Limit, query.Offset)
|
||||
queryArgs := append(append([]any{}, args...), query.Limit, query.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, alertNotificationSelect+where+` ORDER BY n.created_at DESC,n.id DESC LIMIT ? OFFSET ?`, queryArgs...)
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AlertNotification{}
|
||||
for rows.Next() {
|
||||
var item AlertNotification
|
||||
if err := rows.Scan(&item.ID, &item.EventID, &item.Title, &item.Content, &item.Severity, &item.Channel, &item.Read, &item.CreatedAt, &item.ReadAt); err != nil {
|
||||
item, err := scanAlertNotification(rows)
|
||||
if err != nil {
|
||||
return Page[AlertNotification]{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@@ -436,6 +734,42 @@ func (s *ProductionStore) AlertNotifications(ctx context.Context, query AlertNot
|
||||
return Page[AlertNotification]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT channel,
|
||||
COALESCE(SUM(CASE WHEN delivery_status IN ('created','reserved') THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN delivery_status='failed' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN delivery_status='failed' AND GREATEST(COALESCE(attempt_count,1),1)>=? THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN lease_token<>'' AND lease_expires_at>NOW(3) THEN 1 ELSE 0 END),0),
|
||||
COALESCE(DATE_FORMAT(MIN(CASE WHEN delivery_status IN ('created','reserved') THEN created_at END),'`+alertSQLTimestampFormat+`'),'')
|
||||
FROM vehicle_alert_notification
|
||||
WHERE channel<>'in_app'
|
||||
GROUP BY channel
|
||||
ORDER BY FIELD(channel,'sms','email','wecom'),channel`, alertNotificationMaxAttempts)
|
||||
if err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
|
||||
for rows.Next() {
|
||||
var channel AlertNotificationChannelHealth
|
||||
if err := rows.Scan(&channel.Channel, &channel.Queued, &channel.Failed, &channel.DeadLetter, &channel.ActiveLeases, &channel.OldestQueuedAt); err != nil {
|
||||
return AlertNotificationDeliveryHealth{}, err
|
||||
}
|
||||
health.Queued += channel.Queued
|
||||
health.Failed += channel.Failed
|
||||
health.DeadLetter += channel.DeadLetter
|
||||
health.ActiveLeases += channel.ActiveLeases
|
||||
if channel.OldestQueuedAt != "" && (health.OldestQueuedAt == "" || channel.OldestQueuedAt < health.OldestQueuedAt) {
|
||||
health.OldestQueuedAt = channel.OldestQueuedAt
|
||||
}
|
||||
health.Channels = append(health.Channels, channel)
|
||||
}
|
||||
return health, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return 0, err
|
||||
@@ -447,10 +781,137 @@ func (s *ProductionStore) MarkAlertNotificationsRead(ctx context.Context, reques
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `UPDATE vehicle_alert_notification SET is_read=1,read_by=?,read_at=CURRENT_TIMESTAMP(3) WHERE channel='in_app' AND is_read=0 AND id IN (`+strings.Join(placeholders, ",")+`)`, args...)
|
||||
query := `UPDATE vehicle_alert_notification n`
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
query += ` JOIN vehicle_alert_event e ON e.id=n.event_id`
|
||||
}
|
||||
query += ` SET n.is_read=1,n.read_by=?,n.read_at=CURRENT_TIMESTAMP(3) WHERE n.channel='in_app' AND n.is_read=0 AND n.id IN (` + strings.Join(placeholders, ",") + `)`
|
||||
if len(request.AllowedVINs) > 0 {
|
||||
vinPlaceholders := make([]string, len(request.AllowedVINs))
|
||||
for index, vin := range request.AllowedVINs {
|
||||
vinPlaceholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
query += ` AND e.vin IN (` + strings.Join(vinPlaceholders, ",") + `)`
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) alertNotificationByID(ctx context.Context, id int64) (AlertNotification, error) {
|
||||
item, err := scanAlertNotification(s.db.QueryRowContext(ctx, alertNotificationSelect+`n.id=?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlertNotification{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, alertNotificationRetryAuditSelect+`notification_id=? ORDER BY requested_at DESC,id DESC LIMIT 20`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]AlertNotificationRetryAudit, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanAlertNotificationRetryAudit(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
|
||||
if err := s.ensureAlertSchema(ctx); err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var channel, status string
|
||||
var attemptCount int
|
||||
err = tx.QueryRowContext(ctx, `SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`, id).Scan(&channel, &status, &attemptCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
|
||||
}
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
|
||||
existing, existingErr := scanAlertNotificationRetryAudit(tx.QueryRowContext(ctx, alertNotificationRetryAuditSelect+`idempotency_key=?`, request.IdempotencyKey))
|
||||
if existingErr == nil {
|
||||
if existing.NotificationID != id {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
|
||||
}
|
||||
if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
notification, loadErr := s.alertNotificationByID(ctx, id)
|
||||
if loadErr != nil {
|
||||
return AlertNotificationRetryResult{}, loadErr
|
||||
}
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: existing, Idempotent: true}, nil
|
||||
}
|
||||
if !errors.Is(existingErr, sql.ErrNoRows) {
|
||||
return AlertNotificationRetryResult{}, existingErr
|
||||
}
|
||||
if status != "failed" {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
|
||||
}
|
||||
if attemptCount != request.ExpectedAttemptCount {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
|
||||
}
|
||||
if attemptCount >= alertNotificationMaxAttempts {
|
||||
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
|
||||
}
|
||||
|
||||
nextStatus := "reserved"
|
||||
if channel == "in_app" {
|
||||
nextStatus = "sent"
|
||||
}
|
||||
nextAttempt := attemptCount + 1
|
||||
result, err := tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`,
|
||||
id, request.IdempotencyKey, request.Actor, request.Reason, status, nextStatus, nextAttempt)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
auditID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if channel == "in_app" {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='sent',attempt_count=?,provider_message_id=CONCAT('in_app:',id,':retry:',?),delivered_at=CURRENT_TIMESTAMP(3),last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`,
|
||||
nextAttempt, nextAttempt, request.Actor, id)
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`,
|
||||
nextAttempt, request.Actor, id)
|
||||
}
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
notification, err := s.alertNotificationByID(ctx, id)
|
||||
if err != nil {
|
||||
return AlertNotificationRetryResult{}, err
|
||||
}
|
||||
receipt := AlertNotificationRetryAudit{
|
||||
ID: auditID, NotificationID: id, Actor: request.Actor, Reason: request.Reason,
|
||||
PreviousStatus: status, NextStatus: nextStatus, AttemptCount: nextAttempt,
|
||||
RequestedAt: notification.RetryRequestedAt,
|
||||
}
|
||||
return AlertNotificationRetryResult{Notification: notification, Receipt: receipt}, nil
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
if !alertRuleInScope(rule, item) {
|
||||
continue
|
||||
}
|
||||
value, supported := alertStreamMetricValue(rule.Metric, record, mappings)
|
||||
value, supported := alertStreamRuleMetricValue(rule, item, record, mappings)
|
||||
if !supported {
|
||||
continue
|
||||
}
|
||||
@@ -75,22 +75,15 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
observedAt = record.ReceivedAt
|
||||
}
|
||||
fingerprint := rule.ID + "|" + record.VIN + "|" + record.Protocol
|
||||
matched := false
|
||||
if strings.EqualFold(rule.Operator, "changed") {
|
||||
normalized := 0.0
|
||||
if value != 0 {
|
||||
normalized = 1
|
||||
}
|
||||
previous, exists := ruleStates[fingerprint]
|
||||
if exists && !observedAt.After(previous.LastObservedAt) {
|
||||
previous, stateExists := ruleStates[fingerprint]
|
||||
matched, normalized, stateful := alertRuleMatches(rule, value, previous, stateExists)
|
||||
if stateful {
|
||||
if stateExists && !observedAt.After(previous.LastObservedAt) {
|
||||
result.LateObservations++
|
||||
continue
|
||||
}
|
||||
matched = exists && previous.LastValue != normalized
|
||||
ruleStates[fingerprint] = alertRuleState{LastValue: normalized, LastObservedAt: observedAt}
|
||||
stateUpserts[fingerprint] = alertRuleStateUpsert{RuleID: rule.ID, VIN: record.VIN, Protocol: record.Protocol, LastValue: normalized, ObservedAt: observedAt}
|
||||
} else {
|
||||
matched = compareAlertRuleValue(value, rule)
|
||||
}
|
||||
if matched {
|
||||
if len(activeEvents[fingerprint]) > 0 {
|
||||
@@ -125,12 +118,12 @@ func evaluateAlertStreamRecordsTx(ctx context.Context, tx *sql.Tx, records []Ale
|
||||
return result, err
|
||||
}
|
||||
unit := alertMetricUnit(rule.Metric)
|
||||
for _, channel := range rule.NotificationChannels {
|
||||
for _, target := range rule.NotificationTargets {
|
||||
delivery := "reserved"
|
||||
if channel == "in_app" {
|
||||
delivery = "created"
|
||||
if target.Channel == "in_app" {
|
||||
delivery = "sent"
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,delivery_status) VALUES(?,?,?,?,?,?)`, id, rule.Name, fmt.Sprintf("%s / %s 触发%s:%.2f %s", item.Plate, item.VIN, rule.Name, value, unit), rule.Severity, channel, delivery); err != nil {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO vehicle_alert_notification(event_id,title,content,severity,channel,recipient,recipient_ref,delivery_status) VALUES(?,?,?,?,?,?,?,?)`, id, alertNotificationTitle(rule), alertNotificationContent(rule, item, value, unit), rule.Severity, target.Channel, target.Label, target.RecipientID, delivery); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
@@ -313,7 +306,7 @@ func loadAlertStreamLastTriggered(ctx context.Context, tx *sql.Tx, rules []Alert
|
||||
}
|
||||
|
||||
func loadActiveAlertStreamRules(ctx context.Context, tx *sql.Tx) ([]AlertRule, error) {
|
||||
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
|
||||
rows, err := tx.QueryContext(ctx, alertRuleSelect+`WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -386,8 +379,8 @@ func loadAlertStreamVehicleMetadata(ctx context.Context, tx *sql.Tx, records []A
|
||||
}
|
||||
|
||||
func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMetadata, mappings alertStreamMetricMapping) alertEvaluationEvidence {
|
||||
longitude, _ := alertStreamLocationValue("longitude", record, mappings)
|
||||
latitude, _ := alertStreamLocationValue("latitude", record, mappings)
|
||||
longitude, longitudeOK := alertStreamLocationValue("longitude", record, mappings)
|
||||
latitude, latitudeOK := alertStreamLocationValue("latitude", record, mappings)
|
||||
plate := firstNonEmpty(record.Plate, metadata.Plate)
|
||||
return alertEvaluationEvidence{
|
||||
VIN: record.VIN, Plate: plate, Protocol: record.Protocol, OEM: metadata.OEM, Model: metadata.Model, Company: metadata.Company,
|
||||
@@ -395,10 +388,18 @@ func alertStreamEvidence(record AlertStreamRecord, metadata alertStreamVehicleMe
|
||||
EventAt: record.EventAt.Format("2006-01-02 15:04:05.999"),
|
||||
ReceivedAt: record.ReceivedAt.Format("2006-01-02 15:04:05.999"),
|
||||
Longitude: longitude, Latitude: latitude,
|
||||
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
|
||||
HasLocation: longitudeOK && latitudeOK && validAlertCoordinate(longitude, latitude),
|
||||
Location: fmt.Sprintf("%.6f,%.6f", longitude, latitude),
|
||||
}
|
||||
}
|
||||
|
||||
func alertStreamRuleMetricValue(rule AlertRule, item alertEvaluationEvidence, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
|
||||
if normalizedAlertTriggerType(rule.TriggerType, rule.Metric) == "geofence" {
|
||||
return alertRuleMetricValue(rule, item)
|
||||
}
|
||||
return alertStreamMetricValue(rule.Metric, record, mappings)
|
||||
}
|
||||
|
||||
func alertStreamLocationValue(metric string, record AlertStreamRecord, mappings alertStreamMetricMapping) (float64, bool) {
|
||||
if source := mappings[metric][strings.ToUpper(record.Protocol)]; source != "" && !strings.EqualFold(source, "vehicle_realtime_location."+metric) {
|
||||
return alertStreamRawNumber(record.Fields[source])
|
||||
|
||||
@@ -39,14 +39,16 @@ func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *te
|
||||
record := AlertStreamRecord{
|
||||
Protocol: "GB32960", EventAt: time.Unix(100, 0), ReceivedAt: time.Unix(130, 0),
|
||||
Fields: map[string]json.RawMessage{
|
||||
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
|
||||
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
|
||||
"gb32960.vehicle.speed_kmh": json.RawMessage(`"82.5"`),
|
||||
"gb32960.alarm.general_alarm_flag": json.RawMessage(`"0x00000004"`),
|
||||
"gb32960.fuel_cell.max_hydrogen_concentration_percent": json.RawMessage(`0.032`),
|
||||
},
|
||||
}
|
||||
mappings := alertStreamMetricMapping{
|
||||
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
|
||||
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
|
||||
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
|
||||
"speed_kmh": {"GB32960": "gb32960.vehicle.speed_kmh"},
|
||||
"soc_percent": {"GB32960": "gb32960.vehicle.soc_percent"},
|
||||
"alarm_active": {"GB32960": "gb32960.alarm.general_alarm_flag"},
|
||||
"hydrogen_concentration_percent": {"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"},
|
||||
}
|
||||
if value, ok := alertStreamMetricValue("speed_kmh", record, mappings); !ok || value != 82.5 {
|
||||
t.Fatalf("mapped numeric field = %v,%v", value, ok)
|
||||
@@ -60,6 +62,9 @@ func TestAlertStreamMetricValueUsesCatalogMappingAndStrictMissingSemantics(t *te
|
||||
if value, ok := alertStreamMetricValue("data_delay_sec", record, mappings); !ok || value != 30 {
|
||||
t.Fatalf("derived delay = %v,%v", value, ok)
|
||||
}
|
||||
if value, ok := alertStreamMetricValue("hydrogen_concentration_percent", record, mappings); !ok || value != 0.032 {
|
||||
t.Fatalf("mapped hydrogen concentration = %v,%v", value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertStreamStateQueriesAreScopedToBatchRulesVehiclesAndProtocols(t *testing.T) {
|
||||
@@ -86,7 +91,7 @@ func TestActiveAlertStreamCommitsRuleEffectsBeforeCheckpointInSameTransaction(t
|
||||
checkpointQuery := `SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(checkpointQuery)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnRows(sqlmock.NewRows([]string{"id"}))
|
||||
mock.ExpectExec(`INSERT INTO vehicle_alert_stream_checkpoint`).WithArgs("alert-active", record.Topic, 0, int64(8), int64(8), int64(1), int64(1), int64(0), int64(0), int64(0), record.EventAt, record.ReceivedAt, "evt-7", "", "").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
result, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record})
|
||||
@@ -108,7 +113,7 @@ func TestActiveAlertStreamRollsBackCheckpointWhenRuleEvaluationFails(t *testing.
|
||||
record := AlertStreamRecord{Topic: "vehicle.fields.go.jt808.v1", Partition: 0, Offset: 9, HighWatermark: 10, Protocol: "JT808", VIN: "VIN1", SourceEventID: "evt-9", EventAt: time.Now(), ReceivedAt: time.Now(), Fields: map[string]json.RawMessage{"jt808.location.speed_kmh": json.RawMessage(`10`)}, Valid: true}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT next_offset FROM vehicle_alert_stream_checkpoint WHERE consumer_group=? AND topic=? AND partition_id=? FOR UPDATE`)).WithArgs("alert-active", record.Topic, 0).WillReturnRows(sqlmock.NewRows([]string{"next_offset"}))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE enabled=1 AND archived_at IS NULL AND metric<>'freshness_sec' ORDER BY id FOR UPDATE`)).WillReturnError(errors.New("rule read failed"))
|
||||
mock.ExpectRollback()
|
||||
if _, err := store.RecordAlertStreamBatch(t.Context(), "alert-active", []AlertStreamRecord{record}); err == nil {
|
||||
t.Fatal("rule evaluation failure must abort checkpoint transaction")
|
||||
|
||||
@@ -43,6 +43,36 @@ func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertSQLTimestampsDeclareShanghaiOffset(t *testing.T) {
|
||||
if alertSQLTimestampFormat != "%Y-%m-%dT%H:%i:%s.%f+08:00" {
|
||||
t.Fatalf("unexpected alert timestamp format %q", alertSQLTimestampFormat)
|
||||
}
|
||||
for name, query := range map[string]string{
|
||||
"event": alertEventSelect,
|
||||
"action": alertActionSelect,
|
||||
"rule": alertRuleSelect,
|
||||
"notification": alertNotificationSelect,
|
||||
} {
|
||||
if !strings.Contains(query, alertSQLTimestampFormat) {
|
||||
t.Fatalf("%s query does not use the alert timestamp format", name)
|
||||
}
|
||||
if strings.Contains(query, "%fZ") {
|
||||
t.Fatalf("%s query still labels local database time as UTC", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVehicleEventPublishesCanonicalContract(t *testing.T) {
|
||||
event := normalizeVehicleEvent(AlertEvent{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "exit", Status: "unprocessed"})
|
||||
if event.EventType != "vehicle.geofence.exited" || event.EventCategory != "geofence" || event.ExecutionState != "pending" {
|
||||
t.Fatalf("unexpected canonical event: %#v", event)
|
||||
}
|
||||
recovered := normalizeVehicleEvent(AlertEvent{TriggerType: "offline", Metric: "freshness_sec", Status: "recovered"})
|
||||
if recovered.EventType != "vehicle.connectivity.offline" || recovered.ExecutionState != "recovered" {
|
||||
t.Fatalf("unexpected recovered event: %#v", recovered)
|
||||
}
|
||||
}
|
||||
|
||||
type configuredMetricStore struct {
|
||||
*MockStore
|
||||
definitions []MetricDefinition
|
||||
@@ -145,9 +175,12 @@ func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{AlertNotificationConfig: AlertNotificationConfig{
|
||||
Targets: []AlertNotificationTargetOption{{ID: "night-shift", Label: "夜班负责人", Channels: []string{"sms"}}},
|
||||
Channels: []AlertNotificationChannelCapability{{Channel: "in_app", Label: "站内信", Configured: true}, {Channel: "sms", Label: "短信", Configured: true}},
|
||||
}})
|
||||
truth := true
|
||||
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationChannels: []string{"sms", "sms"}, Enabled: true})
|
||||
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationTargets: []AlertNotificationTarget{{Channel: "sms", RecipientID: "night-shift", Label: "夜班负责人"}}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -167,6 +200,167 @@ func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRollbackCreatesRevisionAndPreservesEnabledState(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
rules, err := service.AlertRules(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var original AlertRule
|
||||
for _, rule := range rules {
|
||||
if rule.ID == "rule-speeding" {
|
||||
original = rule
|
||||
break
|
||||
}
|
||||
}
|
||||
changed := alertRuleInputFromRule(original)
|
||||
changed.Name = "车辆持续超速"
|
||||
changed.Threshold = 96
|
||||
changed.Actor = "editor-a"
|
||||
updated, err := service.SaveAlertRule(t.Context(), changed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled, err := service.SetAlertRuleEnabled(t.Context(), updated.ID, AlertRuleEnabledUpdate{Version: updated.Version, Enabled: false, Actor: "operator-b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := service.RollbackAlertRule(t.Context(), disabled.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: disabled.Version, Actor: "reviewer-c"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.Name != original.Name || restored.Threshold != original.Threshold {
|
||||
t.Fatalf("target configuration was not restored: %+v", restored)
|
||||
}
|
||||
if restored.Enabled || restored.Version != disabled.Version+1 {
|
||||
t.Fatalf("rollback must preserve disabled state and create a new version: %+v", restored)
|
||||
}
|
||||
revisions, err := service.AlertRuleRevisions(t.Context(), restored.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) < 4 || revisions[0].Action != "rollback" || revisions[0].Version != restored.Version || revisions[0].Actor != "reviewer-c" {
|
||||
t.Fatalf("rollback revision missing: %+v", revisions)
|
||||
}
|
||||
_, err = service.RollbackAlertRule(t.Context(), restored.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: restored.Version, Actor: "reviewer-c"})
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "ALERT_RULE_ROLLBACK_NO_CHANGES" {
|
||||
t.Fatalf("expected no-op rollback rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleLibraryArchivesAndRestoresDisabledRuleWithAudit(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
admin := WithPrincipal(t.Context(), Principal{Name: "规则管理员", Username: "rule-admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
current, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "current", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if current.Total != 6 || current.Summary.Current != 6 || current.Summary.Archived != 0 {
|
||||
t.Fatalf("unexpected initial library: %+v", current)
|
||||
}
|
||||
|
||||
archived, err := service.SetAlertRuleArchived(admin, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "安全阈值规则已由新版替代", Actor: "rule-admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archived.ArchivedAt == "" || archived.ArchivedBy != "rule-admin" || archived.ArchiveReason == "" || archived.Version != 2 {
|
||||
t.Fatalf("archive receipt incomplete: %+v", archived)
|
||||
}
|
||||
|
||||
activeRules, err := service.AlertRules(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range activeRules {
|
||||
if rule.ID == archived.ID {
|
||||
t.Fatalf("archived rule leaked into active evaluator list: %+v", rule)
|
||||
}
|
||||
}
|
||||
archivePage, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "archived", Keyword: "新版替代", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archivePage.Total != 1 || archivePage.Summary.Current != 5 || archivePage.Summary.Archived != 1 || archivePage.Items[0].ID != archived.ID {
|
||||
t.Fatalf("unexpected archive page: %+v", archivePage)
|
||||
}
|
||||
revisions, err := service.AlertRuleRevisions(admin, archived.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revisions[0].Action != "archive" || revisions[0].Reason != "安全阈值规则已由新版替代" {
|
||||
t.Fatalf("archive audit missing: %+v", revisions[0])
|
||||
}
|
||||
|
||||
restored, err := service.SetAlertRuleArchived(admin, archived.ID, false, AlertRuleLifecycleRequest{Version: archived.Version, Reason: "恢复复核通知接收人配置", Actor: "rule-admin"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.ArchivedAt != "" || restored.Enabled || restored.Version != 3 {
|
||||
t.Fatalf("restored rule must return disabled at next version: %+v", restored)
|
||||
}
|
||||
revisions, err = service.AlertRuleRevisions(admin, restored.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if revisions[0].Action != "restore" || revisions[0].Reason != "恢复复核通知接收人配置" {
|
||||
t.Fatalf("restore audit missing: %+v", revisions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleGovernanceRequiresAdministrator(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
operator := WithPrincipal(t.Context(), Principal{Name: "规则值班员", Role: "operator", UserType: "operator"})
|
||||
if _, err := service.AlertRulePage(operator, AlertRuleQuery{Limit: 10}); err == nil {
|
||||
t.Fatal("operator must not list the governance rule library")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected library permission error: %v", err)
|
||||
}
|
||||
if _, err := service.SetAlertRuleArchived(operator, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "规则已经由新版替代"}); err == nil {
|
||||
t.Fatal("operator must not archive automation rules")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected archive permission error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRevisionRoutes(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v2/alerts/rules/rule-speeding/revisions", nil))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"version":2`) {
|
||||
t.Fatalf("revision route status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
rollback := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/rules/rule-speeding/rollback", strings.NewReader(`{"targetVersion":2,"currentVersion":2}`)))
|
||||
if rollback.Code != http.StatusBadRequest || !strings.Contains(rollback.Body.String(), "ALERT_RULE_ROLLBACK_TARGET_INVALID") {
|
||||
t.Fatalf("rollback validation status=%d body=%s", rollback.Code, rollback.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRevisionsParsesAuditSnapshots(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectQuery("SELECT rule_id,rule_version,actor,action,COALESCE\\(reason,''\\),snapshot_json").WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"rule_id", "rule_version", "actor", "action", "reason", "snapshot_json", "created_at"}).AddRow("rule-1", 3, "admin-a", "update", "", `{"id":"rule-1","name":"超速","severity":"major","valueType":"numeric","metric":"speed_kmh","operator":"gt","threshold":90,"scopeProtocols":["JT808"],"scopeVins":[],"scopeOems":[],"scopeModels":[],"scopeCompanies":[],"notificationChannels":["in_app"],"enabled":true}`, "2026-07-23T10:00:00.000000+08:00"))
|
||||
revisions, err := store.AlertRuleRevisions(t.Context(), "rule-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(revisions) != 1 || revisions[0].Snapshot.Version != 3 || revisions[0].Snapshot.Threshold != 90 || revisions[0].Snapshot.UpdatedBy != "admin-a" {
|
||||
t.Fatalf("unexpected revisions: %+v", revisions)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
before, err := service.AlertSummary(t.Context(), AlertQuery{})
|
||||
@@ -190,6 +384,139 @@ func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsFiltersTheCompleteRangeBeforePagination(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
store.alertNotifications = append(store.alertNotifications, AlertNotification{
|
||||
ID: 999, EventID: "event-delivery-failed", Title: "夜班短信失败", Content: "短信网关超时",
|
||||
Severity: "major", Channel: "sms", Recipient: "夜班负责人", DeliveryStatus: "failed",
|
||||
VehiclePlate: "浙A·失败", VehicleVIN: "VIN-DELIVERY-FAILED", Protocol: "JT808",
|
||||
CreatedAt: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
service := NewService(store)
|
||||
|
||||
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
||||
Search: "VIN-DELIVERY-FAILED", DeliveryStatus: "failed", Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 999 {
|
||||
t.Fatalf("server-wide notification filter mismatch: %+v", page)
|
||||
}
|
||||
delivered, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
|
||||
Search: "短信网关超时", DeliveryStatus: "delivered", Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if delivered.Total != 0 || len(delivered.Items) != 0 {
|
||||
t.Fatalf("failed delivery leaked into delivered filter: %+v", delivered)
|
||||
}
|
||||
if _, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{DeliveryStatus: "unknown", Limit: 20}); err == nil {
|
||||
t.Fatal("invalid delivery status should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationRetryIsPermissionedIdempotentAndAudited(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
operator := WithPrincipal(t.Context(), Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"})
|
||||
request := AlertNotificationRetryRequest{
|
||||
ExpectedAttemptCount: 2,
|
||||
Reason: "已确认短信网关恢复",
|
||||
IdempotencyKey: "notification-retry-10-attempt-3",
|
||||
}
|
||||
result, err := service.RetryAlertNotification(operator, 10, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Idempotent || result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 3 || result.Notification.RetryRequestedBy != "operator-a" {
|
||||
t.Fatalf("unexpected retry result: %+v", result)
|
||||
}
|
||||
if result.Receipt.NotificationID != 10 || result.Receipt.AttemptCount != 3 || result.Receipt.Reason != request.Reason {
|
||||
t.Fatalf("unexpected retry receipt: %+v", result.Receipt)
|
||||
}
|
||||
repeated, err := service.RetryAlertNotification(operator, 10, request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !repeated.Idempotent || repeated.Notification.AttemptCount != 3 || repeated.Receipt.ID != result.Receipt.ID {
|
||||
t.Fatalf("same key must reuse the original retry: %+v", repeated)
|
||||
}
|
||||
audit, err := service.AlertNotificationRetryAudits(operator, 10)
|
||||
if err != nil || len(audit) != 1 || audit[0].ID != result.Receipt.ID {
|
||||
t.Fatalf("retry audit mismatch: %+v err=%v", audit, err)
|
||||
}
|
||||
viewer := WithPrincipal(t.Context(), Principal{Name: "只读审计员", Username: "viewer-a", Role: "viewer", UserType: "operator"})
|
||||
if _, err := service.RetryAlertNotification(viewer, 10, request); err == nil {
|
||||
t.Fatal("read-only role must not retry notifications")
|
||||
}
|
||||
if _, err := service.RetryAlertNotification(operator, 10, AlertNotificationRetryRequest{ExpectedAttemptCount: 2, Reason: "短", IdempotencyKey: "notification-retry-invalid"}); err == nil {
|
||||
t.Fatal("retry reason must be auditable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationsScopeCustomerToGrantedVehicles(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
allowed := WithPrincipal(t.Context(), Principal{
|
||||
Name: "客户账号", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LFP23A98V2P012345"},
|
||||
})
|
||||
page, err := service.AlertNotifications(allowed, AlertNotificationQuery{DeliveryStatus: "failed", Limit: 20})
|
||||
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 10 {
|
||||
t.Fatalf("customer notification scope mismatch: %+v err=%v", page, err)
|
||||
}
|
||||
denied := WithPrincipal(t.Context(), Principal{Name: "无授权客户", Role: "customer", UserType: "customer"})
|
||||
empty, err := service.AlertNotifications(denied, AlertNotificationQuery{Limit: 20})
|
||||
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
|
||||
t.Fatalf("customer without grants must receive an empty scope: %+v err=%v", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionAlertNotificationRetryQueuesOneAuditedAttempt(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`)).
|
||||
WithArgs(int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"channel", "delivery_status", "attempt_count"}).AddRow("sms", "failed", 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationRetryAuditSelect + `idempotency_key=?`)).
|
||||
WithArgs("notification-retry-42-attempt-2").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "notification_id", "actor", "reason", "previous_status", "next_status", "attempt_count", "requested_at"}))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`)).
|
||||
WithArgs(int64(42), "notification-retry-42-attempt-2", "operator-a", "短信网关已经恢复", "failed", "reserved", 2).
|
||||
WillReturnResult(sqlmock.NewResult(81, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`)).
|
||||
WithArgs(2, "operator-a", int64(42)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
notificationColumns := []string{"id", "event_id", "title", "content", "severity", "channel", "recipient", "recipient_ref", "delivery_status", "attempt_count", "provider_message_id", "is_read", "created_at", "delivered_at", "read_at", "last_error", "last_attempt_at", "retry_requested_by", "retry_requested_at", "plate", "vin", "protocol"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationSelect + `n.id=?`)).
|
||||
WithArgs(int64(42)).
|
||||
WillReturnRows(sqlmock.NewRows(notificationColumns).AddRow(42, "event-42", "低电量短信", "SOC 低于 20%", "major", "sms", "夜班负责人", "night-shift", "reserved", 2, "", false, "2026-07-23T10:00:00+08:00", "", "", "provider_timeout", "2026-07-23T10:05:00+08:00", "operator-a", "2026-07-23T10:05:00+08:00", "粤A0042", "VIN42", "GB32960"))
|
||||
|
||||
result, err := store.RetryAlertNotification(t.Context(), 42, AlertNotificationRetryRequest{
|
||||
ExpectedAttemptCount: 1,
|
||||
Reason: "短信网关已经恢复",
|
||||
IdempotencyKey: "notification-retry-42-attempt-2",
|
||||
Actor: "operator-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 2 || result.Receipt.ID != 81 || result.Receipt.NextStatus != "reserved" {
|
||||
t.Fatalf("unexpected production retry result: %+v", result)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
item := alertEvaluationEvidence{VIN: "VIN1", Protocol: "JT808", OEM: "宇通", Model: "纯电客车", Company: "示范公交", SpeedKmh: 96, AlarmFlag: 1}
|
||||
rule := AlertRule{Metric: "speed_kmh", Operator: "gt", Threshold: 80, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{"VIN1"}}
|
||||
@@ -228,10 +555,10 @@ func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
|
||||
}
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 {
|
||||
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 29 {
|
||||
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
|
||||
}
|
||||
}
|
||||
@@ -321,12 +648,12 @@ func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
columns := []string{"id", "name", "description", "trigger_type", "fence_name", "fence_longitude", "fence_latitude", "fence_radius_m", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "notification_targets_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
|
||||
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "metric", "", 0.0, 0.0, 0.0, "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, `[{"channel":"in_app","recipientId":"platform-operators","label":"平台值班组"}]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_candidate WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", "", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
rule, err := store.SetAlertRuleEnabled(t.Context(), "rule-1", AlertRuleEnabledUpdate{Version: 1, Enabled: false, Actor: "admin-a"})
|
||||
if err != nil {
|
||||
@@ -380,7 +707,7 @@ func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
|
||||
if placeholders := strings.Count(query, "?"); placeholders != len(args) {
|
||||
t.Fatalf("event insert placeholders=%d args=%d query=%s", placeholders, len(args), query)
|
||||
}
|
||||
if len(args) != 22 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
if len(args) != 23 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
|
||||
t.Fatalf("unexpected event insert contract args=%#v query=%s", args, query)
|
||||
}
|
||||
}
|
||||
@@ -408,6 +735,114 @@ func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleValidationAcceptsStreamMappedHydrogenMetric(t *testing.T) {
|
||||
var hydrogen MetricDefinition
|
||||
for _, definition := range metricDefinitions() {
|
||||
if definition.Key == "hydrogen_concentration_percent" {
|
||||
hydrogen = definition
|
||||
break
|
||||
}
|
||||
}
|
||||
if hydrogen.Key == "" {
|
||||
t.Fatal("hydrogen concentration metric missing from catalog")
|
||||
}
|
||||
input := AlertRuleInput{
|
||||
Name: "氢气浓度报警提示", Severity: "critical", ValueType: "numeric",
|
||||
Metric: hydrogen.Key, Operator: "gt", Threshold: 0.05,
|
||||
}
|
||||
if err := validateAlertRule(input, hydrogen); err != nil {
|
||||
t.Fatalf("stream-mapped hydrogen rule rejected: %v", err)
|
||||
}
|
||||
if unit := alertMetricUnit(hydrogen.Key); unit != "%" {
|
||||
t.Fatalf("hydrogen alert unit=%q want %%", unit)
|
||||
}
|
||||
|
||||
unmapped := hydrogen
|
||||
unmapped.Key = "unmapped_dynamic_metric"
|
||||
unmapped.SourceFields = nil
|
||||
input.Metric = unmapped.Key
|
||||
err := validateAlertRule(input, unmapped)
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "ALERT_RULE_METRIC_UNSUPPORTED" {
|
||||
t.Fatalf("unmapped dynamic metric should remain rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertAutomationTriggerValidationAndNormalization(t *testing.T) {
|
||||
geofence := AlertRuleInput{
|
||||
Name: "离开临港停车场", TriggerType: "geofence", FenceName: "临港停车场",
|
||||
FenceLongitude: 121.9, FenceLatitude: 30.9, FenceRadiusM: 500,
|
||||
Severity: "critical", Operator: "exit", RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"},
|
||||
}
|
||||
normalizeAlertRuleTrigger(&geofence)
|
||||
if geofence.Metric != "geofence_distance_m" || geofence.ValueType != "numeric" || geofence.Threshold != 500 {
|
||||
t.Fatalf("geofence normalization incomplete: %+v", geofence)
|
||||
}
|
||||
if err := validateAlertRule(geofence); err != nil {
|
||||
t.Fatalf("valid geofence rejected: %v", err)
|
||||
}
|
||||
geofence.ScopeProtocols = []string{"JT808", "GB32960"}
|
||||
if err := validateAlertRule(geofence); err == nil {
|
||||
t.Fatal("multi-source geofence should be rejected to prevent coordinate drift")
|
||||
}
|
||||
geofence.ScopeProtocols = []string{"JT808"}
|
||||
geofence.FenceRadiusM = 20
|
||||
if err := validateAlertRule(geofence); err == nil {
|
||||
t.Fatal("unsafe geofence radius should be rejected")
|
||||
}
|
||||
|
||||
stationary := AlertRuleInput{Name: "长时间不动", TriggerType: "stationary", Severity: "major", Operator: "lte", Threshold: 1, DurationSec: 1800}
|
||||
normalizeAlertRuleTrigger(&stationary)
|
||||
if err := validateAlertRule(stationary); err != nil {
|
||||
t.Fatalf("valid stationary rule rejected: %v", err)
|
||||
}
|
||||
offline := AlertRuleInput{Name: "长时间离线", TriggerType: "offline", Severity: "major", Operator: "gt", Threshold: 36000}
|
||||
normalizeAlertRuleTrigger(&offline)
|
||||
if err := validateAlertRule(offline); err != nil || offline.RecoveryThreshold != 36000 {
|
||||
t.Fatalf("offline rule did not normalize: rule=%+v err=%v", offline, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeofenceDistanceAndDirectionalTransitions(t *testing.T) {
|
||||
rule := AlertRule{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "enter", FenceLongitude: 121.4737, FenceLatitude: 31.2304, FenceRadiusM: 500}
|
||||
outside := alertEvaluationEvidence{Longitude: 121.49, Latitude: 31.2304, HasLocation: true}
|
||||
inside := alertEvaluationEvidence{Longitude: 121.4738, Latitude: 31.2304, HasLocation: true}
|
||||
outsideDistance, ok := alertRuleMetricValue(rule, outside)
|
||||
if !ok || outsideDistance <= 500 {
|
||||
t.Fatalf("outside distance=%f ok=%t", outsideDistance, ok)
|
||||
}
|
||||
insideDistance, ok := alertRuleMetricValue(rule, inside)
|
||||
if !ok || insideDistance >= 500 {
|
||||
t.Fatalf("inside distance=%f ok=%t", insideDistance, ok)
|
||||
}
|
||||
matched, state, stateful := alertRuleMatches(rule, insideDistance, alertRuleState{LastValue: 0}, true)
|
||||
if !matched || !stateful || state != 1 {
|
||||
t.Fatalf("enter transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
||||
}
|
||||
rule.Operator = "exit"
|
||||
matched, state, stateful = alertRuleMatches(rule, outsideDistance, alertRuleState{LastValue: 1}, true)
|
||||
if !matched || !stateful || state != 0 {
|
||||
t.Fatalf("exit transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertNotificationPolicySupportsRecordOnlyAndHighPriority(t *testing.T) {
|
||||
if channels := normalizeAlertChannels(nil); len(channels) != 0 {
|
||||
t.Fatalf("record-only automation unexpectedly created channels: %+v", channels)
|
||||
}
|
||||
if channels := normalizeAlertChannels([]string{"sms"}); len(channels) != 2 || channels[0] != "in_app" {
|
||||
t.Fatalf("reserved external channel should retain in-app evidence: %+v", channels)
|
||||
}
|
||||
rule := AlertRule{Name: "车辆离开围栏", TriggerType: "geofence", FenceName: "临港停车场", Operator: "exit", Severity: "critical"}
|
||||
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "沪A00001F"}
|
||||
if title := alertNotificationTitle(rule); !strings.HasPrefix(title, "【高优先级】") {
|
||||
t.Fatalf("critical notification title=%q", title)
|
||||
}
|
||||
if content := alertNotificationContent(rule, item, 800, "m"); !strings.Contains(content, "离开电子围栏") || !strings.Contains(content, "临港停车场") {
|
||||
t.Fatalf("geofence notification content=%q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricCatalogAndAlertValidationShareStoreConfiguration(t *testing.T) {
|
||||
definitions := metricDefinitions()
|
||||
for index := range definitions {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const vehicleBusinessRelationSelect = `SELECT
|
||||
s.source_version,
|
||||
CAST(s.vehicle_id AS CHAR),
|
||||
s.vin,
|
||||
s.plate_number,
|
||||
CAST(s.customer_id AS CHAR),
|
||||
s.customer_name,
|
||||
COALESCE(CAST(s.contract_id AS CHAR), ''),
|
||||
s.contract_code,
|
||||
s.project_name,
|
||||
s.department_id,
|
||||
s.department_name,
|
||||
s.responsible_user_id,
|
||||
s.responsible_user_name,
|
||||
s.operation_status,
|
||||
COALESCE(DATE_FORMAT(s.scope_start_at, '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(s.source_updated_at, '%Y-%m-%d %H:%i:%s'), ''),
|
||||
COALESCE(DATE_FORMAT(s.published_at, '%Y-%m-%d %H:%i:%s'), '')
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope s
|
||||
ON BINARY s.source_version = BINARY st.active_version
|
||||
WHERE st.id = 1 AND BINARY s.vin = BINARY ?
|
||||
LIMIT 1`
|
||||
|
||||
func (s *ProductionStore) ensureBusinessScopeSchema(ctx context.Context) error {
|
||||
return s.businessScopeSchema.ensure(func() error {
|
||||
var tables int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema=DATABASE() AND table_name IN ('business_scope_state','business_customer_vehicle_scope')`).Scan(&tables); err != nil {
|
||||
return err
|
||||
}
|
||||
if tables != 2 {
|
||||
return fmt.Errorf("business scope schema unavailable; apply deploy/migrations/012_business_scope_projection.sql and 016_business_scope_dimensions.sql")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleBusinessRelation(ctx context.Context, vin string) (VehicleBusinessRelation, bool, error) {
|
||||
if err := s.ensureBusinessScopeSchema(ctx); err != nil {
|
||||
return VehicleBusinessRelation{}, false, err
|
||||
}
|
||||
relation := VehicleBusinessRelation{SourceSystem: "oneos"}
|
||||
err := s.db.QueryRowContext(ctx, vehicleBusinessRelationSelect, vin).Scan(
|
||||
&relation.SourceVersion,
|
||||
&relation.VehicleID,
|
||||
&relation.VIN,
|
||||
&relation.PlateNumber,
|
||||
&relation.CustomerID,
|
||||
&relation.CustomerName,
|
||||
&relation.ContractID,
|
||||
&relation.ContractCode,
|
||||
&relation.ProjectName,
|
||||
&relation.DepartmentID,
|
||||
&relation.DepartmentName,
|
||||
&relation.ResponsibleUserID,
|
||||
&relation.ResponsibleUserName,
|
||||
&relation.OperationStatus,
|
||||
&relation.ScopeStartAt,
|
||||
&relation.SourceUpdatedAt,
|
||||
&relation.PublishedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return VehicleBusinessRelation{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return VehicleBusinessRelation{}, false, err
|
||||
}
|
||||
return relation, true, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleBusinessFilters(ctx context.Context, query url.Values) (VehicleBusinessFilters, error) {
|
||||
if err := s.ensureBusinessScopeSchema(ctx); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
where := []string{"st.id = 1"}
|
||||
args := []any{}
|
||||
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT
|
||||
s.department_id,s.department_name,s.responsible_user_id,s.responsible_user_name,
|
||||
CAST(s.customer_id AS CHAR),s.customer_name,s.operation_status,s.vin
|
||||
FROM business_scope_state st
|
||||
JOIN business_customer_vehicle_scope s ON BINARY s.source_version=BINARY st.active_version
|
||||
WHERE `+strings.Join(where, " AND "), args...)
|
||||
if err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type optionKey struct{ value, label string }
|
||||
departments := map[optionKey]map[string]bool{}
|
||||
responsibleUsers := map[optionKey]map[string]bool{}
|
||||
customers := map[optionKey]map[string]bool{}
|
||||
statuses := map[optionKey]map[string]bool{}
|
||||
add := func(target map[optionKey]map[string]bool, value, label, vin string) {
|
||||
value, label = strings.TrimSpace(value), strings.TrimSpace(label)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if label == "" {
|
||||
label = value
|
||||
}
|
||||
key := optionKey{value: value, label: label}
|
||||
if target[key] == nil {
|
||||
target[key] = map[string]bool{}
|
||||
}
|
||||
target[key][vin] = true
|
||||
}
|
||||
for rows.Next() {
|
||||
var departmentID, departmentName, responsibleID, responsibleName, customerID, customerName, status, vin string
|
||||
if err := rows.Scan(&departmentID, &departmentName, &responsibleID, &responsibleName, &customerID, &customerName, &status, &vin); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
add(departments, departmentID, departmentName, vin)
|
||||
add(responsibleUsers, responsibleID, responsibleName, vin)
|
||||
add(customers, customerID, customerName, vin)
|
||||
add(statuses, status, status, vin)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return VehicleBusinessFilters{}, err
|
||||
}
|
||||
toOptions := func(source map[optionKey]map[string]bool) []VehicleBusinessFilterOption {
|
||||
result := make([]VehicleBusinessFilterOption, 0, len(source))
|
||||
for key, vins := range source {
|
||||
result = append(result, VehicleBusinessFilterOption{Value: key.value, Label: key.label, Count: len(vins)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Label == result[j].Label {
|
||||
return result[i].Value < result[j].Value
|
||||
}
|
||||
return result[i].Label < result[j].Label
|
||||
})
|
||||
return result
|
||||
}
|
||||
return VehicleBusinessFilters{
|
||||
Departments: toOptions(departments), ResponsibleUsers: toOptions(responsibleUsers),
|
||||
Customers: toOptions(customers), Statuses: toOptions(statuses),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestVehicleBusinessRelationReadsOnlyTheActiveSnapshotAndKeepsBigIntIDsAsStrings(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM information_schema\.tables`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(vehicleBusinessRelationSelect)).
|
||||
WithArgs("LTEST000000000001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"source_version", "vehicle_id", "vin", "plate_number", "customer_id", "customer_name",
|
||||
"contract_id", "contract_code", "project_name", "department_id", "department_name",
|
||||
"responsible_user_id", "responsible_user_name", "operation_status", "scope_start_at",
|
||||
"source_updated_at", "published_at",
|
||||
}).AddRow(
|
||||
"oneos-v1:abc", "9223372036854775806", "LTEST000000000001", "粤A12345",
|
||||
"9223372036854775805", "示例客户", "9223372036854775804", "HT-001", "示范项目",
|
||||
"20", "华南运营部", "30", "张经理", "运营中",
|
||||
"2026-07-01 08:00:00", "2026-07-24 08:00:00", "2026-07-24 08:02:00",
|
||||
))
|
||||
|
||||
relation, found, err := store.VehicleBusinessRelation(context.Background(), "LTEST000000000001")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || relation.SourceSystem != "oneos" || relation.CustomerName != "示例客户" {
|
||||
t.Fatalf("unexpected business relation: %+v", relation)
|
||||
}
|
||||
if relation.VehicleID != "9223372036854775806" || relation.CustomerID != "9223372036854775805" {
|
||||
t.Fatalf("BIGINT identifiers must remain exact strings: %+v", relation)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleDetailIncludesOneOSBusinessRelation(t *testing.T) {
|
||||
detail, err := NewService(NewMockStore()).VehicleDetail(context.Background(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.BusinessRelation == nil {
|
||||
t.Fatal("vehicle detail omitted the OneOS business relation")
|
||||
}
|
||||
if detail.BusinessRelation.CustomerName != "岭牛示范客户" || detail.BusinessRelation.ContractCode != "HT-2026-001" {
|
||||
t.Fatalf("unexpected business relation: %+v", detail.BusinessRelation)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +32,7 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/vehicles/resolve", h.handleVehicleResolve)
|
||||
h.mux.HandleFunc("GET /api/vehicles/coverage", h.handleVehicleCoverage)
|
||||
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)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/summary", h.handleVehicleServiceSummary)
|
||||
h.mux.HandleFunc("GET /api/vehicle-service/overview", h.handleVehicleServiceOverview)
|
||||
@@ -43,7 +45,9 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
||||
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||
h.mux.HandleFunc("POST /api/mileage/daily", h.handleDailyMileagePost)
|
||||
h.mux.HandleFunc("GET /api/v2/statistics/mileage", h.handleMileageStatistics)
|
||||
h.mux.HandleFunc("POST /api/v2/statistics/mileage", h.handleMileageStatisticsPost)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-summary", h.handleOnlineStatisticsSummary)
|
||||
h.mux.HandleFunc("GET /api/statistics/online-vehicles", h.handleOnlineVehicleStatuses)
|
||||
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||
@@ -64,21 +68,48 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic)
|
||||
h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/summary", h.handleReconciliationSummary)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/assignees", h.handleReconciliationAssignees)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues", h.handleReconciliationIssues)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/export", h.handleReconciliationIssuesExport)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-actions", h.handleReconciliationBatchAction)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-assignments", h.handleReconciliationBatchAssignment)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-archive", h.handleReconciliationBatchArchive)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/batch-restore", h.handleReconciliationBatchRestore)
|
||||
h.mux.HandleFunc("GET /api/v2/reconciliation/issues/{id}", h.handleReconciliationIssue)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/actions", h.handleReconciliationAction)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/assignment", h.handleReconciliationAssignment)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/archive", h.handleReconciliationArchive)
|
||||
h.mux.HandleFunc("POST /api/v2/reconciliation/issues/{id}/restore", h.handleReconciliationRestore)
|
||||
h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles)
|
||||
h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback)
|
||||
h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog)
|
||||
h.mux.HandleFunc("GET /api/v2/history/metrics", h.handleHistoryMetricCatalog)
|
||||
h.mux.HandleFunc("GET /api/v2/history/query", h.handleHistoryData)
|
||||
h.mux.HandleFunc("GET /api/v2/history/series", h.handleHistorySeries)
|
||||
h.mux.HandleFunc("GET /api/v2/history/preferences", h.handleHistoryPreferences)
|
||||
h.mux.HandleFunc("PUT /api/v2/history/preferences", h.handleUpdateHistoryPreferences)
|
||||
h.mux.HandleFunc("POST /api/v2/exports", h.handleCreateHistoryExport)
|
||||
h.mux.HandleFunc("GET /api/v2/exports", h.handleListHistoryExports)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/page", h.handleListHistoryExportsPage)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/preview", h.handlePreviewHistoryExportCleanup)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/audit", h.handleHistoryExportCleanupAudit)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup", h.handleCleanupHistoryExports)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/cleanup/automation", h.handleHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("PUT /api/v2/exports/cleanup/automation", h.handleUpdateHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/approve", h.handleApproveHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/reject", h.handleRejectHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/cleanup/automation/{id}/retry", h.handleRetryHistoryExportCleanupAutomation)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/batch", h.handleBatchHistoryExports)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/cancel", h.handleCancelHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/rebuild", h.handleRebuildHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/archive", h.handleArchiveHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/exports/{id}/restore", h.handleRestoreHistoryExport)
|
||||
h.mux.HandleFunc("PUT /api/v2/exports/{id}/cleanup-protection", h.handleHistoryExportCleanupProtection)
|
||||
h.mux.HandleFunc("GET /api/v2/exports/{id}/download", h.handleDownloadHistoryExport)
|
||||
h.mux.HandleFunc("POST /api/v2/access/summary", h.handleAccessSummary)
|
||||
h.mux.HandleFunc("POST /api/v2/access/vehicles", h.handleAccessVehicles)
|
||||
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities", h.handleAccessUnresolvedIdentities)
|
||||
h.mux.HandleFunc("POST /api/v2/access/unresolved-identities/{id}/claim", h.handleClaimAccessIdentity)
|
||||
h.mux.HandleFunc("GET /api/v2/access/thresholds", h.handleAccessThresholds)
|
||||
h.mux.HandleFunc("PUT /api/v2/access/thresholds", h.handleUpdateAccessThresholds)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/summary", h.handleAlertSummary)
|
||||
@@ -86,11 +117,30 @@ func (h *Handler) routes() {
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/events/{id}", h.handleAlertEvent)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/events/{id}/actions", h.handleAlertAction)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules", h.handleAlertRules)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules/library", h.handleAlertRuleLibrary)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules", h.handleSaveAlertRule)
|
||||
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}", h.handleSaveAlertRule)
|
||||
h.mux.HandleFunc("PUT /api/v2/alerts/rules/{id}/enabled", h.handleAlertRuleEnabled)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/rules/{id}/revisions", h.handleAlertRuleRevisions)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/rollback", h.handleAlertRuleRollback)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/archive", h.handleArchiveAlertRule)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/rules/{id}/restore", h.handleRestoreAlertRule)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notification-config", h.handleAlertNotificationConfig)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications/health", h.handleAlertNotificationDeliveryHealth)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications", h.handleAlertNotifications)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/notifications/read", h.handleAlertNotificationsRead)
|
||||
h.mux.HandleFunc("POST /api/v2/alerts/notifications/{id}/retry", h.handleAlertNotificationRetry)
|
||||
h.mux.HandleFunc("GET /api/v2/alerts/notifications/{id}/retry-audit", h.handleAlertNotificationRetryAudit)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationConfig(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertNotificationConfig(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationDeliveryHealth(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertNotificationDeliveryHealth(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationSummary(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -107,6 +157,29 @@ func (h *Handler) handleReconciliationIssues(w http.ResponseWriter, r *http.Requ
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationAssignees(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ReconciliationAssignees(r.Context(), r.URL.Query().Get("search"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationIssuesExport(w http.ResponseWriter, r *http.Request) {
|
||||
var query ReconciliationQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
return
|
||||
}
|
||||
file, err := h.service.ExportReconciliationIssues(r.Context(), query)
|
||||
if err != nil {
|
||||
h.write(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="quality-issues.csv"; filename*=UTF-8''`+url.PathEscape(file.Name))
|
||||
w.Header().Set("X-Export-Name", url.PathEscape(file.Name))
|
||||
w.Header().Set("X-Export-Count", strconv.Itoa(file.RowCount))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(file.Content)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationIssue(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ReconciliationIssue(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
@@ -121,6 +194,67 @@ func (h *Handler) handleReconciliationAction(w http.ResponseWriter, r *http.Requ
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationAssignmentRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.AssignReconciliationIssue(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchAssignment(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationBatchAssignmentRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchAssignReconciliationIssues(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchAction(w http.ResponseWriter, r *http.Request) {
|
||||
var request ReconciliationBatchActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchUpdateReconciliationIssues(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationArchive(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationLifecycle(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationRestore(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationLifecycle(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationLifecycle(w http.ResponseWriter, r *http.Request, archived bool) {
|
||||
var request ReconciliationLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.SetReconciliationIssueArchived(r.Context(), r.PathValue("id"), archived, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchArchive(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationBatchLifecycle(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchRestore(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleReconciliationBatchLifecycle(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handler) handleReconciliationBatchLifecycle(w http.ResponseWriter, r *http.Request, archived bool) {
|
||||
var request ReconciliationBatchLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchSetReconciliationIssuesArchived(r.Context(), archived, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *http.Request) {
|
||||
var query AccessUnresolvedIdentityQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
@@ -130,6 +264,16 @@ func (h *Handler) handleAccessUnresolvedIdentities(w http.ResponseWriter, r *htt
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleClaimAccessIdentity(w http.ResponseWriter, r *http.Request) {
|
||||
var input AccessIdentityClaimInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
input.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.ClaimAccessIdentity(r.Context(), r.PathValue("id"), input)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleProfile(r.Context(), r.PathValue("vin"))
|
||||
h.write(w, r, data, err)
|
||||
@@ -224,6 +368,16 @@ func (h *Handler) handleAlertRules(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
query := AlertRuleQuery{
|
||||
Keyword: r.URL.Query().Get("keyword"), Status: r.URL.Query().Get("status"),
|
||||
Protocol: r.URL.Query().Get("protocol"), Lifecycle: r.URL.Query().Get("lifecycle"),
|
||||
Limit: parsePositive(r.URL.Query().Get("limit"), 10), Offset: parsePositive(r.URL.Query().Get("offset"), 0),
|
||||
}
|
||||
data, err := h.service.AlertRulePage(r.Context(), query)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleSaveAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var input AlertRuleInput
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
@@ -247,11 +401,52 @@ func (h *Handler) handleAlertRuleEnabled(w http.ResponseWriter, r *http.Request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.AlertRuleRevisions(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertRuleRollback(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleRollbackRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.RollbackAlertRule(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleArchiveAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.SetAlertRuleArchived(r.Context(), r.PathValue("id"), true, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRestoreAlertRule(w http.ResponseWriter, r *http.Request) {
|
||||
var request AlertRuleLifecycleRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
request.Actor = ActorFromContext(r.Context())
|
||||
data, err := h.service.SetAlertRuleArchived(r.Context(), r.PathValue("id"), false, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parsePositive(r.URL.Query().Get("limit"), 20)
|
||||
offset := parsePositive(r.URL.Query().Get("offset"), 0)
|
||||
unread := strings.EqualFold(r.URL.Query().Get("unreadOnly"), "true") || r.URL.Query().Get("unreadOnly") == "1"
|
||||
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{UnreadOnly: unread, Limit: limit, Offset: offset})
|
||||
data, err := h.service.AlertNotifications(r.Context(), AlertNotificationQuery{
|
||||
UnreadOnly: unread,
|
||||
Search: strings.TrimSpace(r.URL.Query().Get("search")),
|
||||
DeliveryStatus: strings.TrimSpace(r.URL.Query().Get("deliveryStatus")),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
})
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
@@ -265,6 +460,30 @@ func (h *Handler) handleAlertNotificationsRead(w http.ResponseWriter, r *http.Re
|
||||
h.write(w, r, map[string]int{"updated": count}, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationRetry(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.write(w, r, nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"})
|
||||
return
|
||||
}
|
||||
var request AlertNotificationRetryRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RetryAlertNotification(r.Context(), id, request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAlertNotificationRetryAudit(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.PathValue("id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
h.write(w, r, nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"})
|
||||
return
|
||||
}
|
||||
data, err := h.service.AlertNotificationRetryAudits(r.Context(), id)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleAccessSummary(w http.ResponseWriter, r *http.Request) {
|
||||
var query AccessQuery
|
||||
if !decodeJSONBody(w, r, &query) {
|
||||
@@ -324,6 +543,129 @@ func (h *Handler) handleListHistoryExports(w http.ResponseWriter, r *http.Reques
|
||||
h.write(w, r, h.service.ListHistoryExports(r.Context()), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) handleListHistoryExportsPage(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parsePositive(r.URL.Query().Get("limit"), 10)
|
||||
offset := parsePositive(r.URL.Query().Get("offset"), 0)
|
||||
h.write(w, r, h.service.ListHistoryExportsPage(r.Context(), HistoryExportQuery{
|
||||
Search: r.URL.Query().Get("search"), Status: r.URL.Query().Get("status"), Scope: r.URL.Query().Get("scope"), OwnerScope: r.URL.Query().Get("ownerScope"), Sort: r.URL.Query().Get("sort"), Limit: limit, Offset: offset,
|
||||
}), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) handlePreviewHistoryExportCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.PreviewHistoryExportCleanup(r.Context(), HistoryExportCleanupQuery{
|
||||
OlderThanDays: parsePositive(r.URL.Query().Get("olderThanDays"), 180),
|
||||
OwnerScope: r.URL.Query().Get("ownerScope"),
|
||||
})
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupAudit(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryExportCleanupAudit(r.Context(), parsePositive(r.URL.Query().Get("limit"), 20))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCleanupHistoryExports(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CleanupHistoryExports(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryExportCleanupAutomation(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationPolicyRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateHistoryExportCleanupAutomation(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleApproveHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ApproveHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRejectHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RejectHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRetryHistoryExportCleanupAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupAutomationActionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RetryHistoryExportCleanupAutomation(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.HistoryPreferences(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleUpdateHistoryPreferences(w http.ResponseWriter, r *http.Request) {
|
||||
var input HistoryPreferences
|
||||
if !decodeJSONBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateHistoryPreferences(r.Context(), input)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCancelHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.CancelHistoryExport(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRebuildHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.RebuildHistoryExport(r.Context(), r.PathValue("id"))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleArchiveHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.SetHistoryExportArchived(r.Context(), r.PathValue("id"), true)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleRestoreHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.SetHistoryExportArchived(r.Context(), r.PathValue("id"), false)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleHistoryExportCleanupProtection(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportCleanupProtectionRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.SetHistoryExportCleanupProtection(r.Context(), r.PathValue("id"), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleBatchHistoryExports(w http.ResponseWriter, r *http.Request) {
|
||||
var request HistoryExportBatchRequest
|
||||
if !decodeJSONBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.BatchHistoryExports(r.Context(), request)
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDownloadHistoryExport(w http.ResponseWriter, r *http.Request) {
|
||||
path, name, err := h.service.HistoryExportFile(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
@@ -401,6 +743,11 @@ func (h *Handler) handleVehicleCoverageSummary(w http.ResponseWriter, r *http.Re
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleBusinessFilters(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleBusinessFilters(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleVehicleServiceSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.VehicleServiceSummary(r.Context())
|
||||
h.write(w, r, data, err)
|
||||
@@ -487,6 +834,15 @@ func (h *Handler) handleDailyMileage(w http.ResponseWriter, r *http.Request) {
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleDailyMileagePost(w http.ResponseWriter, r *http.Request) {
|
||||
query, ok := decodeMileageQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.DailyMileage(r.Context(), mileageQueryValues(query))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.MileageSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
@@ -497,6 +853,62 @@ func (h *Handler) handleMileageStatistics(w http.ResponseWriter, r *http.Request
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) handleMileageStatisticsPost(w http.ResponseWriter, r *http.Request) {
|
||||
query, ok := decodeMileageQuery(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.MileageStatistics(r.Context(), mileageQueryValues(query))
|
||||
h.write(w, r, data, err)
|
||||
}
|
||||
|
||||
func decodeMileageQuery(w http.ResponseWriter, r *http.Request) (MileageQuery, bool) {
|
||||
defer r.Body.Close()
|
||||
var query MileageQuery
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&query); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "里程查询 JSON 解析失败", err.Error(), traceID(r))
|
||||
return MileageQuery{}, false
|
||||
}
|
||||
return query, true
|
||||
}
|
||||
|
||||
func mileageQueryValues(query MileageQuery) url.Values {
|
||||
values := url.Values{}
|
||||
if query.DateFrom != "" {
|
||||
values.Set("dateFrom", query.DateFrom)
|
||||
}
|
||||
if query.DateTo != "" {
|
||||
values.Set("dateTo", query.DateTo)
|
||||
}
|
||||
if query.Keyword != "" {
|
||||
values.Set("keyword", query.Keyword)
|
||||
}
|
||||
if len(query.VINs) > 0 {
|
||||
values.Set("vins", strings.Join(query.VINs, ","))
|
||||
}
|
||||
if query.VehicleScope != "" {
|
||||
values.Set("vehicleScope", query.VehicleScope)
|
||||
}
|
||||
if query.Protocol != "" {
|
||||
values.Set("protocol", query.Protocol)
|
||||
}
|
||||
if len(query.Protocols) > 0 {
|
||||
values.Set("protocols", strings.Join(query.Protocols, ","))
|
||||
}
|
||||
if query.Deduplicate {
|
||||
values.Set("deduplicate", "1")
|
||||
}
|
||||
if query.Limit > 0 {
|
||||
values.Set("limit", strconv.Itoa(query.Limit))
|
||||
}
|
||||
if query.Offset > 0 {
|
||||
values.Set("offset", strconv.Itoa(query.Offset))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (h *Handler) handleOnlineStatisticsSummary(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.OnlineStatisticsSummary(r.Context(), r.URL.Query())
|
||||
h.write(w, r, data, err)
|
||||
|
||||
@@ -29,6 +29,45 @@ func TestHandlerDashboardSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRetriesFailedNotificationAndReturnsPersistentAudit(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
principal := Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"}
|
||||
retry := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/notifications/10/retry", strings.NewReader(`{"expectedAttemptCount":2,"reason":"已确认短信网关恢复","idempotencyKey":"notification-retry-10-attempt-3"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), principal))
|
||||
handler.ServeHTTP(retry, request)
|
||||
if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `"attemptCount":3`) || !strings.Contains(retry.Body.String(), `"nextStatus":"reserved"`) {
|
||||
t.Fatalf("retry status=%d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
|
||||
audit := httptest.NewRecorder()
|
||||
auditRequest := httptest.NewRequest(http.MethodGet, "/api/v2/alerts/notifications/10/retry-audit", nil)
|
||||
auditRequest = auditRequest.WithContext(WithPrincipal(auditRequest.Context(), principal))
|
||||
handler.ServeHTTP(audit, auditRequest)
|
||||
if audit.Code != http.StatusOK || !strings.Contains(audit.Body.String(), `"reason":"已确认短信网关恢复"`) {
|
||||
t.Fatalf("audit status=%d body=%s", audit.Code, audit.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryCleanupAutomationExposesVersionedApprovalPolicy(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
handler := NewHandler(service)
|
||||
admin := WithPrincipal(context.Background(), Principal{SubjectID: "admin-1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
get := httptest.NewRecorder()
|
||||
handler.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/v2/exports/cleanup/automation", nil).WithContext(admin))
|
||||
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), `"revision":1`) || !strings.Contains(get.Body.String(), `"approvalWindowHours":48`) {
|
||||
t.Fatalf("automation status=%d body=%s", get.Code, get.Body.String())
|
||||
}
|
||||
|
||||
update := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/v2/exports/cleanup/automation", strings.NewReader(`{"enabled":true,"olderThanDays":180,"intervalDays":7,"approvalWindowHours":24,"expectedRevision":1}`)).WithContext(admin)
|
||||
handler.ServeHTTP(update, request)
|
||||
if update.Code != http.StatusOK || !strings.Contains(update.Body.String(), `"enabled":true`) || !strings.Contains(update.Body.String(), `"revision":2`) || !strings.Contains(update.Body.String(), `"status":"no_candidates"`) {
|
||||
t.Fatalf("automation update status=%d body=%s", update.Code, update.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
@@ -59,6 +98,111 @@ func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchReviewReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-actions", strings.NewReader(`{
|
||||
"items":[
|
||||
{"id":"reconciliation-demo-position","version":1},
|
||||
{"id":"missing-issue","version":1}
|
||||
],
|
||||
"status":"fixed",
|
||||
"note":"已核对定位设备与原始报文,修复结果通过复测"
|
||||
}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维乙", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch review status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"id":"reconciliation-demo-position"`, `"actor":"运维乙"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch review response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationAssignmentAndOwnerFilter(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/assignment", strings.NewReader(fmt.Sprintf(`{"version":1,"assignee":"定位运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"assignee":"定位运维组"`) || !strings.Contains(response.Body.String(), `"action":"assign"`) {
|
||||
t.Fatalf("assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","owner":"assigned","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"assignee":"定位运维组"`) {
|
||||
t.Fatalf("assigned filter status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchAssignmentReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
dueAt := time.Now().Add(8 * time.Hour).UTC().Format(time.RFC3339)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-assignments", strings.NewReader(fmt.Sprintf(`{"items":[{"id":"reconciliation-demo-position","version":1},{"id":"missing-issue","version":1}],"assignee":"夜班运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "值班主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"assignee":"夜班运维组"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch assignment response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationDirectoryAndFilteredExport(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
directory := httptest.NewRecorder()
|
||||
directoryRequest := httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/assignees?search=current", nil)
|
||||
directoryRequest = directoryRequest.WithContext(WithPrincipal(directoryRequest.Context(), Principal{Name: "Current Operator", Username: "current", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(directory, directoryRequest)
|
||||
if directory.Code != http.StatusOK || !strings.Contains(directory.Body.String(), `"current":true`) || !strings.Contains(directory.Body.String(), `"name":"Current Operator"`) {
|
||||
t.Fatalf("directory status=%d body=%s", directory.Code, directory.Body.String())
|
||||
}
|
||||
|
||||
exported := httptest.NewRecorder()
|
||||
handler.ServeHTTP(exported, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/export", strings.NewReader(`{"status":"active","owner":"unassigned"}`)))
|
||||
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "text/csv; charset=utf-8" || exported.Header().Get("X-Export-Count") != "1" || !strings.Contains(exported.Body.String(), "多来源实时位置漂移") {
|
||||
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationArchiveAndRestoreExposeAuditReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
admin := Principal{Name: "质量治理管理员", Username: "quality-admin", Role: "admin", UserType: "admin"}
|
||||
|
||||
archive := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/archive", strings.NewReader(`{"version":2,"reason":"已完成周期复核,转入审计归档"}`))
|
||||
handler.ServeHTTP(archive, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if archive.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", archive.Code, archive.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"archivedBy":"质量治理管理员"`, `"archiveReason":"已完成周期复核,转入审计归档"`, `"action":"archive"`, `"version":3`} {
|
||||
if !strings.Contains(archive.Body.String(), expected) {
|
||||
t.Fatalf("archive response missing %s: %s", expected, archive.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"scope":"archived","keyword":"周期复核","status":"all","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"id":"reconciliation-demo-source"`) {
|
||||
t.Fatalf("archive list status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
|
||||
restore := httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/restore", strings.NewReader(`{"version":3,"reason":"新的来源证据需要重新核对"}`))
|
||||
handler.ServeHTTP(restore, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if restore.Code != http.StatusOK || !strings.Contains(restore.Body.String(), `"action":"restore"`) || !strings.Contains(restore.Body.String(), `"archivedAt":""`) {
|
||||
t.Fatalf("restore status=%d body=%s", restore.Code, restore.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct {
|
||||
@@ -119,6 +263,29 @@ func TestHandlerV2AccessManagement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClaimsAccessIdentityWithAuthenticatedActor(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
claim := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities/mock-unresolved-jt808/claim", strings.NewReader(`{"vin":"LNXNEGRR7SR318212","note":"已核对来源注册信息与车辆档案"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "治理管理员", Role: "admin", UserType: "operator"}))
|
||||
handler.ServeHTTP(claim, request)
|
||||
if claim.Code != http.StatusOK {
|
||||
t.Fatalf("claim status=%d body=%s", claim.Code, claim.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"vin":"LNXNEGRR7SR318212"`, `"claimedBy":"治理管理员"`, `"profileComplete":false`, `"auditId":`} {
|
||||
if !strings.Contains(claim.Body.String(), expected) {
|
||||
t.Fatalf("claim response missing %s: %s", expected, claim.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
queue := httptest.NewRecorder()
|
||||
handler.ServeHTTP(queue, httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities", strings.NewReader(`{"limit":20}`)))
|
||||
if queue.Code != http.StatusOK || !strings.Contains(queue.Body.String(), `"total":0`) {
|
||||
t.Fatalf("claimed identity must leave unresolved queue: status=%d body=%s", queue.Code, queue.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2TrackPlayback(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -166,14 +333,46 @@ func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySeriesGrainAndBounds(t *testing.T) {
|
||||
func TestHandlerV2HistoryReturnsPerVehicleQueryReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,NO_HISTORY_VEHICLE&category=location&limit=10", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data HistoryDataResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode history response: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Data.Summary.RequestedVehicleCount != 2 || len(body.Data.Summary.Vehicles) != 2 {
|
||||
t.Fatalf("history query receipt must preserve both requested vehicles: %+v", body.Data.Summary)
|
||||
}
|
||||
if body.Data.Summary.Vehicles[0].Status != "matched" || body.Data.Summary.Vehicles[0].RowCount == 0 {
|
||||
t.Fatalf("known vehicle should expose matched row evidence: %+v", body.Data.Summary.Vehicles[0])
|
||||
}
|
||||
if body.Data.Summary.Vehicles[1].Keyword != "NO_HISTORY_VEHICLE" || body.Data.Summary.Vehicles[1].Status != "no_data" || body.Data.Summary.Vehicles[1].RowCount != 0 {
|
||||
t.Fatalf("empty vehicle should remain visible as a no-data receipt: %+v", body.Data.Summary.Vehicles[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryWindowGrainAndBounds(t *testing.T) {
|
||||
if got := historySeriesGrain(24*time.Hour, 240); got != 900 {
|
||||
t.Fatalf("24h / 240 should select the next safe nice bucket, got %d", got)
|
||||
}
|
||||
if got := historySeriesGrain(30*24*time.Hour, 240); got != 21600 {
|
||||
t.Fatalf("30d / 240 should use six-hour buckets, got %d", got)
|
||||
}
|
||||
if _, _, _, _, err := historySeriesWindow("2026-06-01", "2026-07-02", time.Now()); err != nil {
|
||||
t.Fatalf("an exact 31-day history window should remain valid: %v", err)
|
||||
}
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct{ path, code string }{
|
||||
{path: "/api/v2/history/series?category=raw&keyword=%E5%B7%9DAHTWO1", code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED"},
|
||||
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
|
||||
{path: "/api/v2/history/query?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
@@ -403,6 +602,21 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleDetail `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if !containsString(body.Data.Sources, "GB32960") || !containsString(body.Data.Sources, "JT808") {
|
||||
t.Fatalf("vehicle detail should expose protocols backed by observed data, got %+v", body.Data.Sources)
|
||||
}
|
||||
if containsString(body.Data.Sources, "YUTONG_MQTT") {
|
||||
t.Fatalf("vehicle detail must not expose an empty canonical slot as an available protocol, got %+v", body.Data.Sources)
|
||||
}
|
||||
if len(body.Data.SourceStatus) < len(canonicalVehicleProtocols) {
|
||||
t.Fatalf("diagnostic source status should retain canonical readiness slots, got %+v", body.Data.SourceStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleSourceEvidence(t *testing.T) {
|
||||
@@ -1129,6 +1343,47 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostCarriesLargeArrayQuery(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
handler := NewHandler(NewService(store))
|
||||
vins := make([]string, 250)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
body, err := json.Marshal(MileageQuery{
|
||||
DateFrom: "2026-07-01", DateTo: "2026-07-31", VINs: vins,
|
||||
Protocols: []string{"GB32960", "JT808"}, Deduplicate: true, Limit: 10_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := len(strings.Split(store.lastDailyMileageQuery.Get("vins"), ",")); got != len(vins) {
|
||||
t.Fatalf("daily mileage VIN count = %d, want %d", got, len(vins))
|
||||
}
|
||||
if got := store.lastMileageStatisticsQuery.Get("protocols"); got != "GB32960,JT808" {
|
||||
t.Fatalf("statistics protocols = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostRejectsUnknownJSONField(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/statistics/mileage", bytes.NewBufferString(`{"dateFrom":"2026-07-01","unknown":true}`))
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BAD_JSON") {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerQualityIncludesNoSourceVehicles(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
historyExportCleanupAutomationRunLimit = 100
|
||||
historyExportCleanupAutomationAttempts = 3
|
||||
)
|
||||
|
||||
var (
|
||||
allowedHistoryExportCleanupIntervals = map[int]bool{7: true, 14: true, 30: true}
|
||||
allowedHistoryExportCleanupApprovalWindows = map[int]bool{24: true, 48: true, 72: true}
|
||||
)
|
||||
|
||||
type historyExportCleanupAutomationFile struct {
|
||||
Policy HistoryExportCleanupAutomationPolicy `json:"policy"`
|
||||
Runs []HistoryExportCleanupAutomationRun `json:"runs"`
|
||||
}
|
||||
|
||||
type HistoryExportCleanupAutomationTick struct {
|
||||
LockAcquired bool `json:"lockAcquired"`
|
||||
CreatedRunID string `json:"createdRunId,omitempty"`
|
||||
ClaimedRunID string `json:"claimedRunId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func defaultHistoryExportCleanupAutomationPolicy() HistoryExportCleanupAutomationPolicy {
|
||||
return HistoryExportCleanupAutomationPolicy{
|
||||
OlderThanDays: 180,
|
||||
IntervalDays: 7,
|
||||
ApprovalWindowHours: 48,
|
||||
Revision: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) historyExportCleanupAutomationPath() string {
|
||||
return filepath.Join(s.exportDir, "cleanup-automation.json")
|
||||
}
|
||||
|
||||
func (s *Service) historyExportCleanupAutomationLockPath() string {
|
||||
return filepath.Join(s.exportDir, "cleanup-automation.lock")
|
||||
}
|
||||
|
||||
func (s *Service) acquireHistoryExportCleanupAutomationLock(nonBlocking bool) (*os.File, error) {
|
||||
if err := os.MkdirAll(s.exportDir, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lockFile, err := os.OpenFile(s.historyExportCleanupAutomationLockPath(), os.O_CREATE|os.O_RDWR, 0o640)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
operation := syscall.LOCK_EX
|
||||
if nonBlocking {
|
||||
operation |= syscall.LOCK_NB
|
||||
}
|
||||
if err := syscall.Flock(int(lockFile.Fd()), operation); err != nil {
|
||||
_ = lockFile.Close()
|
||||
return nil, err
|
||||
}
|
||||
return lockFile, nil
|
||||
}
|
||||
|
||||
func releaseHistoryExportCleanupAutomationLock(lockFile *os.File) {
|
||||
if lockFile == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN)
|
||||
_ = lockFile.Close()
|
||||
}
|
||||
|
||||
func (s *Service) loadHistoryExportCleanupAutomationLocked() (historyExportCleanupAutomationFile, error) {
|
||||
state := historyExportCleanupAutomationFile{Policy: defaultHistoryExportCleanupAutomationPolicy(), Runs: []HistoryExportCleanupAutomationRun{}}
|
||||
contents, err := os.ReadFile(s.historyExportCleanupAutomationPath())
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return state, nil
|
||||
}
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
if err := json.Unmarshal(contents, &state); err != nil {
|
||||
return historyExportCleanupAutomationFile{}, fmt.Errorf("decode cleanup automation state: %w", err)
|
||||
}
|
||||
if state.Policy.Revision <= 0 {
|
||||
state.Policy = defaultHistoryExportCleanupAutomationPolicy()
|
||||
}
|
||||
if state.Runs == nil {
|
||||
state.Runs = []HistoryExportCleanupAutomationRun{}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *Service) persistHistoryExportCleanupAutomationLocked(state historyExportCleanupAutomationFile) error {
|
||||
if len(state.Runs) > historyExportCleanupAutomationRunLimit {
|
||||
state.Runs = state.Runs[:historyExportCleanupAutomationRunLimit]
|
||||
}
|
||||
contents, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := s.historyExportCleanupAutomationPath() + ".tmp"
|
||||
if err := os.WriteFile(temporary, contents, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(temporary, s.historyExportCleanupAutomationPath())
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationState(state historyExportCleanupAutomationFile, now time.Time) HistoryExportCleanupAutomationState {
|
||||
return HistoryExportCleanupAutomationState{
|
||||
Policy: state.Policy,
|
||||
Runs: append([]HistoryExportCleanupAutomationRun{}, state.Runs...),
|
||||
ServerTime: now.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) HistoryExportCleanupAutomation(ctx context.Context) (HistoryExportCleanupAutomationState, error) {
|
||||
if _, err := historyExportCleanupPrincipal(ctx); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation state: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, time.Now().UTC()), nil
|
||||
}
|
||||
|
||||
func normalizedHistoryExportCleanupAutomationPolicy(request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationPolicyRequest, error) {
|
||||
if !allowedHistoryExportCleanupDays[request.OlderThanDays] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_WINDOW_INVALID", Message: "自动清理周期仅支持 30、90、180 或 365 天"}
|
||||
}
|
||||
if !allowedHistoryExportCleanupIntervals[request.IntervalDays] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_INTERVAL_INVALID", Message: "自动复核频率仅支持每 7、14 或 30 天"}
|
||||
}
|
||||
if !allowedHistoryExportCleanupApprovalWindows[request.ApprovalWindowHours] {
|
||||
return request, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_INVALID", Message: "审批窗口仅支持 24、48 或 72 小时"}
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateHistoryExportCleanupAutomation(ctx context.Context, request HistoryExportCleanupAutomationPolicyRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
principal, err := historyExportCleanupPrincipal(ctx)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
request, err = normalizedHistoryExportCleanupAutomationPolicy(request)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation policy: %w", err)
|
||||
}
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
if request.ExpectedRevision != state.Policy.Revision {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REVISION_CONFLICT", Message: "自动清理策略已被其他管理员修改,请刷新后重试"}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
changed := state.Policy.Enabled != request.Enabled ||
|
||||
state.Policy.OlderThanDays != request.OlderThanDays ||
|
||||
state.Policy.IntervalDays != request.IntervalDays ||
|
||||
state.Policy.ApprovalWindowHours != request.ApprovalWindowHours
|
||||
state.Policy.Enabled = request.Enabled
|
||||
state.Policy.OlderThanDays = request.OlderThanDays
|
||||
state.Policy.IntervalDays = request.IntervalDays
|
||||
state.Policy.ApprovalWindowHours = request.ApprovalWindowHours
|
||||
state.Policy.Revision++
|
||||
state.Policy.UpdatedAt = now.Format(time.RFC3339)
|
||||
state.Policy.UpdatedBy = firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin")
|
||||
if request.Enabled && changed {
|
||||
state.Policy.NextReviewAt = now.Format(time.RFC3339)
|
||||
}
|
||||
if !request.Enabled {
|
||||
state.Policy.NextReviewAt = ""
|
||||
}
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("persist cleanup automation policy: %w", err)
|
||||
}
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
if request.Enabled {
|
||||
_, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, "policy-update", 5*time.Minute)
|
||||
}
|
||||
return s.HistoryExportCleanupAutomation(ctx)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRunID(now time.Time) string {
|
||||
if identifier, err := randomExportID(); err == nil {
|
||||
return strings.Replace(identifier, "exp_", "cleanup_run_", 1)
|
||||
}
|
||||
return "cleanup_run_" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRunFromPreview(policy HistoryExportCleanupAutomationPolicy, preview HistoryExportCleanupPreview, now time.Time) HistoryExportCleanupAutomationRun {
|
||||
status := "awaiting_approval"
|
||||
if preview.PlannedCount == 0 {
|
||||
status = "no_candidates"
|
||||
}
|
||||
run := HistoryExportCleanupAutomationRun{
|
||||
ID: historyExportCleanupAutomationRunID(now), Status: status, Revision: 1, PolicyRevision: policy.Revision,
|
||||
OlderThanDays: preview.OlderThanDays, Cutoff: preview.Cutoff, CandidateCount: preview.CandidateCount,
|
||||
PlannedCount: preview.PlannedCount, ProtectedCount: preview.ProtectedCount, FileCount: preview.FileCount,
|
||||
FileSizeBytes: preview.FileSizeBytes, PreviewToken: preview.PreviewToken, CreatedAt: now.Format(time.RFC3339),
|
||||
ApprovalDeadline: now.Add(time.Duration(policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339),
|
||||
MaxAttempts: historyExportCleanupAutomationAttempts, LastActionAt: now.Format(time.RFC3339), LastActionBy: "scheduler",
|
||||
}
|
||||
if status == "no_candidates" {
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.LastActionReason = "本轮没有符合策略且未受保护的归档任务"
|
||||
}
|
||||
return run
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationNextReview(policy HistoryExportCleanupAutomationPolicy, now time.Time) string {
|
||||
next := now.Add(time.Duration(policy.IntervalDays) * 24 * time.Hour)
|
||||
return next.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationDue(value string, now time.Time) bool {
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
return err != nil || !parsed.After(now)
|
||||
}
|
||||
|
||||
func historyExportCleanupAutomationRefreshExpired(state *historyExportCleanupAutomationFile, now time.Time) {
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.Status == "awaiting_approval" || run.Status == "needs_review" {
|
||||
if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) {
|
||||
run.Status = "expired"
|
||||
run.Revision++
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = "scheduler"
|
||||
run.LastActionReason = "审批窗口已结束,未执行任何清理"
|
||||
}
|
||||
}
|
||||
if run.Status == "running" && historyExportCleanupAutomationDue(run.LeaseExpiresAt, now) {
|
||||
run.Status = "failed"
|
||||
run.Revision++
|
||||
run.Error = "执行实例租约已过期,本轮等待安全恢复"
|
||||
run.LeaseOwner = ""
|
||||
run.LeaseExpiresAt = ""
|
||||
if run.AttemptCount < run.MaxAttempts {
|
||||
run.NextRetryAt = now.Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) automationPreview(now time.Time, olderThanDays int) HistoryExportCleanupPreview {
|
||||
principal := Principal{SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler", Role: "admin", UserType: "admin", AuthProvider: "system"}
|
||||
s.exportsMu.Lock()
|
||||
defer s.exportsMu.Unlock()
|
||||
if s.refreshHistoryExportAvailabilityLocked(now) {
|
||||
_ = s.persistHistoryExportsLocked()
|
||||
}
|
||||
preview, _ := s.historyExportCleanupPreviewLocked(principal, HistoryExportCleanupQuery{OlderThanDays: olderThanDays, OwnerScope: "all"}, now)
|
||||
return preview
|
||||
}
|
||||
|
||||
func (s *Service) claimHistoryExportCleanupAutomationRun(state *historyExportCleanupAutomationFile, workerID string, lease time.Duration, now time.Time) *HistoryExportCleanupAutomationRun {
|
||||
for index := len(state.Runs) - 1; index >= 0; index-- {
|
||||
run := &state.Runs[index]
|
||||
runnable := run.Status == "approved" ||
|
||||
run.Status == "failed" && run.AttemptCount < run.MaxAttempts && historyExportCleanupAutomationDue(run.NextRetryAt, now)
|
||||
if !runnable {
|
||||
continue
|
||||
}
|
||||
run.Status = "running"
|
||||
run.Revision++
|
||||
run.AttemptCount++
|
||||
run.StartedAt = now.Format(time.RFC3339)
|
||||
run.LeaseOwner = workerID + ":" + strconv.FormatInt(now.UnixNano(), 10)
|
||||
run.LeaseExpiresAt = now.Add(lease).Format(time.RFC3339)
|
||||
run.NextRetryAt = ""
|
||||
run.Error = ""
|
||||
copy := *run
|
||||
return ©
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) RunHistoryExportCleanupAutomationOnce(ctx context.Context, workerID string, lease time.Duration) (HistoryExportCleanupAutomationTick, error) {
|
||||
if strings.TrimSpace(workerID) == "" {
|
||||
workerID = "cleanup-scheduler"
|
||||
}
|
||||
if lease < time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(true)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) {
|
||||
return HistoryExportCleanupAutomationTick{LockAcquired: false}, nil
|
||||
}
|
||||
return HistoryExportCleanupAutomationTick{}, fmt.Errorf("lock cleanup automation tick: %w", err)
|
||||
}
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationTick{}, err
|
||||
}
|
||||
historyExportCleanupAutomationRefreshExpired(&state, now)
|
||||
tick := HistoryExportCleanupAutomationTick{LockAcquired: true}
|
||||
if state.Policy.Enabled && historyExportCleanupAutomationDue(state.Policy.NextReviewAt, now) {
|
||||
preview := s.automationPreview(now, state.Policy.OlderThanDays)
|
||||
run := historyExportCleanupAutomationRunFromPreview(state.Policy, preview, now)
|
||||
state.Runs = append([]HistoryExportCleanupAutomationRun{run}, state.Runs...)
|
||||
state.Policy.NextReviewAt = historyExportCleanupAutomationNextReview(state.Policy, now)
|
||||
tick.CreatedRunID = run.ID
|
||||
tick.Status = run.Status
|
||||
}
|
||||
claim := s.claimHistoryExportCleanupAutomationRun(&state, workerID, lease, now)
|
||||
if claim != nil {
|
||||
tick.ClaimedRunID = claim.ID
|
||||
tick.Status = claim.Status
|
||||
}
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
return HistoryExportCleanupAutomationTick{}, fmt.Errorf("persist cleanup automation tick: %w", err)
|
||||
}
|
||||
releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
if claim == nil {
|
||||
return tick, nil
|
||||
}
|
||||
|
||||
schedulerContext := WithPrincipal(ctx, Principal{
|
||||
SubjectID: "cleanup-scheduler", Name: "归档清理调度器", Username: "cleanup-scheduler",
|
||||
Role: "admin", UserType: "admin", AuthProvider: "system",
|
||||
})
|
||||
result, executionErr := s.CleanupHistoryExports(schedulerContext, HistoryExportCleanupRequest{
|
||||
OlderThanDays: claim.OlderThanDays, OwnerScope: "all", PreviewToken: claim.PreviewToken,
|
||||
})
|
||||
if err := s.finishHistoryExportCleanupAutomationRun(claim, result, executionErr); err != nil {
|
||||
return tick, err
|
||||
}
|
||||
return tick, executionErr
|
||||
}
|
||||
|
||||
func (s *Service) finishHistoryExportCleanupAutomationRun(claim *HistoryExportCleanupAutomationRun, result HistoryExportCleanupResult, executionErr error) error {
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lock cleanup automation completion: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.ID != claim.ID || run.Status != "running" || run.LeaseOwner != claim.LeaseOwner {
|
||||
continue
|
||||
}
|
||||
run.Revision++
|
||||
run.LeaseOwner = ""
|
||||
run.LeaseExpiresAt = ""
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = "scheduler"
|
||||
if executionErr == nil {
|
||||
run.Status = "completed"
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
run.CleanupRecordID = result.Record.ID
|
||||
run.CleanedCount = result.Record.Cleaned
|
||||
run.LastActionReason = "审批后的清理批次已完成"
|
||||
} else if clientErr, ok := asClientError(executionErr); ok && clientErr.Code == "EXPORT_CLEANUP_PREVIEW_STALE" {
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
run.Status = "needs_review"
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339)
|
||||
run.ApprovedAt = ""
|
||||
run.ApprovedBy = ""
|
||||
run.ApprovalReason = ""
|
||||
run.AttemptCount = 0
|
||||
run.Error = "候选范围已变化,需要按最新影响重新审批"
|
||||
run.LastActionReason = run.Error
|
||||
} else {
|
||||
run.Status = "failed"
|
||||
run.Error = executionErr.Error()
|
||||
run.LastActionReason = "执行失败,保留审批与候选证据"
|
||||
if run.AttemptCount < run.MaxAttempts {
|
||||
delay := time.Duration(1<<min(run.AttemptCount-1, 4)) * time.Minute
|
||||
run.NextRetryAt = now.Add(delay).Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return s.persistHistoryExportCleanupAutomationLocked(state)
|
||||
}
|
||||
|
||||
func (s *Service) mutateHistoryExportCleanupAutomationRun(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest, action string) (HistoryExportCleanupAutomationState, error) {
|
||||
principal, err := historyExportCleanupPrincipal(ctx)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
reason := truncateHistoryPreferenceText(request.Reason, 160)
|
||||
if reason == "" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_REASON_REQUIRED", Message: "审批、驳回或恢复执行都需要填写原因"}
|
||||
}
|
||||
lockFile, err := s.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, fmt.Errorf("lock cleanup automation action: %w", err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
state, err := s.loadHistoryExportCleanupAutomationLocked()
|
||||
if err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "admin")
|
||||
for index := range state.Runs {
|
||||
run := &state.Runs[index]
|
||||
if run.ID != strings.TrimSpace(id) {
|
||||
continue
|
||||
}
|
||||
if run.Revision != request.ExpectedRevision {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_RUN_CONFLICT", Message: "清理审批已经变化,请刷新后重试"}
|
||||
}
|
||||
switch action {
|
||||
case "approve":
|
||||
if run.Status != "awaiting_approval" && run.Status != "needs_review" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_APPROVABLE", Message: "当前清理批次不在待审批状态"}
|
||||
}
|
||||
if historyExportCleanupAutomationDue(run.ApprovalDeadline, now) {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_APPROVAL_EXPIRED", Message: "审批窗口已结束,系统不会执行该批次"}
|
||||
}
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
if preview.PreviewToken != run.PreviewToken {
|
||||
run.Status = "needs_review"
|
||||
run.Revision++
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.ApprovalDeadline = now.Add(time.Duration(state.Policy.ApprovalWindowHours) * time.Hour).Format(time.RFC3339)
|
||||
run.Error = "候选范围已变化,请按最新影响再次确认"
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = actor
|
||||
run.LastActionReason = reason
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, now), nil
|
||||
}
|
||||
run.Status = "approved"
|
||||
run.ApprovedAt = now.Format(time.RFC3339)
|
||||
run.ApprovedBy = actor
|
||||
run.ApprovalReason = reason
|
||||
run.Error = ""
|
||||
case "reject":
|
||||
if run.Status != "awaiting_approval" && run.Status != "needs_review" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_REJECTABLE", Message: "当前清理批次不在待审批状态"}
|
||||
}
|
||||
run.Status = "rejected"
|
||||
run.RejectedAt = now.Format(time.RFC3339)
|
||||
run.RejectedBy = actor
|
||||
run.RejectionReason = reason
|
||||
run.CompletedAt = now.Format(time.RFC3339)
|
||||
case "retry":
|
||||
if run.Status != "failed" {
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_NOT_RETRYABLE", Message: "只有失败批次可以人工恢复执行"}
|
||||
}
|
||||
preview := s.automationPreview(now, run.OlderThanDays)
|
||||
run.Cutoff = preview.Cutoff
|
||||
run.CandidateCount = preview.CandidateCount
|
||||
run.PlannedCount = preview.PlannedCount
|
||||
run.ProtectedCount = preview.ProtectedCount
|
||||
run.FileCount = preview.FileCount
|
||||
run.FileSizeBytes = preview.FileSizeBytes
|
||||
run.PreviewToken = preview.PreviewToken
|
||||
run.Status = "approved"
|
||||
run.ApprovedAt = now.Format(time.RFC3339)
|
||||
run.ApprovedBy = actor
|
||||
run.ApprovalReason = reason
|
||||
run.AttemptCount = 0
|
||||
run.NextRetryAt = ""
|
||||
run.Error = ""
|
||||
default:
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_ACTION_INVALID", Message: "不支持的自动清理操作"}
|
||||
}
|
||||
run.Revision++
|
||||
run.LastActionAt = now.Format(time.RFC3339)
|
||||
run.LastActionBy = actor
|
||||
run.LastActionReason = reason
|
||||
if err := s.persistHistoryExportCleanupAutomationLocked(state); err != nil {
|
||||
return HistoryExportCleanupAutomationState{}, err
|
||||
}
|
||||
return historyExportCleanupAutomationState(state, now), nil
|
||||
}
|
||||
return HistoryExportCleanupAutomationState{}, clientError{Code: "EXPORT_CLEANUP_AUTOMATION_RUN_NOT_FOUND", Message: "自动清理批次不存在"}
|
||||
}
|
||||
|
||||
func (s *Service) ApproveHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "approve")
|
||||
}
|
||||
|
||||
func (s *Service) RejectHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "reject")
|
||||
}
|
||||
|
||||
func (s *Service) RetryHistoryExportCleanupAutomation(ctx context.Context, id string, request HistoryExportCleanupAutomationActionRequest) (HistoryExportCleanupAutomationState, error) {
|
||||
return s.mutateHistoryExportCleanupAutomationRun(ctx, id, request, "retry")
|
||||
}
|
||||
|
||||
func (s *Service) StartHistoryExportCleanupAutomation(ctx context.Context) {
|
||||
if !s.runtime.HistoryCleanupAutomation {
|
||||
return
|
||||
}
|
||||
poll := s.runtime.HistoryCleanupPoll
|
||||
if poll < 5*time.Second {
|
||||
poll = time.Minute
|
||||
}
|
||||
if poll > time.Hour {
|
||||
poll = time.Hour
|
||||
}
|
||||
lease := s.runtime.HistoryCleanupLease
|
||||
if lease < time.Minute {
|
||||
lease = 5 * time.Minute
|
||||
}
|
||||
workerID := firstNonEmpty(strings.TrimSpace(s.runtime.HistoryCleanupWorkerID), "cleanup-scheduler")
|
||||
go func() {
|
||||
ticker := time.NewTicker(poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
_, _ = s.RunHistoryExportCleanupAutomationOnce(ctx, workerID, lease)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func addAutomationCleanupCandidate(t *testing.T, service *Service, id string, archivedAt time.Time) {
|
||||
t.Helper()
|
||||
service.exportsMu.Lock()
|
||||
defer service.exportsMu.Unlock()
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: "自动清理候选 " + id, Status: "expired", Format: "csv", Category: "raw",
|
||||
OwnerID: "owner-1", OwnerUsername: "customer-a", CreatedAt: archivedAt.Add(-24 * time.Hour).Format(time.RFC3339),
|
||||
UpdatedAt: archivedAt.Format(time.RFC3339), ArchivedAt: archivedAt.Format(time.RFC3339),
|
||||
RetentionDays: 7, Evidence: "自动清理测试归档",
|
||||
}
|
||||
if err := service.persistHistoryExportsLocked(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationRequiresApprovalAndCompletes(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
addAutomationCleanupCandidate(t, service, "automation-old", time.Now().UTC().Add(-200*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
|
||||
state, err := service.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 180, IntervalDays: 7, ApprovalWindowHours: 48, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Policy.Enabled || len(state.Runs) != 1 || state.Runs[0].Status != "awaiting_approval" || state.Runs[0].PlannedCount != 1 {
|
||||
t.Fatalf("saving an enabled policy should schedule a gated review: %+v", state)
|
||||
}
|
||||
run := state.Runs[0]
|
||||
state, err = service.ApproveHistoryExportCleanupAutomation(ctx, run.ID, HistoryExportCleanupAutomationActionRequest{
|
||||
ExpectedRevision: run.Revision, Reason: "已核对候选、保护例外和文件影响,同意本批执行",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Runs[0].Status != "approved" || state.Runs[0].ApprovedBy != "admin" {
|
||||
t.Fatalf("approval evidence missing: %+v", state.Runs[0])
|
||||
}
|
||||
tick, err := service.RunHistoryExportCleanupAutomationOnce(ctx, "scheduler-a", 5*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tick.ClaimedRunID != run.ID {
|
||||
t.Fatalf("approved run was not claimed: %+v", tick)
|
||||
}
|
||||
state, err = service.HistoryExportCleanupAutomation(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Runs[0].Status != "completed" || state.Runs[0].CleanedCount != 1 || state.Runs[0].CleanupRecordID == "" || state.Runs[0].AttemptCount != 1 {
|
||||
t.Fatalf("completed run should retain execution evidence: %+v", state.Runs[0])
|
||||
}
|
||||
if _, exists := service.historyExportJob("automation-old"); exists {
|
||||
t.Fatal("approved automation run should remove the planned archive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationRefreshesChangedScopeBeforeApproval(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
now := time.Now().UTC()
|
||||
addAutomationCleanupCandidate(t, service, "automation-first", now.Add(-200*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
state, err := service.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 180, IntervalDays: 7, ApprovalWindowHours: 24, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := state.Runs[0]
|
||||
addAutomationCleanupCandidate(t, service, "automation-late", now.Add(-190*24*time.Hour))
|
||||
|
||||
state, err = service.ApproveHistoryExportCleanupAutomation(ctx, run.ID, HistoryExportCleanupAutomationActionRequest{
|
||||
ExpectedRevision: run.Revision, Reason: "审批前复核自动清理影响",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refreshed := state.Runs[0]
|
||||
if refreshed.Status != "needs_review" || refreshed.PlannedCount != 2 || refreshed.PreviewToken == run.PreviewToken || refreshed.ApprovedAt != "" {
|
||||
t.Fatalf("changed candidates must force a fresh approval: before=%+v after=%+v", run, refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationUsesCrossProcessFileLock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
second := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
lockFile, err := first.acquireHistoryExportCleanupAutomationLock(false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer releaseHistoryExportCleanupAutomationLock(lockFile)
|
||||
tick, err := second.RunHistoryExportCleanupAutomationOnce(exportAdminContext(), "scheduler-b", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tick.LockAcquired {
|
||||
t.Fatalf("second scheduler instance must not enter a locked tick: %+v", tick)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupAutomationStateSurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
addAutomationCleanupCandidate(t, first, "automation-persisted", time.Now().UTC().Add(-365*24*time.Hour))
|
||||
ctx := exportAdminContext()
|
||||
state, err := first.UpdateHistoryExportCleanupAutomation(ctx, HistoryExportCleanupAutomationPolicyRequest{
|
||||
Enabled: true, OlderThanDays: 365, IntervalDays: 14, ApprovalWindowHours: 72, ExpectedRevision: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "cleanup-automation.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloaded, err := second.HistoryExportCleanupAutomation(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reloaded.Policy.Revision != state.Policy.Revision || len(reloaded.Runs) != 1 || reloaded.Runs[0].ID != state.Runs[0].ID {
|
||||
t.Fatalf("automation state did not survive restart: first=%+v reloaded=%+v", state, reloaded)
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,29 @@ import (
|
||||
)
|
||||
|
||||
type MockStore struct {
|
||||
vehicles []VehicleRow
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
alertMu sync.RWMutex
|
||||
alertRules []AlertRule
|
||||
alertEvents []AlertEvent
|
||||
alertNotifications []AlertNotification
|
||||
nextAlertActionID int64
|
||||
nextNotificationID int64
|
||||
reconciliationMu sync.RWMutex
|
||||
reconciliationIssues []ReconciliationIssue
|
||||
vehicles []VehicleRow
|
||||
locations []RealtimeLocationRow
|
||||
accessMu sync.RWMutex
|
||||
accessThresholds AccessThresholdConfig
|
||||
accessIdentities []AccessUnresolvedIdentity
|
||||
sourcePolicyMu sync.RWMutex
|
||||
sourcePolicies map[string]VehicleSourcePolicyConfig
|
||||
sourceProviders map[string]string
|
||||
sourcePolicyRemarks map[string]string
|
||||
profileMu sync.RWMutex
|
||||
profiles map[string]VehicleProfile
|
||||
businessRelations map[string]VehicleBusinessRelation
|
||||
alertMu sync.RWMutex
|
||||
alertRules []AlertRule
|
||||
alertRuleRevisions map[string][]AlertRuleRevision
|
||||
alertEvents []AlertEvent
|
||||
alertNotifications []AlertNotification
|
||||
alertNotificationRetryAudits []AlertNotificationRetryAudit
|
||||
nextAlertActionID int64
|
||||
nextNotificationID int64
|
||||
nextNotificationRetryAuditID int64
|
||||
reconciliationMu sync.RWMutex
|
||||
reconciliationIssues []ReconciliationIssue
|
||||
}
|
||||
|
||||
func NewMockStore() *MockStore {
|
||||
@@ -40,14 +45,47 @@ func NewMockStore() *MockStore {
|
||||
{VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88},
|
||||
}
|
||||
store := &MockStore{
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
vehicles: vehicles,
|
||||
accessThresholds: defaultAccessThresholds(time.Now()),
|
||||
accessIdentities: []AccessUnresolvedIdentity{{
|
||||
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
|
||||
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
|
||||
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
|
||||
}},
|
||||
sourcePolicies: map[string]VehicleSourcePolicyConfig{},
|
||||
sourceProviders: map[string]string{},
|
||||
sourcePolicyRemarks: map[string]string{},
|
||||
profiles: map[string]VehicleProfile{
|
||||
"LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", BrandName: "飞驰", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"},
|
||||
},
|
||||
businessRelations: map[string]VehicleBusinessRelation{
|
||||
"LB9A32A24R0LS1426": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10001",
|
||||
VIN: "LB9A32A24R0LS1426", PlateNumber: "粤AG18312",
|
||||
CustomerID: "20001", CustomerName: "岭牛示范客户", ContractID: "30001", ContractCode: "HT-2026-001",
|
||||
ProjectName: "氢能物流示范项目", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50001", ResponsibleUserName: "示范负责人", OperationStatus: "运营中",
|
||||
ScopeStartAt: "2026-03-01 08:00:00", SourceUpdatedAt: "2026-07-24 08:00:00", PublishedAt: "2026-07-24 08:02:00",
|
||||
},
|
||||
"LNXNEGRR7SR318212": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10002",
|
||||
VIN: "LNXNEGRR7SR318212", PlateNumber: "川AHTWO1",
|
||||
CustomerID: "20002", CustomerName: "成都氢运客户", DepartmentID: "40002", DepartmentName: "业务二部",
|
||||
ResponsibleUserID: "50002", ResponsibleUserName: "李业务", OperationStatus: "运营中",
|
||||
},
|
||||
"LMRKH9AC2R1004087": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10003",
|
||||
VIN: "LMRKH9AC2R1004087", PlateNumber: "豫A88888",
|
||||
CustomerID: "20003", CustomerName: "中原物流客户", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50003", ResponsibleUserName: "王业务", OperationStatus: "待交付",
|
||||
},
|
||||
"LB9A32A24P0LS1230": {
|
||||
SourceSystem: "oneos", SourceVersion: "oneos-v1:demo", VehicleID: "10004",
|
||||
VIN: "LB9A32A24P0LS1230", PlateNumber: "粤AFF7936",
|
||||
CustomerID: "20001", CustomerName: "岭牛示范客户", DepartmentID: "40001", DepartmentName: "华南运营部",
|
||||
ResponsibleUserID: "50001", ResponsibleUserName: "示范负责人", OperationStatus: "已停运",
|
||||
},
|
||||
},
|
||||
locations: []RealtimeLocationRow{
|
||||
{VIN: vehicles[0].VIN, Plate: vehicles[0].Plate, Protocol: vehicles[0].Protocol, Longitude: 113.2644, Latitude: 23.1291, SpeedKmh: 42.5, SOCPercent: 76.2, TotalMileageKm: 48798.9, LastSeen: vehicles[0].LastSeen},
|
||||
{VIN: vehicles[1].VIN, Plate: vehicles[1].Plate, Protocol: vehicles[1].Protocol, Longitude: 104.0668, Latitude: 30.5728, SpeedKmh: 18.3, SOCPercent: 64.8, TotalMileageKm: 119925, LastSeen: vehicles[1].LastSeen},
|
||||
@@ -106,6 +144,11 @@ func (m *MockStore) ReconciliationSummary(_ context.Context, _ int) (Reconciliat
|
||||
rules := map[string]int{}
|
||||
severities := map[string]int{}
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if item.ArchivedAt != "" {
|
||||
result.Archived++
|
||||
continue
|
||||
}
|
||||
result.Current++
|
||||
switch item.Status {
|
||||
case "pending":
|
||||
result.Active++
|
||||
@@ -137,7 +180,13 @@ 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.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary), strings.ToLower(query.Keyword)) {
|
||||
if query.Scope == "archived" && item.ArchivedAt == "" {
|
||||
continue
|
||||
}
|
||||
if query.Scope != "archived" && item.ArchivedAt != "" {
|
||||
continue
|
||||
}
|
||||
if query.Keyword != "" && !strings.Contains(strings.ToLower(item.VIN+" "+item.Plate+" "+item.Title+" "+item.Summary+" "+item.ArchivedBy+" "+item.ArchiveReason), strings.ToLower(query.Keyword)) {
|
||||
continue
|
||||
}
|
||||
if query.RuleCode != "" && query.RuleCode != "all" && item.RuleCode != query.RuleCode {
|
||||
@@ -155,6 +204,27 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
if query.Status != "" && query.Status != "all" && query.Status != "active" && item.Status != query.Status {
|
||||
continue
|
||||
}
|
||||
if query.Owner == "unassigned" && item.Assignee != "" {
|
||||
continue
|
||||
}
|
||||
if query.Owner == "assigned" && item.Assignee == "" {
|
||||
continue
|
||||
}
|
||||
if query.Owner != "" && query.Owner != "all" && query.Owner != "unassigned" && query.Owner != "assigned" && item.Assignee != query.Owner {
|
||||
continue
|
||||
}
|
||||
if query.SLA == "overdue" && (item.DueAt == "" || item.DueAt >= time.Now().Format("2006-01-02 15:04:05")) {
|
||||
continue
|
||||
}
|
||||
if query.SLA == "due_soon" {
|
||||
if item.DueAt == "" {
|
||||
continue
|
||||
}
|
||||
due, _ := time.Parse("2006-01-02 15:04:05", item.DueAt)
|
||||
if due.Before(time.Now()) || due.After(time.Now().Add(8*time.Hour)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
cloned := cloneReconciliationIssue(item)
|
||||
cloned.Actions = nil
|
||||
items = append(items, cloned)
|
||||
@@ -169,6 +239,28 @@ func (m *MockStore) ReconciliationIssues(_ context.Context, query Reconciliation
|
||||
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ReconciliationAssignees(_ context.Context) ([]ReconciliationAssignee, error) {
|
||||
m.reconciliationMu.RLock()
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
counts := map[string]int{}
|
||||
for _, item := range m.reconciliationIssues {
|
||||
if item.ArchivedAt == "" && item.Assignee != "" && (item.Status == "pending" || strings.HasPrefix(item.Status, "confirmed_source_")) {
|
||||
counts[item.Assignee]++
|
||||
}
|
||||
}
|
||||
items := make([]ReconciliationAssignee, 0, len(counts))
|
||||
for name, count := range counts {
|
||||
items = append(items, ReconciliationAssignee{Name: name, Source: "history", ActiveCount: count})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ActiveCount != items[j].ActiveCount {
|
||||
return items[i].ActiveCount > items[j].ActiveCount
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ReconciliationIssue(_ context.Context, id string) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.RLock()
|
||||
defer m.reconciliationMu.RUnlock()
|
||||
@@ -191,6 +283,9 @@ func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, requ
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if item.ArchivedAt != "" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
from := item.Status
|
||||
item.Status = request.Status
|
||||
item.ResolutionNote = request.Note
|
||||
@@ -210,6 +305,80 @@ func (m *MockStore) UpdateReconciliationIssue(_ context.Context, id string, requ
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) AssignReconciliationIssue(_ context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.Lock()
|
||||
defer m.reconciliationMu.Unlock()
|
||||
due, err := time.Parse(time.RFC3339, request.DueAt)
|
||||
if strings.TrimSpace(request.Assignee) == "" || err != nil {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_INVALID", Message: "负责人或处理期限无效"}
|
||||
}
|
||||
for index := range m.reconciliationIssues {
|
||||
item := &m.reconciliationIssues[index]
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if item.ArchivedAt != "" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
if item.Status != "pending" && !strings.HasPrefix(item.Status, "confirmed_source_") {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"}
|
||||
}
|
||||
item.Assignee = strings.TrimSpace(request.Assignee)
|
||||
item.AssignedBy = request.Actor
|
||||
item.AssignedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
item.DueAt = due.UTC().Format("2006-01-02 15:04:05")
|
||||
item.Version++
|
||||
item.Actions = append(item.Actions, ReconciliationAction{ID: int64(len(item.Actions) + 1), Action: "assign", Actor: request.Actor, Note: "交接给 " + item.Assignee, CreatedAt: item.AssignedAt})
|
||||
return cloneReconciliationIssue(*item), nil
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) SetReconciliationIssueArchived(_ context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
m.reconciliationMu.Lock()
|
||||
defer m.reconciliationMu.Unlock()
|
||||
for index := range m.reconciliationIssues {
|
||||
item := &m.reconciliationIssues[index]
|
||||
if item.ID != id {
|
||||
continue
|
||||
}
|
||||
if item.Version != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
currentlyArchived := item.ArchivedAt != ""
|
||||
if currentlyArchived == archived {
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"}
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"}
|
||||
}
|
||||
action := "restore"
|
||||
if archived {
|
||||
if item.Status != "fixed" && item.Status != "no_action" && item.Status != "recovered" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"}
|
||||
}
|
||||
action = "archive"
|
||||
item.ArchivedAt = time.Now().Format("2006-01-02 15:04:05")
|
||||
item.ArchivedBy = request.Actor
|
||||
item.ArchiveReason = request.Reason
|
||||
} else {
|
||||
item.ArchivedAt = ""
|
||||
item.ArchivedBy = ""
|
||||
item.ArchiveReason = ""
|
||||
}
|
||||
item.Version++
|
||||
item.Actions = append(item.Actions, ReconciliationAction{
|
||||
ID: int64(len(item.Actions) + 1), Action: action, FromStatus: item.Status, ToStatus: item.Status,
|
||||
Actor: request.Actor, Note: request.Reason, CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
return cloneReconciliationIssue(*item), nil
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
|
||||
func (m *MockStore) EvaluateReconciliation(_ context.Context) (ReconciliationEvaluationResult, error) {
|
||||
summary, _ := m.ReconciliationSummary(context.Background(), 30)
|
||||
return ReconciliationEvaluationResult{
|
||||
@@ -298,6 +467,11 @@ func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfil
|
||||
return profile, ok, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleBusinessRelation(_ context.Context, vin string) (VehicleBusinessRelation, bool, error) {
|
||||
relation, ok := m.businessRelations[vin]
|
||||
return relation, ok, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
if vin != "LB9A32A24R0LS1426" {
|
||||
return VehicleSourceEvidence{
|
||||
@@ -420,15 +594,81 @@ func (m *MockStore) AccessEvidence(context.Context) ([]AccessEvidenceRow, error)
|
||||
}
|
||||
|
||||
func (m *MockStore) AccessUnresolvedIdentities(_ context.Context, query AccessUnresolvedIdentityQuery) (Page[AccessUnresolvedIdentity], error) {
|
||||
items := []AccessUnresolvedIdentity{{
|
||||
ID: "mock-unresolved-jt808", Protocol: "JT808", IdentifierMasked: "138****0001", Plate: "待维护",
|
||||
Manufacturer: "示范终端", SourceEndpoint: "gateway-a", LatestSeenAt: time.Now().Add(-30 * time.Second).Format(time.RFC3339),
|
||||
FreshnessSec: 30, IssueCode: "missing_vin_jt808", RecommendedAction: "核对终端手机号、车牌和厂家后维护 phone→VIN 权威绑定;禁止猜测 VIN",
|
||||
}}
|
||||
if query.Offset >= len(items) {
|
||||
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
m.accessMu.RLock()
|
||||
defer m.accessMu.RUnlock()
|
||||
items := make([]AccessUnresolvedIdentity, 0, len(m.accessIdentities))
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
for _, item := range m.accessIdentities {
|
||||
if query.Protocol != "" && !strings.EqualFold(item.Protocol, query.Protocol) {
|
||||
continue
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(item.IdentifierMasked+" "+item.Plate+" "+item.Manufacturer+" "+item.SourceEndpoint), keyword) {
|
||||
continue
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return Page[AccessUnresolvedIdentity]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
|
||||
total := len(items)
|
||||
if query.Offset >= total {
|
||||
return Page[AccessUnresolvedIdentity]{Items: []AccessUnresolvedIdentity{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
end := query.Offset + query.Limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
return Page[AccessUnresolvedIdentity]{Items: append([]AccessUnresolvedIdentity(nil), items[query.Offset:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) ClaimAccessIdentity(_ context.Context, identityID string, input AccessIdentityClaimInput) (AccessIdentityClaimResult, error) {
|
||||
m.accessMu.Lock()
|
||||
identityIndex := -1
|
||||
var identity AccessUnresolvedIdentity
|
||||
for index, item := range m.accessIdentities {
|
||||
if item.ID == identityID {
|
||||
identityIndex = index
|
||||
identity = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if identityIndex < 0 {
|
||||
m.accessMu.Unlock()
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_STALE", Message: "该来源已被认领或不再存在,请返回待办列表刷新"}
|
||||
}
|
||||
plate := ""
|
||||
vehicleFound := false
|
||||
for _, vehicle := range m.vehicles {
|
||||
if strings.EqualFold(vehicle.VIN, input.VIN) {
|
||||
vehicleFound = true
|
||||
if plate == "" {
|
||||
plate = vehicle.Plate
|
||||
}
|
||||
}
|
||||
}
|
||||
if !vehicleFound {
|
||||
m.accessMu.Unlock()
|
||||
return AccessIdentityClaimResult{}, clientError{Code: "ACCESS_IDENTITY_VEHICLE_NOT_FOUND", Message: "所选 VIN 不在权威主车辆中,请重新选择"}
|
||||
}
|
||||
m.accessIdentities = append(m.accessIdentities[:identityIndex], m.accessIdentities[identityIndex+1:]...)
|
||||
m.accessMu.Unlock()
|
||||
|
||||
m.profileMu.RLock()
|
||||
profile, hasProfile := m.profiles[input.VIN]
|
||||
m.profileMu.RUnlock()
|
||||
missing := []string{}
|
||||
checks := []struct{ label, value string }{
|
||||
{"车辆品牌", profile.BrandName}, {"车型", profile.ModelName}, {"车辆类型", profile.VehicleType}, {"所属企业", profile.CompanyName},
|
||||
{"运营状态", profile.OperationStatus}, {"接入服务商", profile.AccessProvider}, {"首次接入", profile.FirstAccessAt},
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !hasProfile || strings.TrimSpace(check.value) == "" || (check.label == "运营状态" && strings.EqualFold(check.value, "unknown")) {
|
||||
missing = append(missing, check.label)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
return AccessIdentityClaimResult{
|
||||
IdentityID: identity.ID, Protocol: identity.Protocol, IdentifierMasked: identity.IdentifierMasked,
|
||||
VIN: input.VIN, Plate: plate, ProfileComplete: len(missing) == 0, ProfileMissingFields: missing,
|
||||
ClaimedBy: input.Actor, ClaimedAt: now.Format(time.RFC3339), AuditID: now.UnixNano(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) AccessThresholds(context.Context) (AccessThresholdConfig, error) {
|
||||
@@ -575,7 +815,8 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
row.SourceStatus = buildVehicleCoverageSourceStatus(row.Protocols, onlineProtocols, row.LastSeen)
|
||||
row.ServiceStatus = buildVehicleCoverageServiceStatus(*row)
|
||||
row.SourceConsistency = buildVehicleCoverageSourceConsistency(*row)
|
||||
if keepCoverageRow(*row, query) {
|
||||
relation, hasRelation := m.businessRelations[row.VIN]
|
||||
if matchesBusinessFilters(relation, hasRelation, query) && keepCoverageRow(*row, query) {
|
||||
items = append(items, *row)
|
||||
}
|
||||
}
|
||||
@@ -583,6 +824,75 @@ func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[V
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
func matchesBusinessFilters(relation VehicleBusinessRelation, found bool, query url.Values) bool {
|
||||
filters := []struct {
|
||||
raw string
|
||||
value string
|
||||
}{
|
||||
{query.Get("departmentIds"), relation.DepartmentID},
|
||||
{query.Get("responsibleUserIds"), relation.ResponsibleUserID},
|
||||
{query.Get("customerIds"), relation.CustomerID},
|
||||
{query.Get("operationStatuses"), relation.OperationStatus},
|
||||
}
|
||||
for _, filter := range filters {
|
||||
values := splitCSV(filter.raw)
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
if !found || !containsString(values, filter.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleBusinessFilters(_ context.Context, query url.Values) (VehicleBusinessFilters, error) {
|
||||
scope := map[string]bool{}
|
||||
if values := splitCSV(query.Get("scopeVins")); len(values) > 0 {
|
||||
for _, vin := range values {
|
||||
scope[vin] = true
|
||||
}
|
||||
}
|
||||
type counter struct {
|
||||
label string
|
||||
vins map[string]bool
|
||||
}
|
||||
departments := map[string]*counter{}
|
||||
responsibleUsers := map[string]*counter{}
|
||||
customers := map[string]*counter{}
|
||||
statuses := map[string]*counter{}
|
||||
add := func(target map[string]*counter, value, label, vin string) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return
|
||||
}
|
||||
if target[value] == nil {
|
||||
target[value] = &counter{label: firstNonEmpty(label, value), vins: map[string]bool{}}
|
||||
}
|
||||
target[value].vins[vin] = true
|
||||
}
|
||||
for vin, relation := range m.businessRelations {
|
||||
if len(scope) > 0 && !scope[vin] {
|
||||
continue
|
||||
}
|
||||
add(departments, relation.DepartmentID, relation.DepartmentName, vin)
|
||||
add(responsibleUsers, relation.ResponsibleUserID, relation.ResponsibleUserName, vin)
|
||||
add(customers, relation.CustomerID, relation.CustomerName, vin)
|
||||
add(statuses, relation.OperationStatus, relation.OperationStatus, vin)
|
||||
}
|
||||
options := func(source map[string]*counter) []VehicleBusinessFilterOption {
|
||||
result := make([]VehicleBusinessFilterOption, 0, len(source))
|
||||
for value, item := range source {
|
||||
result = append(result, VehicleBusinessFilterOption{Value: value, Label: item.label, Count: len(item.vins)})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Label < result[j].Label })
|
||||
return result
|
||||
}
|
||||
return VehicleBusinessFilters{
|
||||
Departments: options(departments), ResponsibleUsers: options(responsibleUsers),
|
||||
Customers: options(customers), Statuses: options(statuses),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||||
allQuery := copyValues(query)
|
||||
allQuery.Set("limit", "100000")
|
||||
@@ -898,12 +1208,23 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
|
||||
}
|
||||
items = append(items, *row)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
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))
|
||||
right := strings.ToUpper(firstNonEmpty(items[j].Plate, items[j].VIN))
|
||||
if left == right {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return left < right
|
||||
})
|
||||
} else {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].LastSeen == items[j].LastSeen {
|
||||
return items[i].VIN < items[j].VIN
|
||||
}
|
||||
return items[i].LastSeen > items[j].LastSeen
|
||||
})
|
||||
}
|
||||
return page(items, query), nil
|
||||
}
|
||||
|
||||
@@ -1347,20 +1668,37 @@ func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) {
|
||||
}
|
||||
|
||||
func filterVehicles(rows []VehicleRow, query url.Values) []VehicleRow {
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Get("keyword")))
|
||||
keywords := vehicleSearchKeywords(query)
|
||||
batch := strings.TrimSpace(query.Get("keywords")) != ""
|
||||
protocol := strings.TrimSpace(query.Get("protocol"))
|
||||
if keyword == "" && protocol == "" {
|
||||
scopeRaw := strings.TrimSpace(query.Get("scopeVins"))
|
||||
scope := map[string]bool{}
|
||||
for _, vin := range splitCSV(scopeRaw) {
|
||||
scope[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
}
|
||||
if len(keywords) == 0 && protocol == "" && scopeRaw == "" {
|
||||
return rows
|
||||
}
|
||||
return keep(rows, func(row VehicleRow) bool {
|
||||
if scopeRaw != "" && !scope[strings.ToUpper(strings.TrimSpace(row.VIN))] {
|
||||
return false
|
||||
}
|
||||
if protocol != "" && row.Protocol != protocol {
|
||||
return false
|
||||
}
|
||||
if keyword == "" {
|
||||
if len(keywords) == 0 {
|
||||
return true
|
||||
}
|
||||
if batch {
|
||||
for _, keyword := range keywords {
|
||||
if strings.EqualFold(row.VIN, keyword) || strings.EqualFold(row.Plate, keyword) || strings.EqualFold(row.Phone, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
value := strings.ToLower(row.VIN + row.Plate + row.Phone + row.OEM)
|
||||
return strings.Contains(value, keyword)
|
||||
return strings.Contains(value, strings.ToLower(keywords[0]))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,10 +76,22 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
having := []string{}
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
where, args = appendBusinessCoverageFilters(where, args, query)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
if strings.TrimSpace(query.Get("keywords")) != "" {
|
||||
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(keywords)), ",")
|
||||
where = append(where, "(v.vin IN ("+placeholders+") OR s.plate IN ("+placeholders+") OR b.vin IN ("+placeholders+") OR b.plate IN ("+placeholders+") OR b.phone IN ("+placeholders+"))")
|
||||
for range 5 {
|
||||
for _, keyword := range keywords {
|
||||
args = append(args, keyword)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
keyword := keywords[0]
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "s.protocol = ?")
|
||||
@@ -148,6 +160,8 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
|
||||
groupSQL := `FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY v.vin, b.plate, b.phone, b.oem, b.vin ` +
|
||||
havingSQL
|
||||
@@ -175,6 +189,7 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
vehicleSetSQL, args := buildVehicleCoverageSetSQL(query.Get("scopeVins"))
|
||||
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
|
||||
having := []string{}
|
||||
where, args = appendBusinessCoverageFilters(where, args, query)
|
||||
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" {
|
||||
where = append(where, "(v.vin LIKE ? OR s.plate LIKE ? OR b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
@@ -257,6 +272,8 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
`FROM (` + vehicleSetSQL + `) v ` +
|
||||
`LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin ` +
|
||||
`LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin ` +
|
||||
`LEFT JOIN business_scope_state bst ON bst.id = 1 ` +
|
||||
`LEFT JOIN business_customer_vehicle_scope bs ON BINARY bs.source_version = BINARY bst.active_version AND BINARY bs.vin = BINARY v.vin ` +
|
||||
`WHERE ` + strings.Join(where, " AND ") + ` ` +
|
||||
`GROUP BY v.vin ` + havingSQL
|
||||
return SQLQuery{
|
||||
@@ -275,6 +292,14 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery {
|
||||
}
|
||||
}
|
||||
|
||||
func appendBusinessCoverageFilters(where []string, args []any, query url.Values) ([]string, []any) {
|
||||
where, args = appendCSVListFilter(where, args, "bs.department_id", query.Get("departmentIds"))
|
||||
where, args = appendCSVListFilter(where, args, "bs.responsible_user_id", query.Get("responsibleUserIds"))
|
||||
where, args = appendCSVListFilter(where, args, "CAST(bs.customer_id AS CHAR)", query.Get("customerIds"))
|
||||
where, args = appendCSVListFilter(where, args, "bs.operation_status", query.Get("operationStatuses"))
|
||||
return where, args
|
||||
}
|
||||
|
||||
func buildVehicleCoverageSetSQL(scope string) (string, []any) {
|
||||
scope = strings.TrimSpace(scope)
|
||||
if scope == "" {
|
||||
@@ -454,6 +479,10 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
// Ordering only by updated_at makes the selected point flap whenever protocols
|
||||
// report at different cadences. When every source is stale, recency remains the
|
||||
// safest fallback so an old high-priority source cannot mask newer evidence.
|
||||
vehicleOrderSQL := `MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC`
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("sort")), "identity") {
|
||||
vehicleOrderSQL = `COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC`
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT v.vin, ` +
|
||||
`COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') AS plate, ` +
|
||||
@@ -482,7 +511,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
`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 MAX(l.updated_at) IS NULL ASC, MAX(l.updated_at) DESC, v.vin ASC LIMIT ? OFFSET ?`,
|
||||
`ORDER BY ` + vehicleOrderSQL + ` LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT v.vin ` + baseGroupSQL + groupSuffixSQL + `) vehicle_realtime_count`,
|
||||
CountArgs: countArgs,
|
||||
@@ -522,36 +551,57 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
}
|
||||
countArgs := append([]any(nil), args...)
|
||||
args = append(args, limit, offset)
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
fromSQL := `FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK'
|
||||
WHERE ` + strings.Join(where, " AND ")
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date`
|
||||
return SQLQuery{
|
||||
built := SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||
built.CountText = ""
|
||||
built.CountArgs = nil
|
||||
}
|
||||
return built
|
||||
}
|
||||
return SQLQuery{
|
||||
built := SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, m.protocol ` +
|
||||
`COALESCE(m.latest_total_mileage_km, 0) AS end_mileage_km, m.daily_mileage_km, ` +
|
||||
`COALESCE(m.pure_hydrogen_mileage_km, 0), h.consumption_kg, m.protocol ` +
|
||||
fromSQL + ` ORDER BY m.stat_date DESC, m.vin ASC, m.protocol ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) ` + fromSQL,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
if query.Get("skipCount") == "1" || strings.EqualFold(query.Get("skipCount"), "true") {
|
||||
built.CountText = ""
|
||||
built.CountArgs = nil
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
@@ -585,7 +635,8 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
return SQLQuery{
|
||||
Text: `SELECT COUNT(DISTINCT m.vin) AS vehicle_count, COUNT(*) AS record_count, ` +
|
||||
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km ` + fromSQL,
|
||||
`COUNT(DISTINCT m.protocol) AS source_count, COALESCE(SUM(m.daily_mileage_km), 0) AS total_mileage_km, ` +
|
||||
`COALESCE(SUM(m.pure_hydrogen_mileage_km), 0) AS total_pure_hydrogen_mileage_km ` + fromSQL,
|
||||
Args: args,
|
||||
}
|
||||
}
|
||||
@@ -625,28 +676,43 @@ func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
pureHydrogenMileageExpression := `MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))`
|
||||
latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder := mileageDailySelectionOrder("m.daily_mileage_km", "m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
pureHydrogenMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.pure_hydrogen_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
pureHydrogenMileageExpression + ` AS pure_hydrogen_mileage_km, ` +
|
||||
`MAX(h.consumption_kg) AS hydrogen_consumption_kg, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||
`FROM vehicle_daily_mileage m
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin
|
||||
LEFT JOIN vehicle_open_daily_energy h
|
||||
ON h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci
|
||||
AND h.stat_date = m.stat_date
|
||||
AND h.energy_type = 'HYDROGEN' AND h.quality_status = 'OK' ` +
|
||||
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
||||
}
|
||||
|
||||
func buildMileageStatisticsSummarySQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT COUNT(DISTINCT d.vin), COUNT(*), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COALESCE(AVG(d.daily_mileage_km), 0) FROM (` + base + `) d`, Args: args}
|
||||
}
|
||||
|
||||
func buildMileageStatisticsTrendSQL(query url.Values) SQLQuery {
|
||||
base, args := buildMileageStatisticsBaseSQL(query)
|
||||
return SQLQuery{Text: `SELECT DATE_FORMAT(d.stat_date, '%Y-%m-%d'), COALESCE(SUM(d.daily_mileage_km), 0), ` +
|
||||
`COALESCE(SUM(d.pure_hydrogen_mileage_km), 0), ` +
|
||||
`COUNT(d.hydrogen_consumption_kg), COALESCE(SUM(d.hydrogen_consumption_kg), 0), ` +
|
||||
`COALESCE(SUM(CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km ELSE 0 END), 0), ` +
|
||||
`COUNT(DISTINCT d.vin) FROM (` + base + `) d GROUP BY d.stat_date ORDER BY d.stat_date ASC`, Args: args}
|
||||
}
|
||||
|
||||
@@ -740,23 +806,31 @@ func mileageDailySelectionOrder(mileageColumn, protocolColumn string, protocols
|
||||
}
|
||||
|
||||
func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
return appendListFilter(where, args, column, raw, 0)
|
||||
}
|
||||
|
||||
func appendCSVListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
return appendListFilter(where, args, column, raw, 200)
|
||||
}
|
||||
|
||||
func appendListFilter(where []string, args []any, column string, raw string, maxValues int) ([]string, []any) {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
value := strings.TrimSpace(item)
|
||||
if value == "" || seen[value] || (maxValues > 0 && len(values) >= maxValues) {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
values = append(values, vin)
|
||||
seen[value] = true
|
||||
values = append(values, value)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return where, args
|
||||
}
|
||||
placeholders := make([]string, len(values))
|
||||
for index, vin := range values {
|
||||
for index, value := range values {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
args = append(args, value)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
@@ -13,25 +13,29 @@ 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"`
|
||||
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.MenuKeys = append([]string(nil), p.MenuKeys...)
|
||||
p.VehicleVINs = append([]string(nil), p.VehicleVINs...)
|
||||
p.VehicleGrants = append([]VehicleGrant(nil), p.VehicleGrants...)
|
||||
p.DepartmentIDs = append([]string(nil), p.DepartmentIDs...)
|
||||
p.VehicleCount = len(p.VehicleVINs)
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type ProductionStore struct {
|
||||
accessSchema schemaReadyGate
|
||||
alertSchema schemaReadyGate
|
||||
profileSchema schemaReadyGate
|
||||
businessScopeSchema schemaReadyGate
|
||||
reconciliationSchema schemaReadyGate
|
||||
}
|
||||
|
||||
@@ -628,10 +629,11 @@ func (s *ProductionStore) HistoryLocationsFromTDengine(ctx context.Context, quer
|
||||
}
|
||||
limit, offset := buildLimitOffset(query)
|
||||
tdQuery := map[string]string{
|
||||
"protocol": query.Get("protocol"),
|
||||
"vin": query.Get("vin"),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"protocol": query.Get("protocol"),
|
||||
"vin": query.Get("vin"),
|
||||
"limit": strconv.Itoa(limit),
|
||||
"offset": strconv.Itoa(offset),
|
||||
"skipCount": query.Get("skipCount"),
|
||||
}
|
||||
if value := strings.TrimSpace(query.Get("dateFrom")); value != "" {
|
||||
tdQuery["dateFrom"] = value
|
||||
@@ -1027,7 +1029,7 @@ func (s *ProductionStore) RawFrames(ctx context.Context, query RawFrameQuery) (P
|
||||
func (s *ProductionStore) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
|
||||
built := buildMileageSummarySQL(query)
|
||||
var summary MileageSummary
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, built.Text, built.Args...).Scan(&summary.VehicleCount, &summary.RecordCount, &summary.SourceCount, &summary.TotalMileageKm, &summary.TotalPureHydrogenMileageKm); err != nil {
|
||||
return MileageSummary{}, err
|
||||
}
|
||||
if summary.VehicleCount > 0 {
|
||||
@@ -1052,9 +1054,15 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
||||
items := make([]DailyMileageRow, 0)
|
||||
for rows.Next() {
|
||||
var row DailyMileageRow
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.Source); err != nil {
|
||||
var hydrogen sql.NullFloat64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.Date, &row.StartMileageKm, &row.EndMileageKm, &row.DailyMileageKm, &row.PureHydrogenMileageKm, &hydrogen, &row.Source); err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
if hydrogen.Valid {
|
||||
consumption := hydrogen.Float64
|
||||
row.HydrogenConsumptionKg = &consumption
|
||||
row.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(consumption, row.DailyMileageKm, 1)
|
||||
}
|
||||
items = append(items, row)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1071,12 +1079,26 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
result := MileageStatistics{
|
||||
DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"),
|
||||
Trend: []MileageTrendPoint{}, Ranking: []MileageVehicleRank{},
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重)/ vehicle_realtime_location(最新里程表)",
|
||||
Evidence: "vehicle_daily_mileage(按车辆和日期去重;百公里氢耗按匹配车辆日的总里程计算)/ vehicle_open_daily_energy(质量通过的日用氢量)/ vehicle_realtime_location(最新里程表)",
|
||||
}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(&result.VehicleCount, &result.RecordCount, &result.PeriodMileageKm, &result.AverageDailyMileageKm); err != nil {
|
||||
if err := s.db.QueryRowContext(ctx, summary.Text, summary.Args...).Scan(
|
||||
&result.VehicleCount,
|
||||
&result.RecordCount,
|
||||
&result.PeriodMileageKm,
|
||||
&result.PeriodPureHydrogenMileageKm,
|
||||
&result.HydrogenDataDays,
|
||||
&result.PeriodHydrogenConsumptionKg,
|
||||
&result.HydrogenMatchedMileageKm,
|
||||
&result.AverageDailyMileageKm,
|
||||
); err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
result.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(
|
||||
result.PeriodHydrogenConsumptionKg,
|
||||
result.HydrogenMatchedMileageKm,
|
||||
result.HydrogenDataDays,
|
||||
)
|
||||
if result.VehicleCount > 0 {
|
||||
result.AverageMileagePerVIN = result.PeriodMileageKm / float64(result.VehicleCount)
|
||||
}
|
||||
@@ -1095,10 +1117,27 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
}
|
||||
for rows.Next() {
|
||||
var point MileageTrendPoint
|
||||
if err := rows.Scan(&point.Date, &point.MileageKm, &point.Vehicles); err != nil {
|
||||
var hydrogenConsumptionKg float64
|
||||
if err := rows.Scan(
|
||||
&point.Date,
|
||||
&point.MileageKm,
|
||||
&point.PureHydrogenMileageKm,
|
||||
&point.HydrogenDataDays,
|
||||
&hydrogenConsumptionKg,
|
||||
&point.HydrogenMatchedMileageKm,
|
||||
&point.Vehicles,
|
||||
); err != nil {
|
||||
rows.Close()
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
if point.HydrogenDataDays > 0 {
|
||||
point.HydrogenConsumptionKg = &hydrogenConsumptionKg
|
||||
point.HydrogenConsumptionKgPer100Km = hydrogenRatePer100Km(
|
||||
hydrogenConsumptionKg,
|
||||
point.HydrogenMatchedMileageKm,
|
||||
point.HydrogenDataDays,
|
||||
)
|
||||
}
|
||||
result.Trend = append(result.Trend, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -1128,6 +1167,14 @@ func (s *ProductionStore) MileageStatistics(ctx context.Context, query url.Value
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hydrogenRatePer100Km(consumptionKg, dailyMileageKm float64, dataDays int) *float64 {
|
||||
if dataDays <= 0 || consumptionKg < 0 || dailyMileageKm <= 0 {
|
||||
return nil
|
||||
}
|
||||
rate := consumptionKg * 100 / dailyMileageKm
|
||||
return &rate
|
||||
}
|
||||
|
||||
func buildQualityIssueWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
|
||||
@@ -141,6 +141,29 @@ func TestBuildVehicleServiceOverviewBatchSQLUsesFuzzyKeywordMatching(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenRatePer100KmUsesMatchedDailyMileage(t *testing.T) {
|
||||
rate := hydrogenRatePer100Km(7.3, 193.3, 2)
|
||||
if rate == nil || *rate < 3.77 || *rate > 3.78 {
|
||||
t.Fatalf("hydrogen rate = %#v, want about 3.776 kg/100km", rate)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
consumption float64
|
||||
mileage float64
|
||||
dataDays int
|
||||
}{
|
||||
{name: "no quality-approved days", consumption: 7.3, mileage: 128.4},
|
||||
{name: "no matched daily mileage", consumption: 7.3, dataDays: 2},
|
||||
{name: "negative consumption", consumption: -1, mileage: 128.4, dataDays: 2},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := hydrogenRatePer100Km(test.consumption, test.mileage, test.dataDays); got != nil {
|
||||
t.Fatalf("hydrogen rate = %v, want nil", *got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTDengineTableNotExistErrorIsRecognized(t *testing.T) {
|
||||
err := errors.New("[0x2603] Fail to get table info, error: Table does not exist")
|
||||
if !isTDengineTableNotExist(err) {
|
||||
|
||||
@@ -28,6 +28,35 @@ func TestBuildVehicleListSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLAppliesBusinessMultiSelects(t *testing.T) {
|
||||
built := buildVehicleCoverageSQL(url.Values{
|
||||
"departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"},
|
||||
"customerIds": {"20001,20002"}, "operationStatuses": {"运营中,待交付"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"business_scope_state", "business_customer_vehicle_scope",
|
||||
"bs.department_id IN (?,?)", "bs.responsible_user_id IN (?,?)",
|
||||
"CAST(bs.customer_id AS CHAR) IN (?,?)", "bs.operation_status IN (?,?)",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
|
||||
t.Fatalf("business filter SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 10 || len(built.CountArgs) != 8 {
|
||||
t.Fatalf("unexpected business filter args: args=%#v count=%#v", built.Args, built.CountArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReconciliationWhereSupportsExactOwner(t *testing.T) {
|
||||
where, args := buildReconciliationWhere(ReconciliationQuery{Status: "active", Owner: "定位运维组"})
|
||||
if !strings.Contains(where, "i.assignee=?") || !strings.Contains(where, "i.status IN") {
|
||||
t.Fatalf("exact owner filter missing from where clause: %s", where)
|
||||
}
|
||||
if len(args) != 1 || args[0] != "定位运维组" {
|
||||
t.Fatalf("unexpected owner args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleListSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleListSQL(query)
|
||||
@@ -67,6 +96,30 @@ func TestBuildVehicleCoverageSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLSupportsExactBatchIdentitySearch(t *testing.T) {
|
||||
built := buildVehicleCoverageSQL(url.Values{
|
||||
"keywords": {"粤AG18312, LMRKH9AC2R1004087, 粤AG18312"},
|
||||
"limit": {"100"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"v.vin IN (?,?)",
|
||||
"s.plate IN (?,?)",
|
||||
"b.vin IN (?,?)",
|
||||
"b.plate IN (?,?)",
|
||||
"b.phone IN (?,?)",
|
||||
} {
|
||||
if !strings.Contains(built.Text, want) || !strings.Contains(built.CountText, want) {
|
||||
t.Fatalf("batch coverage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
if len(built.Args) != 12 || len(built.CountArgs) != 10 {
|
||||
t.Fatalf("unexpected batch coverage args: args=%#v count=%#v", built.Args, built.CountArgs)
|
||||
}
|
||||
if built.Args[0] != "粤AG18312" || built.Args[1] != "LMRKH9AC2R1004087" || built.Args[10] != 100 {
|
||||
t.Fatalf("batch coverage args should preserve deduplicated identities: %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleCoverageSQL(query)
|
||||
@@ -293,6 +346,16 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLSupportsStableIdentityOrder(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"sort": {"identity"}, "limit": {"20"}})
|
||||
if !strings.Contains(built.Text, "ORDER BY COALESCE(NULLIF(MAX(NULLIF(l.plate, '')), ''), v.plate, '') ASC, v.vin ASC LIMIT ? OFFSET ?") {
|
||||
t.Fatalf("identity sort should keep mobile list membership and order stable: %s", built.Text)
|
||||
}
|
||||
if strings.Contains(built.Text, "ORDER BY MAX(l.updated_at) IS NULL ASC") {
|
||||
t.Fatalf("identity sort should not reorder the list on every realtime report: %s", built.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
|
||||
for _, text := range []string{built.Text, built.CountText} {
|
||||
@@ -328,6 +391,30 @@ func TestBuildVehicleRealtimeSQLBoundsCopiedPlateSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLPreservesLargeAuthorizedVINScope(t *testing.T) {
|
||||
const authorizedVehicleCount = 1024
|
||||
vins := make([]string, authorizedVehicleCount)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
built := buildVehicleRealtimeSQL(url.Values{
|
||||
"scopeVins": {strings.Join(vins, ",")},
|
||||
"limit": {"10000"},
|
||||
})
|
||||
if len(built.CountArgs) != authorizedVehicleCount {
|
||||
t.Fatalf("authorized VIN count args = %d, want %d", len(built.CountArgs), authorizedVehicleCount)
|
||||
}
|
||||
if len(built.Args) != authorizedVehicleCount+2 {
|
||||
t.Fatalf("authorized VIN query args = %d, want %d", len(built.Args), authorizedVehicleCount+2)
|
||||
}
|
||||
if built.CountArgs[0] != vins[0] || built.CountArgs[authorizedVehicleCount-1] != vins[authorizedVehicleCount-1] {
|
||||
t.Fatalf("authorized VIN scope was truncated: first=%v last=%v", built.CountArgs[0], built.CountArgs[authorizedVehicleCount-1])
|
||||
}
|
||||
if strings.Count(built.CountText, "?") != authorizedVehicleCount {
|
||||
t.Fatalf("authorized VIN placeholders = %d, want %d", strings.Count(built.CountText, "?"), authorizedVehicleCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
@@ -395,6 +482,14 @@ func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
if strings.Contains(built.Text, "m.first_total_mileage_km") || !strings.Contains(built.Text, "m.latest_total_mileage_km - m.daily_mileage_km") {
|
||||
t.Fatalf("daily mileage must follow the current projection schema and derive its start value: %s", built.Text)
|
||||
}
|
||||
if !strings.Contains(built.Text, "m.pure_hydrogen_mileage_km") {
|
||||
t.Fatalf("daily mileage must return pure hydrogen mileage: %s", built.Text)
|
||||
}
|
||||
for _, want := range []string{"vehicle_open_daily_energy h", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "h.energy_type = 'HYDROGEN'", "h.quality_status = 'OK'", "h.consumption_kg"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("daily mileage must return quality-approved hydrogen consumption, missing %q: %s", want, built.Text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(built.CountText, "COUNT(*)") || strings.Contains(built.CountText, "LIMIT") {
|
||||
t.Fatalf("count SQL = %s", built.CountText)
|
||||
}
|
||||
@@ -473,7 +568,7 @@ func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *te
|
||||
|
||||
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
||||
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "MAX(COALESCE(m.pure_hydrogen_mileage_km, 0))", "MAX(h.consumption_kg)", "h.vin COLLATE utf8mb4_unicode_ci = m.vin COLLATE utf8mb4_unicode_ci", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
if !strings.Contains(built.Text+built.CountText, want) {
|
||||
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
@@ -578,7 +673,7 @@ func TestMileageProtocolsRejectUnknownValues(t *testing.T) {
|
||||
func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
built := buildMileageSummarySQL(query)
|
||||
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
|
||||
for _, want := range []string{"COUNT(DISTINCT m.vin)", "COUNT(DISTINCT m.protocol)", "SUM(m.daily_mileage_km)", "SUM(m.pure_hydrogen_mileage_km)", "vehicle_daily_mileage", "vehicle_identity_binding"} {
|
||||
if !strings.Contains(built.Text, want) {
|
||||
t.Fatalf("SQL missing %q: %s", want, built.Text)
|
||||
}
|
||||
@@ -594,7 +689,18 @@ func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-31"}}
|
||||
summary := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km", "COUNT(DISTINCT d.vin)", "SUM(d.daily_mileage_km)"} {
|
||||
for _, want := range []string{
|
||||
"GROUP BY m.vin, m.stat_date",
|
||||
"MAX(COALESCE(m.daily_mileage_km",
|
||||
"MAX(COALESCE(m.pure_hydrogen_mileage_km",
|
||||
"MAX(h.consumption_kg)",
|
||||
"COUNT(DISTINCT d.vin)",
|
||||
"SUM(d.daily_mileage_km)",
|
||||
"SUM(d.pure_hydrogen_mileage_km)",
|
||||
"COUNT(d.hydrogen_consumption_kg)",
|
||||
"SUM(d.hydrogen_consumption_kg)",
|
||||
"CASE WHEN d.hydrogen_consumption_kg IS NOT NULL THEN d.daily_mileage_km",
|
||||
} {
|
||||
if !strings.Contains(summary.Text, want) {
|
||||
t.Fatalf("statistics summary SQL missing %q: %s", want, summary.Text)
|
||||
}
|
||||
@@ -603,7 +709,8 @@ func TestBuildMileageStatisticsSQLDeduplicatesVehicleDays(t *testing.T) {
|
||||
t.Fatalf("statistics args = %#v", summary.Args)
|
||||
}
|
||||
trend := buildMileageStatisticsTrendSQL(query)
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") {
|
||||
if !strings.Contains(trend.Text, "GROUP BY d.stat_date ORDER BY d.stat_date ASC") ||
|
||||
!strings.Contains(trend.Text, "SUM(d.hydrogen_consumption_kg)") {
|
||||
t.Fatalf("statistics trend SQL should be chronologically stable: %s", trend.Text)
|
||||
}
|
||||
ranking := buildMileageStatisticsRankingSQL(query)
|
||||
@@ -646,12 +753,12 @@ func TestNormalizeMileageVINSelection(t *testing.T) {
|
||||
if err != nil || normalized.Get("vins") != "VIN001,VIN002" {
|
||||
t.Fatalf("normalize VIN selection = %q, %v", normalized.Get("vins"), err)
|
||||
}
|
||||
tooMany := make([]string, 21)
|
||||
tooMany := make([]string, 50_001)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("VIN%03d", index)
|
||||
}
|
||||
if _, err := normalizeMileageVINSelection(url.Values{"vins": {strings.Join(tooMany, ",")}}); err == nil {
|
||||
t.Fatal("more than 20 selected vehicles should be rejected")
|
||||
t.Fatal("more than 50,000 selected vehicles should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,6 +798,23 @@ func TestBuildRawFrameSQLCanSkipUnneededCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyMileageSQLCanSkipUnneededCount(t *testing.T) {
|
||||
for _, deduplicate := range []string{"", "1"} {
|
||||
built := buildDailyMileageSQL(url.Values{
|
||||
"vins": {"VIN001"},
|
||||
"limit": {"20"},
|
||||
"skipCount": {"1"},
|
||||
"deduplicate": {deduplicate},
|
||||
})
|
||||
if built.CountText != "" || len(built.CountArgs) != 0 {
|
||||
t.Fatalf("bounded mileage preview should skip count for deduplicate=%q: %+v", deduplicate, built)
|
||||
}
|
||||
if !strings.Contains(built.Text, "m.vin IN (?)") || !strings.Contains(built.Text, "LIMIT ? OFFSET ?") {
|
||||
t.Fatalf("bounded mileage preview should keep exact VIN pagination for deduplicate=%q: %+v", deduplicate, built)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQL(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"protocol": "JT808",
|
||||
@@ -717,6 +841,15 @@ func TestBuildHistoryLocationSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQLCanSkipUnneededCount(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"vin": "VIN001", "limit": "20", "skipCount": "1",
|
||||
})
|
||||
if built.CountText != "" || !strings.Contains(built.Text, "LIMIT 20") {
|
||||
t.Fatalf("bounded latest query should skip count: %+v", built)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHistoryLocationSQLNormalizesDatetimeLocalMinutePrecision(t *testing.T) {
|
||||
built := buildHistoryLocationSQL("lingniu_vehicle_ts", map[string]string{
|
||||
"vin": "VIN001", "dateFrom": "2026-07-14T00:00", "dateTo": "2026-07-14T05:56", "limit": "10",
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ReconciliationExportFile struct {
|
||||
Name string
|
||||
Content []byte
|
||||
RowCount int
|
||||
}
|
||||
|
||||
func (s *Service) reconciliationStore() (ReconciliationStore, error) {
|
||||
store, ok := s.store.(ReconciliationStore)
|
||||
if !ok {
|
||||
@@ -31,10 +41,16 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
query.Keyword = strings.TrimSpace(query.Keyword)
|
||||
query.Scope = strings.ToLower(strings.TrimSpace(query.Scope))
|
||||
if query.Scope != "archived" {
|
||||
query.Scope = "current"
|
||||
}
|
||||
query.RuleCode = strings.TrimSpace(query.RuleCode)
|
||||
query.Category = strings.TrimSpace(query.Category)
|
||||
query.Severity = strings.TrimSpace(query.Severity)
|
||||
query.Status = strings.TrimSpace(query.Status)
|
||||
query.Owner = strings.TrimSpace(query.Owner)
|
||||
query.SLA = strings.TrimSpace(query.SLA)
|
||||
if query.Limit <= 0 || query.Limit > 200 {
|
||||
query.Limit = 50
|
||||
}
|
||||
@@ -44,6 +60,256 @@ func (s *Service) ReconciliationIssues(ctx context.Context, query Reconciliation
|
||||
return store.ReconciliationIssues(ctx, query)
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationAssignees(ctx context.Context, search string) ([]ReconciliationAssignee, error) {
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
search = strings.TrimSpace(search)
|
||||
if len([]rune(search)) > 80 {
|
||||
return nil, clientError{Code: "RECONCILIATION_ASSIGNEE_SEARCH_TOO_LONG", Message: "负责人搜索不能超过 80 个字符"}
|
||||
}
|
||||
items, err := store.ReconciliationAssignees(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]ReconciliationAssignee, len(items)+1)
|
||||
for _, item := range items {
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
if item.Name != "" {
|
||||
byName[strings.ToLower(item.Name)] = item
|
||||
}
|
||||
}
|
||||
if principal, ok := PrincipalFromContext(ctx); ok {
|
||||
name := strings.TrimSpace(principal.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(principal.Username)
|
||||
}
|
||||
if name != "" {
|
||||
key := strings.ToLower(name)
|
||||
item, exists := byName[key]
|
||||
if !exists {
|
||||
item = ReconciliationAssignee{Name: name, Source: "current"}
|
||||
}
|
||||
if item.Username == "" {
|
||||
item.Username = strings.TrimSpace(principal.Username)
|
||||
}
|
||||
item.Current = true
|
||||
byName[key] = item
|
||||
}
|
||||
}
|
||||
needle := strings.ToLower(search)
|
||||
result := make([]ReconciliationAssignee, 0, len(byName))
|
||||
for _, item := range byName {
|
||||
if needle != "" && !strings.Contains(strings.ToLower(item.Name+" "+item.Username), needle) {
|
||||
continue
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
if result[i].Current != result[j].Current {
|
||||
return result[i].Current
|
||||
}
|
||||
if result[i].ActiveCount != result[j].ActiveCount {
|
||||
return result[i].ActiveCount > result[j].ActiveCount
|
||||
}
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
if len(result) > 50 {
|
||||
result = result[:50]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExportReconciliationIssues(ctx context.Context, query ReconciliationQuery) (ReconciliationExportFile, error) {
|
||||
query.Offset = 0
|
||||
query.Limit = 200
|
||||
first, err := s.ReconciliationIssues(ctx, query)
|
||||
if err != nil {
|
||||
return ReconciliationExportFile{}, err
|
||||
}
|
||||
if first.Total > reconciliationFindingLimit {
|
||||
return ReconciliationExportFile{}, clientError{Code: "RECONCILIATION_EXPORT_TOO_LARGE", Message: "当前筛选超过 50,000 条,请缩小范围后导出"}
|
||||
}
|
||||
items := append([]ReconciliationIssue(nil), first.Items...)
|
||||
for offset := len(first.Items); offset < first.Total; offset += query.Limit {
|
||||
query.Offset = offset
|
||||
page, pageErr := s.ReconciliationIssues(ctx, query)
|
||||
if pageErr != nil {
|
||||
return ReconciliationExportFile{}, pageErr
|
||||
}
|
||||
items = append(items, page.Items...)
|
||||
if len(page.Items) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
buffer.WriteString("\xEF\xBB\xBF")
|
||||
writer := csv.NewWriter(&buffer)
|
||||
_ = writer.Write([]string{"差异编号", "等级", "状态", "规则", "问题", "VIN", "车牌", "来源 A", "来源 B", "负责人", "处理期限", "首次发现", "最近发现", "命中次数", "结论说明", "处置人", "归档时间", "归档人", "归档原因", "版本"})
|
||||
for _, item := range items {
|
||||
_ = writer.Write([]string{
|
||||
reconciliationCSVCell(item.ID), reconciliationCSVCell(reconciliationSeverityLabel(item.Severity)), reconciliationCSVCell(reconciliationStatusLabel(item.Status)),
|
||||
reconciliationCSVCell(item.RuleCode), reconciliationCSVCell(item.Title), reconciliationCSVCell(item.VIN), reconciliationCSVCell(item.Plate),
|
||||
reconciliationCSVCell(item.ProtocolA), reconciliationCSVCell(item.ProtocolB), reconciliationCSVCell(item.Assignee), reconciliationCSVCell(item.DueAt),
|
||||
reconciliationCSVCell(item.FirstSeenAt), reconciliationCSVCell(item.LastSeenAt), fmt.Sprintf("%d", item.OccurrenceCount),
|
||||
reconciliationCSVCell(item.ResolutionNote), reconciliationCSVCell(item.ResolvedBy),
|
||||
reconciliationCSVCell(item.ArchivedAt), reconciliationCSVCell(item.ArchivedBy), reconciliationCSVCell(item.ArchiveReason),
|
||||
fmt.Sprintf("%d", item.Version),
|
||||
})
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return ReconciliationExportFile{}, err
|
||||
}
|
||||
return ReconciliationExportFile{
|
||||
Name: fmt.Sprintf("质量差异_%s.csv", time.Now().Format("20060102_150405")),
|
||||
Content: buffer.Bytes(),
|
||||
RowCount: len(items),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func reconciliationCSVCell(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" && strings.ContainsRune("=+-@", rune(value[0])) {
|
||||
return "'" + value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reconciliationSeverityLabel(value string) string {
|
||||
if label := map[string]string{"critical": "严重", "major": "重要", "minor": "一般"}[value]; label != "" {
|
||||
return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func reconciliationStatusLabel(value string) string {
|
||||
if label := map[string]string{"pending": "待处理", "confirmed_source_a": "确认来源 A", "confirmed_source_b": "确认来源 B", "no_action": "无需处理", "fixed": "已修复", "recovered": "已恢复"}[value]; label != "" {
|
||||
return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeReconciliationLifecycleRequest(ctx context.Context, request ReconciliationLifecycleRequest) (ReconciliationLifecycleRequest, error) {
|
||||
request.Reason = strings.TrimSpace(request.Reason)
|
||||
if request.Version <= 0 {
|
||||
return request, clientError{Code: "RECONCILIATION_VERSION_REQUIRED", Message: "差异记录版本不能为空"}
|
||||
}
|
||||
if len([]rune(request.Reason)) < 4 {
|
||||
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"}
|
||||
}
|
||||
if len([]rune(request.Reason)) > 500 {
|
||||
return request, clientError{Code: "RECONCILIATION_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 500 字"}
|
||||
}
|
||||
if err := authorizeInternalOperations(ctx, true); err != nil {
|
||||
return request, clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以整理差异审计归档"}
|
||||
}
|
||||
request.Actor = ActorFromContext(ctx)
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
||||
}
|
||||
normalized, err := normalizeReconciliationLifecycleRequest(ctx, request)
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.SetReconciliationIssueArchived(ctx, id, archived, normalized)
|
||||
}
|
||||
|
||||
func (s *Service) BatchSetReconciliationIssuesArchived(ctx context.Context, archived bool, request ReconciliationBatchLifecycleRequest) (ReconciliationBatchActionResult, error) {
|
||||
result := ReconciliationBatchActionResult{
|
||||
Requested: len(request.Items),
|
||||
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
||||
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多整理 20 条差异记录"}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 {
|
||||
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量整理中包含无效的差异记录编号"}
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量整理不能重复选择同一条差异记录"}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.SetReconciliationIssueArchived(ctx, item.ID, archived, ReconciliationLifecycleRequest{
|
||||
Version: item.Version,
|
||||
Reason: request.Reason,
|
||||
})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "整理失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code, failure.Message = itemError.Code, itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "差异记录编号无效"}
|
||||
}
|
||||
request.Actor = ActorFromContext(ctx)
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return store.AssignReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchAssignReconciliationIssues(ctx context.Context, request ReconciliationBatchAssignmentRequest) (ReconciliationBatchActionResult, error) {
|
||||
result := ReconciliationBatchActionResult{Requested: len(request.Items), Succeeded: []ReconciliationIssue{}, Skipped: []ReconciliationBatchActionFailure{}}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多交接 20 条差异记录"}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 || seen[id] {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_ITEM_INVALID", Message: "批量交接包含无效或重复记录"}
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.AssignReconciliationIssue(ctx, item.ID, ReconciliationAssignmentRequest{Version: item.Version, Assignee: request.Assignee, DueAt: request.DueAt})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: item.ID, Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "交接失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code, failure.Message = itemError.Code, itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
@@ -69,6 +335,61 @@ func (s *Service) UpdateReconciliationIssue(ctx context.Context, id string, requ
|
||||
return store.UpdateReconciliationIssue(ctx, id, request)
|
||||
}
|
||||
|
||||
func (s *Service) BatchUpdateReconciliationIssues(ctx context.Context, request ReconciliationBatchActionRequest) (ReconciliationBatchActionResult, error) {
|
||||
request.Status = strings.TrimSpace(request.Status)
|
||||
request.Note = strings.TrimSpace(request.Note)
|
||||
result := ReconciliationBatchActionResult{
|
||||
Requested: len(request.Items),
|
||||
Succeeded: make([]ReconciliationIssue, 0, len(request.Items)),
|
||||
Skipped: make([]ReconciliationBatchActionFailure, 0),
|
||||
}
|
||||
if len(request.Items) == 0 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_EMPTY", Message: "请至少选择一条差异记录"}
|
||||
}
|
||||
if len(request.Items) > 20 {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_TOO_LARGE", Message: "单次最多处置 20 条差异记录"}
|
||||
}
|
||||
allowed := map[string]bool{"pending": true, "no_action": true, "fixed": true}
|
||||
if !allowed[request.Status] {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_STATUS_INVALID", Message: "批量处置仅支持退回待复核、无需处理或已修复"}
|
||||
}
|
||||
if request.Status != "pending" && request.Note == "" {
|
||||
return result, clientError{Code: "RECONCILIATION_NOTE_REQUIRED", Message: "批量处置必须填写说明"}
|
||||
}
|
||||
if len([]rune(request.Note)) > 500 {
|
||||
return result, clientError{Code: "RECONCILIATION_NOTE_TOO_LONG", Message: "处置说明不能超过 500 个字符"}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(request.Items))
|
||||
for _, item := range request.Items {
|
||||
id := strings.TrimSpace(item.ID)
|
||||
if id == "" || len(id) > 64 {
|
||||
return result, clientError{Code: "RECONCILIATION_ID_INVALID", Message: "批量处置中包含无效的差异记录编号"}
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
return result, clientError{Code: "RECONCILIATION_BATCH_DUPLICATE_ID", Message: "批量处置不能重复选择同一条差异记录"}
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Items {
|
||||
updated, err := s.UpdateReconciliationIssue(ctx, strings.TrimSpace(item.ID), ReconciliationActionRequest{
|
||||
Version: item.Version,
|
||||
Status: request.Status,
|
||||
Note: request.Note,
|
||||
})
|
||||
if err == nil {
|
||||
result.Succeeded = append(result.Succeeded, updated)
|
||||
continue
|
||||
}
|
||||
failure := ReconciliationBatchActionFailure{ID: strings.TrimSpace(item.ID), Code: "RECONCILIATION_BATCH_ITEM_FAILED", Message: "处置失败,请刷新后重试"}
|
||||
if itemError, ok := asClientError(err); ok {
|
||||
failure.Code = itemError.Code
|
||||
failure.Message = itemError.Message
|
||||
}
|
||||
result.Skipped = append(result.Skipped, failure)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) EvaluateReconciliation(ctx context.Context) (ReconciliationEvaluationResult, error) {
|
||||
store, err := s.reconciliationStore()
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -16,8 +17,11 @@ const reconciliationFindingLimit = 50000
|
||||
type ReconciliationStore interface {
|
||||
ReconciliationSummary(context.Context, int) (ReconciliationSummary, error)
|
||||
ReconciliationIssues(context.Context, ReconciliationQuery) (Page[ReconciliationIssue], error)
|
||||
ReconciliationAssignees(context.Context) ([]ReconciliationAssignee, error)
|
||||
ReconciliationIssue(context.Context, string) (ReconciliationIssue, error)
|
||||
UpdateReconciliationIssue(context.Context, string, ReconciliationActionRequest) (ReconciliationIssue, error)
|
||||
AssignReconciliationIssue(context.Context, string, ReconciliationAssignmentRequest) (ReconciliationIssue, error)
|
||||
SetReconciliationIssueArchived(context.Context, string, bool, ReconciliationLifecycleRequest) (ReconciliationIssue, error)
|
||||
EvaluateReconciliation(context.Context) (ReconciliationEvaluationResult, error)
|
||||
}
|
||||
|
||||
@@ -36,9 +40,10 @@ type reconciliationFinding struct {
|
||||
}
|
||||
|
||||
type reconciliationExisting struct {
|
||||
ID string
|
||||
Status string
|
||||
Version int
|
||||
ID string
|
||||
Status string
|
||||
Version int
|
||||
Archived bool
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ensureReconciliationSchema(ctx context.Context) error {
|
||||
@@ -118,26 +123,31 @@ issue_id,action,from_status,to_status,actor,note
|
||||
continue
|
||||
}
|
||||
nextStatus := current.Status
|
||||
if current.Status == "recovered" || current.Status == "fixed" {
|
||||
if current.Archived || current.Status == "recovered" || current.Status == "fixed" {
|
||||
nextStatus = "pending"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
rule_code=?,category=?,severity=?,status=?,vin=?,plate=?,protocol_a=?,protocol_b=?,title=?,summary=?,evidence_json=?,
|
||||
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,version=version+1
|
||||
last_seen_at=?,occurrence_count=occurrence_count+1,recovered_at=NULL,
|
||||
archived_at=NULL,archived_by='',archive_reason='',version=version+1
|
||||
WHERE id=?`, finding.RuleCode, finding.Category, finding.Severity, nextStatus, finding.VIN, finding.Plate,
|
||||
finding.ProtocolA, finding.ProtocolB, finding.Title, finding.Summary, string(evidence), now, current.ID); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
if nextStatus != current.Status {
|
||||
if nextStatus != current.Status || current.Archived {
|
||||
note := "已恢复或已修复的差异再次出现"
|
||||
if current.Archived {
|
||||
note = "已归档差异再次出现,自动恢复到当前队列等待复核"
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
|
||||
issue_id,action,from_status,to_status,actor,note
|
||||
) VALUES(?,'reopen',?,?,'reconciliation-evaluator','已恢复或已修复的差异再次出现')`, current.ID, current.Status, nextStatus); err != nil {
|
||||
) VALUES(?,'reopen',?,?,'reconciliation-evaluator',?)`, current.ID, current.Status, nextStatus, note); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
for fingerprint, current := range existing {
|
||||
if seen[fingerprint] || !reconciliationAutoRecoverable(current.Status) {
|
||||
if seen[fingerprint] || current.Archived || !reconciliationAutoRecoverable(current.Status) {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
@@ -152,7 +162,7 @@ issue_id,action,from_status,to_status,actor,note
|
||||
result.Recovered++
|
||||
}
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_reconciliation_issue
|
||||
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
|
||||
WHERE archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')`).Scan(&result.Active); err != nil {
|
||||
return ReconciliationEvaluationResult{}, err
|
||||
}
|
||||
ruleCountsJSON, _ := json.Marshal(result.RuleCounts)
|
||||
@@ -181,7 +191,7 @@ func reconciliationFingerprint(finding reconciliationFinding) string {
|
||||
}
|
||||
|
||||
func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]reconciliationExisting, error) {
|
||||
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version FROM vehicle_reconciliation_issue FOR UPDATE`)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT fingerprint,id,status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue FOR UPDATE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -190,7 +200,7 @@ func loadReconciliationExisting(ctx context.Context, tx *sql.Tx) (map[string]rec
|
||||
for rows.Next() {
|
||||
var fingerprint string
|
||||
var item reconciliationExisting
|
||||
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version); err != nil {
|
||||
if err := rows.Scan(&fingerprint, &item.ID, &item.Status, &item.Version, &item.Archived); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[fingerprint] = item
|
||||
@@ -380,12 +390,15 @@ LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE st.id=1 AND st.active_version IS NOT NULL AND s.vin IS NULL`
|
||||
|
||||
func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where := []string{"1=1"}
|
||||
where := []string{"i.archived_at IS NULL"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
where[0] = "i.archived_at IS NOT NULL"
|
||||
}
|
||||
if value := strings.TrimSpace(query.Keyword); value != "" {
|
||||
like := "%" + value + "%"
|
||||
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ?)")
|
||||
args = append(args, like, like, like, like)
|
||||
where = append(where, "(i.vin LIKE ? OR i.plate LIKE ? OR i.title LIKE ? OR i.summary LIKE ? OR i.archived_by LIKE ? OR i.archive_reason LIKE ?)")
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
for column, value := range map[string]string{
|
||||
"i.rule_code": query.RuleCode, "i.category": query.Category, "i.severity": query.Severity,
|
||||
@@ -401,13 +414,28 @@ func buildReconciliationWhere(query ReconciliationQuery) (string, []any) {
|
||||
where = append(where, "i.status=?")
|
||||
args = append(args, status)
|
||||
}
|
||||
if owner := strings.TrimSpace(query.Owner); owner == "unassigned" {
|
||||
where = append(where, "i.assignee=''")
|
||||
} else if owner == "assigned" {
|
||||
where = append(where, "i.assignee<>''")
|
||||
} else if owner != "" && owner != "all" {
|
||||
where = append(where, "i.assignee=?")
|
||||
args = append(args, owner)
|
||||
}
|
||||
if sla := strings.TrimSpace(query.SLA); sla == "overdue" {
|
||||
where = append(where, "i.due_at IS NOT NULL AND i.due_at<NOW() AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
|
||||
} else if sla == "due_soon" {
|
||||
where = append(where, "i.due_at IS NOT NULL AND i.due_at>=NOW() AND i.due_at<=DATE_ADD(NOW(),INTERVAL 8 HOUR) AND i.status IN ('pending','confirmed_source_a','confirmed_source_b')")
|
||||
}
|
||||
return strings.Join(where, " AND "), args
|
||||
}
|
||||
|
||||
const reconciliationSelect = `SELECT i.id,i.rule_code,i.category,i.severity,i.status,i.vin,i.plate,i.protocol_a,i.protocol_b,
|
||||
i.title,i.summary,CAST(i.evidence_json AS CHAR),DATE_FORMAT(i.first_seen_at,'%Y-%m-%d %H:%i:%s'),
|
||||
DATE_FORMAT(i.last_seen_at,'%Y-%m-%d %H:%i:%s'),i.occurrence_count,
|
||||
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,i.version
|
||||
COALESCE(DATE_FORMAT(i.recovered_at,'%Y-%m-%d %H:%i:%s'),''),i.resolution_note,i.resolved_by,
|
||||
i.assignee,i.assigned_by,COALESCE(DATE_FORMAT(i.assigned_at,'%Y-%m-%d %H:%i:%s'),''),COALESCE(DATE_FORMAT(i.due_at,'%Y-%m-%d %H:%i:%s'),''),
|
||||
COALESCE(DATE_FORMAT(i.archived_at,'%Y-%m-%d %H:%i:%s'),''),i.archived_by,i.archive_reason,i.version
|
||||
FROM vehicle_reconciliation_issue i `
|
||||
|
||||
func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (ReconciliationIssue, error) {
|
||||
@@ -415,7 +443,9 @@ func scanReconciliationIssue(scanner interface{ Scan(...any) error }) (Reconcili
|
||||
var evidence string
|
||||
err := scanner.Scan(&item.ID, &item.RuleCode, &item.Category, &item.Severity, &item.Status, &item.VIN, &item.Plate,
|
||||
&item.ProtocolA, &item.ProtocolB, &item.Title, &item.Summary, &evidence, &item.FirstSeenAt, &item.LastSeenAt,
|
||||
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy, &item.Version)
|
||||
&item.OccurrenceCount, &item.RecoveredAt, &item.ResolutionNote, &item.ResolvedBy,
|
||||
&item.Assignee, &item.AssignedBy, &item.AssignedAt, &item.DueAt,
|
||||
&item.ArchivedAt, &item.ArchivedBy, &item.ArchiveReason, &item.Version)
|
||||
if err == nil {
|
||||
item.Evidence = map[string]any{}
|
||||
err = json.Unmarshal([]byte(evidence), &item.Evidence)
|
||||
@@ -439,8 +469,12 @@ func (s *ProductionStore) ReconciliationIssues(ctx context.Context, query Reconc
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
listArgs := append(append([]any(nil), args...), query.Limit, query.Offset)
|
||||
orderBy := "FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC"
|
||||
if strings.EqualFold(strings.TrimSpace(query.Scope), "archived") {
|
||||
orderBy = "i.archived_at DESC,i.id DESC"
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, reconciliationSelect+`WHERE `+where+`
|
||||
ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id DESC LIMIT ? OFFSET ?`, listArgs...)
|
||||
ORDER BY `+orderBy+` LIMIT ? OFFSET ?`, listArgs...)
|
||||
if err != nil {
|
||||
return Page[ReconciliationIssue]{}, err
|
||||
}
|
||||
@@ -456,6 +490,79 @@ ORDER BY FIELD(i.severity,'critical','major','minor'),i.last_seen_at DESC,i.id D
|
||||
return Page[ReconciliationIssue]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ReconciliationAssignees(ctx context.Context) ([]ReconciliationAssignee, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string]ReconciliationAssignee{}
|
||||
accountRows, err := s.db.QueryContext(ctx, `SELECT display_name,username FROM platform_user
|
||||
WHERE user_type='admin' AND status='enabled' ORDER BY display_name,username LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for accountRows.Next() {
|
||||
var item ReconciliationAssignee
|
||||
if err := accountRows.Scan(&item.Name, &item.Username); err != nil {
|
||||
accountRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
item.Name = strings.TrimSpace(item.Name)
|
||||
item.Username = strings.TrimSpace(item.Username)
|
||||
item.Source = "account"
|
||||
if item.Name == "" {
|
||||
item.Name = item.Username
|
||||
}
|
||||
if item.Name != "" {
|
||||
byName[strings.ToLower(item.Name)] = item
|
||||
}
|
||||
}
|
||||
if err := accountRows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
historyRows, err := s.db.QueryContext(ctx, `SELECT assignee,
|
||||
SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),
|
||||
COALESCE(DATE_FORMAT(MAX(assigned_at),'%Y-%m-%d %H:%i:%s'),'')
|
||||
FROM vehicle_reconciliation_issue WHERE assignee<>'' AND archived_at IS NULL GROUP BY assignee
|
||||
ORDER BY MAX(assigned_at) DESC,assignee LIMIT 100`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for historyRows.Next() {
|
||||
var name, lastAssignedAt string
|
||||
var activeCount int
|
||||
if err := historyRows.Scan(&name, &activeCount, &lastAssignedAt); err != nil {
|
||||
historyRows.Close()
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
item, exists := byName[key]
|
||||
if !exists {
|
||||
item = ReconciliationAssignee{Name: name, Source: "history"}
|
||||
}
|
||||
item.ActiveCount = activeCount
|
||||
item.LastAssignedAt = lastAssignedAt
|
||||
byName[key] = item
|
||||
}
|
||||
if err := historyRows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]ReconciliationAssignee, 0, len(byName))
|
||||
for _, item := range byName {
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].ActiveCount != items[j].ActiveCount {
|
||||
return items[i].ActiveCount > items[j].ActiveCount
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *ProductionStore) ReconciliationIssue(ctx context.Context, id string) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
@@ -511,7 +618,8 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion); err != nil {
|
||||
var archived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
@@ -520,6 +628,9 @@ func (s *ProductionStore) UpdateReconciliationIssue(ctx context.Context, id stri
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
status=?,resolution_note=?,resolved_by=?,
|
||||
recovered_at=CASE WHEN ? IN ('fixed','no_action') THEN NOW(3) ELSE NULL END,
|
||||
@@ -543,6 +654,115 @@ issue_id,action,from_status,to_status,actor,note
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) AssignReconciliationIssue(ctx context.Context, id string, request ReconciliationAssignmentRequest) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
request.Assignee = strings.TrimSpace(request.Assignee)
|
||||
if request.Assignee == "" || len([]rune(request.Assignee)) > 128 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNEE_INVALID", Message: "请输入有效负责人"}
|
||||
}
|
||||
dueAt, err := time.Parse(time.RFC3339, strings.TrimSpace(request.DueAt))
|
||||
if err != nil || !dueAt.After(time.Now()) {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_DUE_AT_INVALID", Message: "请选择有效的未来处理期限"}
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
var archived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, &archived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVED_READ_ONLY", Message: "审计归档中的差异只读;请先恢复到当前队列"}
|
||||
}
|
||||
if currentStatus != "pending" && currentStatus != "confirmed_source_a" && currentStatus != "confirmed_source_b" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ASSIGNMENT_CLOSED", Message: "已结束的差异不能重新分配责任"}
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET assignee=?,assigned_by=?,assigned_at=NOW(3),due_at=?,version=version+1 WHERE id=? AND version=?`, request.Assignee, request.Actor, dueAt.UTC(), id, request.Version)
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录更新冲突"}
|
||||
}
|
||||
note := fmt.Sprintf("交接给 %s,处理期限 %s", request.Assignee, dueAt.In(time.Local).Format("2006-01-02 15:04"))
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(issue_id,action,from_status,to_status,actor,note) VALUES(?,'assign','','',?,?)`, id, request.Actor, note); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ProductionStore) SetReconciliationIssueArchived(ctx context.Context, id string, archived bool, request ReconciliationLifecycleRequest) (ReconciliationIssue, error) {
|
||||
if err := s.ensureReconciliationSchema(ctx); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{})
|
||||
if err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var currentStatus string
|
||||
var currentVersion int
|
||||
var currentlyArchived bool
|
||||
if err := tx.QueryRowContext(ctx, `SELECT status,version,archived_at IS NOT NULL
|
||||
FROM vehicle_reconciliation_issue WHERE id=? FOR UPDATE`, id).Scan(¤tStatus, ¤tVersion, ¤tlyArchived); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_FOUND", Message: "差异记录不存在"}
|
||||
}
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if currentVersion != request.Version {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_VERSION_CONFLICT", Message: "差异记录已更新,请刷新后重试"}
|
||||
}
|
||||
if currentlyArchived == archived {
|
||||
if archived {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ALREADY_ARCHIVED", Message: "差异记录已经在审计归档中"}
|
||||
}
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_NOT_ARCHIVED", Message: "差异记录当前不在审计归档中"}
|
||||
}
|
||||
action := "restore"
|
||||
if archived {
|
||||
if currentStatus != "fixed" && currentStatus != "no_action" && currentStatus != "recovered" {
|
||||
return ReconciliationIssue{}, clientError{Code: "RECONCILIATION_ARCHIVE_OPEN_ISSUE", Message: "只有已修复、无需处理或已恢复的差异才能归档"}
|
||||
}
|
||||
action = "archive"
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
archived_at=NOW(3),archived_by=?,archive_reason=?,version=version+1
|
||||
WHERE id=? AND version=?`, request.Actor, request.Reason, id, request.Version); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
} else {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_reconciliation_issue SET
|
||||
archived_at=NULL,archived_by='',archive_reason='',version=version+1
|
||||
WHERE id=? AND version=?`, id, request.Version); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_reconciliation_action(
|
||||
issue_id,action,from_status,to_status,actor,note
|
||||
) VALUES(?,?,?,?,?,?)`, id, action, currentStatus, currentStatus, request.Actor, request.Reason); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ReconciliationIssue{}, err
|
||||
}
|
||||
return s.ReconciliationIssue(ctx, id)
|
||||
}
|
||||
|
||||
const reconciliationTrendSQL = `SELECT DATE_FORMAT(MIN(finished_at),'%Y-%m-%d'),
|
||||
CAST(SUBSTRING_INDEX(GROUP_CONCAT(detected_count ORDER BY finished_at DESC),',',1) AS UNSIGNED),
|
||||
SUM(new_count),
|
||||
@@ -561,12 +781,14 @@ func (s *ProductionStore) ReconciliationSummary(ctx context.Context, days int) (
|
||||
}
|
||||
var result ReconciliationSummary
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT
|
||||
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(status='pending'),0),
|
||||
COALESCE(SUM(status IN ('confirmed_source_a','confirmed_source_b')),0),
|
||||
COALESCE(SUM(status='recovered'),0),
|
||||
COALESCE(SUM(status IN ('pending','confirmed_source_a','confirmed_source_b') AND first_seen_at<DATE_SUB(NOW(),INTERVAL 24 HOUR)),0)
|
||||
FROM vehicle_reconciliation_issue`).Scan(&result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
|
||||
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))<NOW()),0)
|
||||
FROM vehicle_reconciliation_issue`).Scan(&result.Current, &result.Archived, &result.Active, &result.Pending, &result.Confirmed, &result.Recovered, &result.OverSLA); err != nil {
|
||||
return ReconciliationSummary{}, err
|
||||
}
|
||||
var err error
|
||||
@@ -600,7 +822,7 @@ func (s *ProductionStore) reconciliationBuckets(ctx context.Context, column stri
|
||||
return nil, fmt.Errorf("unsupported reconciliation bucket")
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+column+`,COUNT(*) FROM vehicle_reconciliation_issue
|
||||
WHERE status IN ('pending','confirmed_source_a','confirmed_source_b')
|
||||
WHERE archived_at IS NULL AND status IN ('pending','confirmed_source_a','confirmed_source_b')
|
||||
GROUP BY `+column+` ORDER BY COUNT(*) DESC,`+column)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,193 @@ func exportAdminContext() context.Context {
|
||||
return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"})
|
||||
}
|
||||
|
||||
type concurrentHistoryStore struct {
|
||||
*MockStore
|
||||
mu sync.Mutex
|
||||
active int
|
||||
max int
|
||||
}
|
||||
|
||||
func (s *concurrentHistoryStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
s.mu.Lock()
|
||||
s.active++
|
||||
if s.active > s.max {
|
||||
s.max = s.active
|
||||
}
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
|
||||
}
|
||||
|
||||
func TestHistoryDataQueriesMultipleVehiclesConcurrentlyAndPreservesReceiptOrder(t *testing.T) {
|
||||
store := &concurrentHistoryStore{MockStore: NewMockStore()}
|
||||
response, err := NewService(store).HistoryData(context.Background(), url.Values{
|
||||
"keywords": {"粤AG18312,川AHTWO1,豫A88888"},
|
||||
"category": {"location"},
|
||||
"dateFrom": {"2026-06-23T00:00"},
|
||||
"dateTo": {"2026-07-23T00:00"},
|
||||
"limit": {"10"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.max < 2 {
|
||||
t.Fatalf("multi-vehicle history requests should overlap, max concurrency=%d", store.max)
|
||||
}
|
||||
want := []string{"粤AG18312", "川AHTWO1", "豫A88888"}
|
||||
if len(response.Summary.Vehicles) != len(want) {
|
||||
t.Fatalf("unexpected receipt count: %+v", response.Summary.Vehicles)
|
||||
}
|
||||
for index, keyword := range want {
|
||||
if response.Summary.Vehicles[index].Keyword != keyword {
|
||||
t.Fatalf("receipt order changed at %d: %+v", index, response.Summary.Vehicles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchUpdateReconciliationIssuesValidatesScopeAndKeepsPartialSuccess(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{Name: "批量处置员", Role: "operator", UserType: "operator"})
|
||||
result, err := service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{
|
||||
Items: []ReconciliationBatchActionItem{
|
||||
{ID: "reconciliation-demo-position", Version: 1},
|
||||
{ID: "missing-issue", Version: 1},
|
||||
},
|
||||
Status: "no_action",
|
||||
Note: "已核对本页所选问题,确认无需修改原始数据",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
if result.Succeeded[0].ResolvedBy != "批量处置员" || result.Skipped[0].Code != "RECONCILIATION_NOT_FOUND" {
|
||||
t.Fatalf("unexpected per-item evidence: %+v", result)
|
||||
}
|
||||
|
||||
_, err = service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{
|
||||
Items: []ReconciliationBatchActionItem{{ID: "reconciliation-demo-position", Version: 2}, {ID: "reconciliation-demo-position", Version: 2}},
|
||||
Status: "fixed",
|
||||
Note: "重复选择应在写入前被拒绝",
|
||||
})
|
||||
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_BATCH_DUPLICATE_ID" {
|
||||
t.Fatalf("duplicate ids should be rejected, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationDirectoryIncludesCurrentPrincipalAndHistoricalOwners(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
ctx := WithPrincipal(context.Background(), Principal{Name: "当前值班员", Username: "operator-a", Role: "operator", UserType: "operator"})
|
||||
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
if _, err := service.AssignReconciliationIssue(ctx, "reconciliation-demo-position", ReconciliationAssignmentRequest{Version: 1, Assignee: "定位运维组", DueAt: dueAt}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.ReconciliationAssignees(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || !items[0].Current || items[0].Name != "当前值班员" || items[1].Name != "定位运维组" || items[1].ActiveCount != 1 {
|
||||
t.Fatalf("unexpected assignee directory: %+v", items)
|
||||
}
|
||||
filtered, err := service.ReconciliationAssignees(ctx, "operator-a")
|
||||
if err != nil || len(filtered) != 1 || filtered[0].Name != "当前值班员" {
|
||||
t.Fatalf("search should match username: items=%+v err=%v", filtered, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportReconciliationIssuesUsesFullFilterAndSafeCSV(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
store.reconciliationIssues[0].Plate = "=FORMULA"
|
||||
service := NewService(store)
|
||||
file, err := service.ExportReconciliationIssues(context.Background(), ReconciliationQuery{Status: "active", Owner: "unassigned"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(file.Content)
|
||||
if file.RowCount != 1 || !strings.HasPrefix(text, "\xEF\xBB\xBF") || !strings.Contains(text, "差异编号,等级,状态") || !strings.Contains(text, "'=FORMULA") {
|
||||
t.Fatalf("unexpected export file: rows=%d name=%q content=%q", file.RowCount, file.Name, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconciliationArchiveLifecycleSeparatesCurrentAndAuditScopes(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Username: "quality-admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
archived, err := service.SetReconciliationIssueArchived(admin, "reconciliation-demo-source", true, ReconciliationLifecycleRequest{
|
||||
Version: 2,
|
||||
Reason: "问题已自动恢复并完成月度复核,转入长期审计留存",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if archived.ArchivedAt == "" || archived.ArchivedBy != "质量管理员" || archived.Version != 3 {
|
||||
t.Fatalf("archive receipt incomplete: %+v", archived)
|
||||
}
|
||||
current, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "current", Status: "all", Limit: 20})
|
||||
if err != nil || current.Total != 1 {
|
||||
t.Fatalf("current scope should exclude archived issue: page=%+v err=%v", current, err)
|
||||
}
|
||||
audit, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "archived", Keyword: "月度复核", Status: "all", Limit: 20})
|
||||
if err != nil || audit.Total != 1 || audit.Items[0].ID != archived.ID {
|
||||
t.Fatalf("archive scope should search audit evidence: page=%+v err=%v", audit, err)
|
||||
}
|
||||
if _, err := service.UpdateReconciliationIssue(admin, archived.ID, ReconciliationActionRequest{Version: archived.Version, Status: "pending"}); err == nil {
|
||||
t.Fatal("archived issue must be read-only")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_ARCHIVED_READ_ONLY" {
|
||||
t.Fatalf("unexpected read-only error: %v", err)
|
||||
}
|
||||
|
||||
operator := WithPrincipal(context.Background(), Principal{Name: "质量操作员", Role: "operator", UserType: "operator"})
|
||||
if _, err := service.SetReconciliationIssueArchived(operator, archived.ID, false, ReconciliationLifecycleRequest{Version: archived.Version, Reason: "重新进入当前队列复核"}); err == nil {
|
||||
t.Fatal("operator must not restore audit records")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
|
||||
t.Fatalf("unexpected permission error: %v", err)
|
||||
}
|
||||
|
||||
restored, err := service.SetReconciliationIssueArchived(admin, archived.ID, false, ReconciliationLifecycleRequest{
|
||||
Version: archived.Version,
|
||||
Reason: "收到新的来源接入证据,需要重新核对",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if restored.ArchivedAt != "" || restored.Status != "recovered" || restored.Version != 4 {
|
||||
t.Fatalf("restore must preserve conclusion while returning to current scope: %+v", restored)
|
||||
}
|
||||
if len(restored.Actions) < 2 || restored.Actions[len(restored.Actions)-1].Action != "restore" {
|
||||
t.Fatalf("restore audit missing: %+v", restored.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchReconciliationArchiveKeepsPartialFailuresRecoverable(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Role: "admin", UserType: "admin"})
|
||||
result, err := service.BatchSetReconciliationIssuesArchived(admin, true, ReconciliationBatchLifecycleRequest{
|
||||
Items: []ReconciliationBatchActionItem{
|
||||
{ID: "reconciliation-demo-source", Version: 2},
|
||||
{ID: "reconciliation-demo-position", Version: 1},
|
||||
},
|
||||
Reason: "已完成周期复核,批量整理到审计归档",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 {
|
||||
t.Fatalf("unexpected batch archive result: %+v", result)
|
||||
}
|
||||
if result.Succeeded[0].ID != "reconciliation-demo-source" || result.Skipped[0].Code != "RECONCILIATION_ARCHIVE_OPEN_ISSUE" {
|
||||
t.Fatalf("batch archive should keep open issue unchanged: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
@@ -51,6 +238,309 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportPageSearchesAndPaginatesBeyondFirstTwenty(t *testing.T) {
|
||||
exportDir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: exportDir})
|
||||
now := time.Now().UTC()
|
||||
service.exportsMu.Lock()
|
||||
for index := 0; index < 36; index++ {
|
||||
status := "completed"
|
||||
if index%5 == 0 {
|
||||
status = "failed"
|
||||
}
|
||||
id := fmt.Sprintf("exp_%02d", index+1)
|
||||
filePath := ""
|
||||
if status == "completed" {
|
||||
filePath = filepath.Join(exportDir, id+".csv")
|
||||
if err := os.WriteFile(filePath, []byte("vin\n"), 0o640); err != nil {
|
||||
service.exportsMu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ownerID := "local:subject:1"
|
||||
ownerUsername := "admin"
|
||||
if index >= 30 {
|
||||
ownerID = "local:subject:2"
|
||||
ownerUsername = "audit-peer"
|
||||
}
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: fmt.Sprintf("历史归档 %02d", index+1), Status: status, Category: "location", Format: "csv",
|
||||
Keywords: []string{fmt.Sprintf("VIN%03d", index+1)}, VehicleVINs: []string{fmt.Sprintf("VIN%03d", index+1)},
|
||||
OwnerID: ownerID, OwnerUsername: ownerUsername, CreatedAt: now.Add(-time.Duration(index) * time.Hour).Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339),
|
||||
filePath: filePath,
|
||||
}
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 10, Offset: 20})
|
||||
if page.Total != 36 || len(page.Items) != 10 || page.Items[0].ID != "exp_21" || page.Summary.Total != 36 || page.Summary.Recoverable != 8 {
|
||||
t.Fatalf("unexpected page: %+v", page)
|
||||
}
|
||||
filtered := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Search: "VIN035", Status: "completed", Limit: 10})
|
||||
if filtered.Total != 1 || len(filtered.Items) != 1 || filtered.Items[0].ID != "exp_35" {
|
||||
t.Fatalf("unexpected filtered page: %+v", filtered)
|
||||
}
|
||||
mine := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{OwnerScope: "mine", Limit: 10})
|
||||
if mine.Total != 30 || len(mine.Items) != 10 || mine.Summary.Total != 30 || mine.Items[0].OwnerUsername != "admin" {
|
||||
t.Fatalf("admin mine scope should isolate the current account without weakening global audit access: %+v", mine)
|
||||
}
|
||||
emptyMine := service.ListHistoryExportsPage(WithPrincipal(context.Background(), Principal{SubjectID: "missing-admin", Username: "missing-admin", Role: "admin", UserType: "admin", AuthProvider: "local"}), HistoryExportQuery{OwnerScope: "mine", Limit: 10})
|
||||
if emptyMine.Items == nil || emptyMine.Total != 0 || emptyMine.Summary.Total != 0 {
|
||||
t.Fatalf("empty owner scope should return a stable empty collection and zero summary: %+v", emptyMine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportPageKeepsStableDeepPaginationAcrossTenThousandTasks(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
now := time.Now().UTC()
|
||||
service.exportsMu.Lock()
|
||||
for index := 0; index < 10_005; index++ {
|
||||
id := fmt.Sprintf("scale_%05d", index+1)
|
||||
createdAt := now.Add(-time.Duration(index) * time.Second).Format(time.RFC3339)
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: "规模任务 " + id, Status: "cancelled", Category: "location", Format: "csv",
|
||||
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: createdAt, UpdatedAt: createdAt, CancelledAt: createdAt, CancelledBy: "admin",
|
||||
}
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 9_950})
|
||||
if page.Total != 10_005 || page.Limit != 50 || page.Offset != 9_950 || len(page.Items) != 50 {
|
||||
t.Fatalf("unexpected deep page metadata: total=%d limit=%d offset=%d items=%d", page.Total, page.Limit, page.Offset, len(page.Items))
|
||||
}
|
||||
if page.Items[0].ID != "scale_09951" || page.Items[49].ID != "scale_10000" || page.Summary.Cancelled != 10_005 {
|
||||
t.Fatalf("deep page must keep stable order and complete summary: first=%s last=%s summary=%+v", page.Items[0].ID, page.Items[49].ID, page.Summary)
|
||||
}
|
||||
tail := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 10_000})
|
||||
if tail.Total != 10_005 || len(tail.Items) != 5 || tail.Items[0].ID != "scale_10001" || tail.Items[4].ID != "scale_10005" {
|
||||
t.Fatalf("unexpected tail page: %+v", tail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportArchiveSeparatesCurrentWorkFromAuditRecords(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
ctx := exportAdminContext()
|
||||
nowTime := time.Now().UTC()
|
||||
now := nowTime.Format(time.RFC3339)
|
||||
archiveFile := filepath.Join(dir, "exp_completed_archive.csv")
|
||||
if err := os.WriteFile(archiveFile, []byte("vin\nVIN-ARCHIVE\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.exportsMu.Lock()
|
||||
service.exports["exp_completed_archive"] = &HistoryExportJob{
|
||||
ID: "exp_completed_archive", Name: "月度历史审计包", Status: "completed", Format: "csv", Category: "location",
|
||||
Keywords: []string{"VIN-ARCHIVE"}, VehicleVINs: []string{"VIN-ARCHIVE"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
DateFrom: "2026-06-01T00:00", DateTo: "2026-06-30T23:59", CreatedAt: now, UpdatedAt: now, CompletedAt: now,
|
||||
ExpiresAt: nowTime.Add(12 * time.Hour).Format(time.RFC3339), Evidence: "车辆范围与账号权限已固化", filePath: archiveFile,
|
||||
}
|
||||
service.exports["exp_running_visible"] = &HistoryExportJob{
|
||||
ID: "exp_running_visible", Name: "执行中任务", Status: "running", Format: "csv", Category: "location",
|
||||
Keywords: []string{"VIN-RUNNING"}, VehicleVINs: []string{"VIN-RUNNING"}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
archived, err := service.SetHistoryExportArchived(ctx, "exp_completed_archive", true)
|
||||
if err != nil || archived.ArchivedAt == "" || archived.ArchivedBy != "admin" || !strings.Contains(archived.Evidence, "归档") {
|
||||
t.Fatalf("archive failed: job=%+v err=%v", archived, err)
|
||||
}
|
||||
current := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "current", Limit: 10})
|
||||
if current.Total != 1 || len(current.Items) != 1 || current.Items[0].ID != "exp_running_visible" || current.Summary.Current != 1 || current.Summary.Archived != 1 || current.Summary.Active != 1 || current.Summary.Completed != 0 || current.Summary.Expiring != 0 {
|
||||
t.Fatalf("current queue should exclude archived audit records: %+v", current)
|
||||
}
|
||||
audit := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Search: "车辆范围与账号权限", Limit: 10})
|
||||
if audit.Total != 1 || len(audit.Items) != 1 || audit.Items[0].ID != "exp_completed_archive" || audit.Summary.Active != 0 || audit.Summary.Completed != 1 || audit.Summary.Expiring != 1 {
|
||||
t.Fatalf("archived evidence should remain searchable: %+v", audit)
|
||||
}
|
||||
expiring := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Status: "expiring", Limit: 10})
|
||||
if expiring.Total != 1 || len(expiring.Items) != 1 || expiring.Items[0].ID != "exp_completed_archive" {
|
||||
t.Fatalf("expiring filter should isolate downloadable files inside the selected archive scope: %+v", expiring)
|
||||
}
|
||||
if _, err := service.SetHistoryExportArchived(ctx, "exp_running_visible", true); err == nil {
|
||||
t.Fatal("active jobs must remain visible and cannot be archived")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_NOT_ARCHIVABLE" {
|
||||
t.Fatalf("unexpected active archive error: %v", err)
|
||||
}
|
||||
batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_completed_archive"}, Action: "restore"})
|
||||
if err != nil || len(batch.Succeeded) != 1 || batch.Succeeded[0].ArchivedAt != "" || !strings.Contains(batch.Succeeded[0].Evidence, "移回当前") {
|
||||
t.Fatalf("restore batch failed: result=%+v err=%v", batch, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupPreviewsProtectsAndAuditsPermanentRemoval(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
ctx := exportAdminContext()
|
||||
now := time.Now().UTC()
|
||||
oldArchive := now.Add(-200 * 24 * time.Hour).Format(time.RFC3339)
|
||||
recentArchive := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339)
|
||||
service.exportsMu.Lock()
|
||||
service.exports["cleanup-old"] = &HistoryExportJob{
|
||||
ID: "cleanup-old", Name: "待清理年度包", Status: "expired", Category: "location", Format: "csv",
|
||||
Keywords: []string{"VIN-CLEAN"}, VehicleVINs: []string{"VIN-CLEAN"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 2048, Evidence: "原范围保留",
|
||||
}
|
||||
service.exports["cleanup-protected"] = &HistoryExportJob{
|
||||
ID: "cleanup-protected", Name: "长期合规包", Status: "expired", Category: "raw", Format: "csv",
|
||||
Keywords: []string{"VIN-PROTECT"}, VehicleVINs: []string{"VIN-PROTECT"}, OwnerID: "local:subject:other", OwnerUsername: "audit-peer",
|
||||
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 4096, Evidence: "原范围保留",
|
||||
CleanupProtectedAt: now.Add(-24 * time.Hour).Format(time.RFC3339), CleanupProtectedBy: "admin", CleanupProtectionReason: "年度监管复核",
|
||||
}
|
||||
service.exports["cleanup-recent"] = &HistoryExportJob{
|
||||
ID: "cleanup-recent", Name: "近期归档", Status: "cancelled", Category: "location", Format: "csv",
|
||||
Keywords: []string{"VIN-RECENT"}, VehicleVINs: []string{"VIN-RECENT"}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: recentArchive, UpdatedAt: recentArchive, ArchivedAt: recentArchive, ArchivedBy: "admin", Evidence: "近期归档",
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
preview, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if preview.ArchivedCount != 3 || preview.CandidateCount != 1 || preview.PlannedCount != 1 || preview.ProtectedCount != 1 || len(preview.Candidates) != 1 || preview.Candidates[0].ID != "cleanup-old" || len(preview.Protected) != 1 || preview.Protected[0].ID != "cleanup-protected" {
|
||||
t.Fatalf("unexpected cleanup preview: %+v", preview)
|
||||
}
|
||||
|
||||
protected, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: true, Reason: "诉讼证据保全"})
|
||||
if err != nil || protected.CleanupProtectedAt == "" || protected.CleanupProtectionReason != "诉讼证据保全" || !strings.Contains(protected.Evidence, "长期保留") {
|
||||
t.Fatalf("protect cleanup candidate failed: job=%+v err=%v", protected, err)
|
||||
}
|
||||
if _, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: preview.PreviewToken}); err == nil {
|
||||
t.Fatal("stale preview token must be rejected after protection changes")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_PREVIEW_STALE" {
|
||||
t.Fatalf("unexpected stale preview error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: false}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"})
|
||||
if err != nil || fresh.CandidateCount != 1 || fresh.ProtectedCount != 1 {
|
||||
t.Fatalf("unexpected refreshed cleanup preview: %+v err=%v", fresh, err)
|
||||
}
|
||||
result, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: fresh.PreviewToken})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Record.Cleaned != 1 || result.Record.Protected != 1 || result.Record.Actor != "admin" || len(result.Deleted) != 1 || result.Deleted[0].ID != "cleanup-old" {
|
||||
t.Fatalf("unexpected cleanup result: %+v", result)
|
||||
}
|
||||
page := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Limit: 10})
|
||||
if page.Total != 2 || page.Summary.Archived != 2 {
|
||||
t.Fatalf("cleanup must leave protected and recent records: %+v", page)
|
||||
}
|
||||
audit, err := service.HistoryExportCleanupAudit(ctx, 20)
|
||||
if err != nil || len(audit) != 1 || audit[0].ID != result.Record.ID || audit[0].CandidateDigest != fresh.PreviewToken {
|
||||
t.Fatalf("cleanup audit must remain independently searchable: %+v err=%v", audit, err)
|
||||
}
|
||||
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloadedAudit, err := reloaded.HistoryExportCleanupAudit(ctx, 20)
|
||||
if err != nil || len(reloadedAudit) != 1 || reloadedAudit[0].ID != result.Record.ID {
|
||||
t.Fatalf("cleanup audit must survive restart: %+v err=%v", reloadedAudit, err)
|
||||
}
|
||||
if _, exists := reloaded.historyExportJob("cleanup-old"); exists {
|
||||
t.Fatal("cleaned record must not return after restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportCleanupRequiresAdministratorAndPreservesMoreThanFiveHundredRecords(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
oldArchive := time.Now().UTC().Add(-400 * 24 * time.Hour).Format(time.RFC3339)
|
||||
service.exportsMu.Lock()
|
||||
for index := 1; index <= 550; index++ {
|
||||
id := fmt.Sprintf("persist-%03d", index)
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: id, Status: "cancelled", Category: "location", Format: "csv",
|
||||
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin",
|
||||
}
|
||||
}
|
||||
if err := service.persistHistoryExportsLocked(); err != nil {
|
||||
service.exportsMu.Unlock()
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
if page := reloaded.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Limit: 50}); page.Summary.Archived != 550 {
|
||||
t.Fatalf("explicit cleanup must replace silent 500-record truncation: %+v", page.Summary)
|
||||
}
|
||||
operator := WithPrincipal(context.Background(), Principal{SubjectID: "operator-1", Username: "operator", Role: "operator", UserType: "operator", AuthProvider: "local"})
|
||||
if _, err := reloaded.PreviewHistoryExportCleanup(operator, HistoryExportCleanupQuery{OlderThanDays: 365}); err == nil {
|
||||
t.Fatal("operator must not preview permanent archive cleanup")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_ADMIN_REQUIRED" {
|
||||
t.Fatalf("unexpected cleanup permission error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportPageSortsByScopeActivityAndFutureExpiry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
now := time.Now().UTC()
|
||||
writeJob := func(id string, createdAt, archivedAt, expiresAt time.Time) {
|
||||
path := filepath.Join(dir, id+".csv")
|
||||
if err := os.WriteFile(path, []byte("vin\n"+id+"\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service.exports[id] = &HistoryExportJob{
|
||||
ID: id, Name: id, Status: "completed", Format: "csv", Category: "location",
|
||||
Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin",
|
||||
CreatedAt: createdAt.Format(time.RFC3339), UpdatedAt: archivedAt.Format(time.RFC3339), CompletedAt: createdAt.Format(time.RFC3339),
|
||||
ArchivedAt: archivedAt.Format(time.RFC3339), ExpiresAt: expiresAt.Format(time.RFC3339), filePath: path,
|
||||
}
|
||||
}
|
||||
service.exportsMu.Lock()
|
||||
writeJob("old-created-recently-archived", now.Add(-72*time.Hour), now.Add(-time.Minute), now.Add(12*time.Hour))
|
||||
writeJob("new-created-older-archive", now.Add(-24*time.Hour), now.Add(-2*time.Hour), now.Add(4*time.Hour))
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
recent := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "recent", Limit: 10})
|
||||
if len(recent.Items) != 2 || recent.Items[0].ID != "old-created-recently-archived" {
|
||||
t.Fatalf("archived scope should prioritize the latest archive activity, not original creation time: %+v", recent.Items)
|
||||
}
|
||||
expiry := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "expiry", Limit: 10})
|
||||
if len(expiry.Items) != 2 || expiry.Items[0].ID != "new-created-older-archive" {
|
||||
t.Fatalf("expiry sort should prioritize the first downloadable file to expire: %+v", expiry.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
owner := exportAdminContext()
|
||||
preference, err := service.UpdateHistoryPreferences(owner, HistoryPreferences{
|
||||
RetentionDays: 30,
|
||||
FieldViews: []HistoryFieldView{{ID: "location:交付核验", Name: "交付核验", Category: "location", Keys: []string{"speedKmh", "socPercent"}}},
|
||||
})
|
||||
if err != nil || preference.Revision != 1 || preference.RetentionDays != 30 || len(preference.FieldViews) != 1 {
|
||||
t.Fatalf("save preferences: preference=%+v err=%v", preference, err)
|
||||
}
|
||||
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
restored, err := reloaded.HistoryPreferences(owner)
|
||||
if err != nil || restored.RetentionDays != 30 || len(restored.FieldViews) != 1 || restored.FieldViews[0].Name != "交付核验" {
|
||||
t.Fatalf("restored preferences: preference=%+v err=%v", restored, err)
|
||||
}
|
||||
|
||||
reloaded.exportSlots <- struct{}{}
|
||||
job, err := reloaded.CreateHistoryExport(owner, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"})
|
||||
if err != nil {
|
||||
<-reloaded.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.RetentionDays != 30 || !strings.Contains(job.Evidence, "文件保留 30 天") {
|
||||
<-reloaded.exportSlots
|
||||
t.Fatalf("account retention not applied: %+v", job)
|
||||
}
|
||||
if _, err := reloaded.CancelHistoryExport(owner, job.ID); err != nil {
|
||||
<-reloaded.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
<-reloaded.exportSlots
|
||||
}
|
||||
|
||||
type countingStore struct {
|
||||
*MockStore
|
||||
vehiclesCalls int
|
||||
@@ -59,14 +549,81 @@ type countingStore struct {
|
||||
lastVehicleQuery url.Values
|
||||
lastRealtimeQuery url.Values
|
||||
lastHistoryQuery url.Values
|
||||
historyQueries []url.Values
|
||||
rawFrameQueries []RawFrameQuery
|
||||
lastDailyMileageQuery url.Values
|
||||
lastMileageStatisticsQuery url.Values
|
||||
}
|
||||
|
||||
type hydrogenMetricsStore struct{ *MockStore }
|
||||
|
||||
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
|
||||
consumption, rate := 3.1, 5.5
|
||||
return Page[DailyMileageRow]{Items: []DailyMileageRow{{
|
||||
VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7,
|
||||
PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (MileageSummary, error) {
|
||||
return MileageSummary{TotalMileageKm: 88.7, TotalPureHydrogenMileageKm: 56.2}, nil
|
||||
}
|
||||
|
||||
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
|
||||
consumption, rate := 3.1, 5.5
|
||||
return MileageStatistics{
|
||||
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
||||
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
Trend: []MileageTrendPoint{{
|
||||
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
|
||||
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newCountingStore() *countingStore {
|
||||
return &countingStore{MockStore: NewMockStore()}
|
||||
}
|
||||
|
||||
func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T) {
|
||||
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
|
||||
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
customer := WithPrincipal(context.Background(), Principal{
|
||||
Name: "业务客户", Role: "customer", UserType: "customer",
|
||||
VehicleVINs: []string{"LB9A32A24R0LS1426"},
|
||||
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
|
||||
})
|
||||
query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}}
|
||||
|
||||
daily, err := service.DailyMileage(customer, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil {
|
||||
t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
summary, err := service.MileageStatistics(customer, query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.PeriodPureHydrogenMileageKm != 0 || summary.HydrogenMatchedMileageKm != 0 || summary.HydrogenDataDays != 0 || summary.PeriodHydrogenConsumptionKg != 0 || summary.HydrogenConsumptionKgPer100Km != nil ||
|
||||
len(summary.Trend) != 1 || summary.Trend[0].PureHydrogenMileageKm != 0 || summary.Trend[0].HydrogenMatchedMileageKm != 0 || summary.Trend[0].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("customer statistics exposed hydrogen metrics: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.T) {
|
||||
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
|
||||
query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}}
|
||||
daily, err := service.DailyMileage(exportAdminContext(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
|
||||
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||||
s.vehiclesCalls++
|
||||
s.lastVehicleQuery = cloneValues(query)
|
||||
@@ -81,14 +638,60 @@ func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (
|
||||
|
||||
func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
|
||||
s.lastHistoryQuery = cloneValues(query)
|
||||
s.historyQueries = append(s.historyQueries, cloneValues(query))
|
||||
return s.MockStore.HistoryLocationsFromTDengine(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
s.rawFrameQueries = append(s.rawFrameQueries, query)
|
||||
return s.MockStore.RawFrames(ctx, query)
|
||||
}
|
||||
|
||||
func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
|
||||
s.lastDailyMileageQuery = cloneValues(query)
|
||||
return s.MockStore.DailyMileage(ctx, query)
|
||||
}
|
||||
|
||||
func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !detail.LookupResolved {
|
||||
t.Fatalf("vehicle detail should resolve the requested VIN: %+v", detail)
|
||||
}
|
||||
if len(store.historyQueries) == 0 {
|
||||
t.Fatal("vehicle detail should query bounded TDengine history previews")
|
||||
}
|
||||
for _, query := range store.historyQueries {
|
||||
if query.Get("skipCount") != "1" {
|
||||
t.Fatalf("every vehicle detail history preview should skip the full TDengine count: %+v", store.historyQueries)
|
||||
}
|
||||
}
|
||||
rawPreviewFound := false
|
||||
for _, query := range store.rawFrameQueries {
|
||||
if query.IncludeFields && query.Limit == 10 {
|
||||
rawPreviewFound = true
|
||||
}
|
||||
if !query.SkipCount {
|
||||
t.Fatalf("every vehicle detail raw preview should skip the full TDengine count: %+v", store.rawFrameQueries)
|
||||
}
|
||||
}
|
||||
if !rawPreviewFound {
|
||||
t.Fatalf("vehicle detail raw preview was not queried: %+v", store.rawFrameQueries)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("vins"); got != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("vehicle detail mileage preview should use an exact VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("vin"); got != "" {
|
||||
t.Fatalf("vehicle detail mileage preview must not use the fuzzy VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery)
|
||||
}
|
||||
if got := store.lastDailyMileageQuery.Get("skipCount"); got != "1" {
|
||||
t.Fatalf("vehicle detail mileage preview should skip its unused count, got %q query=%+v", got, store.lastDailyMileageQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
s.lastMileageStatisticsQuery = cloneValues(query)
|
||||
return s.MockStore.MileageStatistics(ctx, query)
|
||||
@@ -815,6 +1418,133 @@ func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) {
|
||||
t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA))
|
||||
}
|
||||
|
||||
func TestHistoryExportCancellationIsOwnerScopedAuditedAndPersistent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
vin := "LNXNEGRR7SR318212"
|
||||
owner := exportCustomerContext("101", "customer-a", vin)
|
||||
other := exportCustomerContext("102", "customer-b", vin)
|
||||
|
||||
service.exportSlots <- struct{}{}
|
||||
job, err := service.CreateHistoryExport(owner, HistoryExportRequest{
|
||||
Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv",
|
||||
})
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.CancelHistoryExport(other, job.ID); err == nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal("another customer should not cancel the owner's export")
|
||||
}
|
||||
cancelled, err := service.CancelHistoryExport(owner, job.ID)
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cancelled.Status != "cancelled" || cancelled.CancelledBy != "customer-a" || cancelled.CancelledAt == "" || !strings.Contains(cancelled.Evidence, "主动取消") {
|
||||
<-service.exportSlots
|
||||
t.Fatalf("cancellation audit metadata missing: %+v", cancelled)
|
||||
}
|
||||
<-service.exportSlots
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
jobs := service.ListHistoryExports(owner)
|
||||
if len(jobs) == 1 && jobs[0].Status == "cancelled" {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
jobs := service.ListHistoryExports(owner)
|
||||
if len(jobs) != 1 || jobs[0].Status != "cancelled" {
|
||||
t.Fatalf("cancelled queued task must never start later: %+v", jobs)
|
||||
}
|
||||
if _, _, err := service.HistoryExportFile(owner, job.ID); err == nil {
|
||||
t.Fatal("cancelled export must not expose a download")
|
||||
}
|
||||
if repeated, err := service.CancelHistoryExport(owner, job.ID); err != nil || repeated.Status != "cancelled" {
|
||||
t.Fatalf("cancelling an already cancelled task should be idempotent: job=%+v err=%v", repeated, err)
|
||||
}
|
||||
|
||||
reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
reloadedJobs := reloaded.ListHistoryExports(owner)
|
||||
if len(reloadedJobs) != 1 || reloadedJobs[0].Status != "cancelled" || reloadedJobs[0].CancelledBy != "customer-a" {
|
||||
t.Fatalf("cancelled state should survive restart: %+v", reloadedJobs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryExportExpiryRebuildAndSafeBatchActions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir})
|
||||
ctx := exportAdminContext()
|
||||
vin := "LNXNEGRR7SR318212"
|
||||
filePath := filepath.Join(dir, "exp_expired.csv")
|
||||
if err := os.WriteFile(filePath, []byte("vin\n"+vin+"\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)
|
||||
createdAt := time.Now().UTC().Add(-8 * 24 * time.Hour).Format(time.RFC3339)
|
||||
service.exportsMu.Lock()
|
||||
service.exports["exp_expired"] = &HistoryExportJob{
|
||||
ID: "exp_expired", Name: "过期任务", Status: "completed", Progress: 100, Format: "csv", Category: "location",
|
||||
Keywords: []string{vin}, Metrics: []string{"speedKmh"}, VehicleVINs: []string{vin}, DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00",
|
||||
OwnerID: "local:subject:1", OwnerUsername: "admin", RowCount: 12, CreatedAt: createdAt, UpdatedAt: createdAt, CompletedAt: createdAt,
|
||||
ExpiresAt: past, DownloadURL: "/api/v2/exports/exp_expired/download", Evidence: "原范围已固化", filePath: filePath,
|
||||
}
|
||||
service.exports["exp_running_batch"] = &HistoryExportJob{
|
||||
ID: "exp_running_batch", Name: "执行中任务", Status: "running", Format: "csv", Category: "location", Keywords: []string{vin},
|
||||
OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: createdAt, UpdatedAt: createdAt, Evidence: "执行中",
|
||||
}
|
||||
service.exportsMu.Unlock()
|
||||
|
||||
jobs := service.ListHistoryExports(ctx)
|
||||
var expired HistoryExportJob
|
||||
for _, job := range jobs {
|
||||
if job.ID == "exp_expired" {
|
||||
expired = job
|
||||
}
|
||||
}
|
||||
if expired.Status != "expired" || expired.DownloadURL != "" || expired.ExpiredAt == "" || !strings.Contains(expired.Error, "保留期") {
|
||||
t.Fatalf("completed file should become an auditable expired task: %+v", expired)
|
||||
}
|
||||
if _, _, err := service.HistoryExportFile(ctx, expired.ID); err == nil {
|
||||
t.Fatal("expired file should not remain downloadable")
|
||||
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_EXPIRED" {
|
||||
t.Fatalf("expired download error=%v", err)
|
||||
}
|
||||
|
||||
service.exportSlots <- struct{}{}
|
||||
rebuilt, err := service.RebuildHistoryExport(ctx, expired.ID)
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rebuilt.Status != "queued" || rebuilt.RebuiltFrom != expired.ID || !strings.Contains(rebuilt.Evidence, expired.ID) {
|
||||
<-service.exportSlots
|
||||
t.Fatalf("rebuild should create a linked queued task: %+v", rebuilt)
|
||||
}
|
||||
if _, err := service.RebuildHistoryExport(ctx, "exp_running_batch"); err == nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal("active task must not be rebuildable")
|
||||
}
|
||||
|
||||
batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_running_batch", "exp_expired"}, Action: "cancel"})
|
||||
if err != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(err)
|
||||
}
|
||||
if batch.Requested != 2 || len(batch.Succeeded) != 1 || batch.Succeeded[0].ID != "exp_running_batch" || len(batch.Skipped) != 1 || batch.Skipped[0].Code != "EXPORT_NOT_CANCELLABLE" {
|
||||
<-service.exportSlots
|
||||
t.Fatalf("batch cancellation must explicitly preserve ineligible tasks: %+v", batch)
|
||||
}
|
||||
if _, cancelErr := service.CancelHistoryExport(ctx, rebuilt.ID); cancelErr != nil {
|
||||
<-service.exportSlots
|
||||
t.Fatal(cancelErr)
|
||||
}
|
||||
<-service.exportSlots
|
||||
}
|
||||
|
||||
type revocableHistoryExportStore struct {
|
||||
*MockStore
|
||||
active bool
|
||||
@@ -969,6 +1699,114 @@ func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawHistorySegmentsReuseLatestTelemetryCategorySource(t *testing.T) {
|
||||
definitions := []MetricDefinition{
|
||||
{Key: "speed_kmh", Category: "driving", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}},
|
||||
{Key: "stack_voltage_v", Category: "fuel-cell", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.stack_1.avg_voltage_v"}},
|
||||
}
|
||||
rows := []HistoryDataRow{{Values: map[string]any{
|
||||
"gb32960.vehicle.speed_kmh": 38.0,
|
||||
"gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4,
|
||||
"jt808.location.additional.total_mileage_km": 1200.0,
|
||||
}}}
|
||||
columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows, definitions)
|
||||
categories := map[string]string{}
|
||||
for _, column := range columns {
|
||||
categories[column.Key] = column.Category
|
||||
}
|
||||
if categories["gb32960.vehicle.speed_kmh"] != "vehicle" || categories["gb32960.fuel_cell.stack_1.avg_voltage_v"] != "fuel-cell" || categories["jt808.location.additional.total_mileage_km"] != "location" {
|
||||
t.Fatalf("raw history did not reuse telemetry categories: %+v", categories)
|
||||
}
|
||||
segments := historyRawSegments(columns)
|
||||
if len(segments) != 3 || segments[0].Key != "vehicle" || segments[0].Label != "整车数据" || segments[1].Key != "fuel-cell" || segments[1].Label != "燃料电池" || segments[2].Key != "location" || segments[2].Label != "定位" {
|
||||
t.Fatalf("unexpected shared raw segments: %+v", segments)
|
||||
}
|
||||
}
|
||||
|
||||
type exactRawHistoryStore struct {
|
||||
*MockStore
|
||||
rows []HistoryDataRow
|
||||
}
|
||||
|
||||
func (s *exactRawHistoryStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) {
|
||||
if query.Category != "raw" {
|
||||
return s.MockStore.HistoryExportBatch(context.Background(), query, cursor, limit)
|
||||
}
|
||||
start := cursor.Offset
|
||||
if start > len(s.rows) {
|
||||
start = len(s.rows)
|
||||
}
|
||||
end := start + limit
|
||||
if end > len(s.rows) {
|
||||
end = len(s.rows)
|
||||
}
|
||||
result := append([]HistoryDataRow(nil), s.rows[start:end]...)
|
||||
cursor.Offset = end
|
||||
return result, cursor, nil
|
||||
}
|
||||
|
||||
func TestHistoryRawDataFiltersOnServerAndPaginatesBeyondOneThousand(t *testing.T) {
|
||||
const totalRows = 1205
|
||||
rows := make([]HistoryDataRow, 0, totalRows)
|
||||
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
for index := 0; index < totalRows; index++ {
|
||||
values := map[string]any{
|
||||
"frameType": "realtime",
|
||||
"rawSizeBytes": 430,
|
||||
"gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4,
|
||||
}
|
||||
if index%2 == 0 {
|
||||
values["gb32960.vehicle.speed_kmh"] = float64(index % 120)
|
||||
}
|
||||
at := start.Add(time.Duration(index) * time.Second).Format(time.RFC3339)
|
||||
rows = append(rows, HistoryDataRow{
|
||||
ID: "row-" + fmt.Sprintf("%04d", index), VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960",
|
||||
DeviceTime: at, ServerTime: at, Quality: "normal", Values: values,
|
||||
})
|
||||
}
|
||||
service := NewService(&exactRawHistoryStore{MockStore: NewMockStore(), rows: rows})
|
||||
response, err := service.HistoryData(context.Background(), url.Values{
|
||||
"keywords": {"LB9A32A24R0LS1426"},
|
||||
"category": {"raw"},
|
||||
"rawSegment": {"fuel-cell"},
|
||||
"dateFrom": {"2026-07-01T00:00"},
|
||||
"dateTo": {"2026-07-02T00:00"},
|
||||
"limit": {"2"},
|
||||
"offset": {"1000"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !response.SegmentCountsExact || response.AllTotal != totalRows || response.Total != totalRows || len(response.Rows) != 2 {
|
||||
t.Fatalf("unexpected exact raw page: exact=%v all=%d total=%d rows=%d", response.SegmentCountsExact, response.AllTotal, response.Total, len(response.Rows))
|
||||
}
|
||||
if response.Rows[0].ID != "row-0204" || response.Rows[1].ID != "row-0203" {
|
||||
t.Fatalf("deep raw page was truncated or unstable: %+v", response.Rows)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, segment := range response.Segments {
|
||||
counts[segment.Key] = segment.Count
|
||||
}
|
||||
if counts["fuel-cell"] != totalRows || counts["vehicle"] != 603 {
|
||||
t.Fatalf("segment counts should cover the complete time range: %+v", counts)
|
||||
}
|
||||
|
||||
vehicle, err := service.HistoryData(context.Background(), url.Values{
|
||||
"keywords": {"LB9A32A24R0LS1426"},
|
||||
"category": {"raw"},
|
||||
"rawSegment": {"vehicle"},
|
||||
"dateFrom": {"2026-07-01T00:00"},
|
||||
"dateTo": {"2026-07-02T00:00"},
|
||||
"limit": {"2"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vehicle.Total != 603 || len(vehicle.Rows) != 2 || vehicle.Rows[0].ID != "row-1204" || vehicle.Rows[1].ID != "row-1202" {
|
||||
t.Fatalf("server-side category filtering failed: total=%d rows=%+v", vehicle.Total, vehicle.Rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
definitions := []MetricDefinition{
|
||||
@@ -1120,3 +1958,63 @@ func BenchmarkLatestTelemetryHundredFrames(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleCoverageSupportsCombinedBusinessMultiSelectFilters(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
query := url.Values{
|
||||
"departmentIds": {"40001,40002"},
|
||||
"responsibleUserIds": {"50001,50002"},
|
||||
"customerIds": {"20001,20002"},
|
||||
"operationStatuses": {"运营中,已停运"},
|
||||
"limit": {"20"},
|
||||
}
|
||||
result, err := service.VehicleCoverage(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, item := range result.Items {
|
||||
got[item.VIN] = true
|
||||
}
|
||||
for _, vin := range []string{"LB9A32A24R0LS1426", "LNXNEGRR7SR318212", "LB9A32A24P0LS1230"} {
|
||||
if !got[vin] {
|
||||
t.Fatalf("combined multi-select omitted %s: %+v", vin, got)
|
||||
}
|
||||
}
|
||||
if got["LMRKH9AC2R1004087"] {
|
||||
t.Fatalf("responsible/status filters leaked unrelated vehicle: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVehicleBusinessFiltersStayInsidePrincipalVINScope(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
UserType: "customer", Role: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426", "LB9A32A24P0LS1230"},
|
||||
})
|
||||
result, err := service.VehicleBusinessFilters(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Departments) != 1 || result.Departments[0].Value != "40001" || result.Departments[0].Count != 2 {
|
||||
t.Fatalf("department options escaped principal scope: %+v", result.Departments)
|
||||
}
|
||||
if len(result.ResponsibleUsers) != 1 || result.ResponsibleUsers[0].Value != "50001" {
|
||||
t.Fatalf("responsible options escaped principal scope: %+v", result.ResponsibleUsers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessFilterCannotWidenPrincipalVINScope(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
ctx := WithPrincipal(context.Background(), Principal{
|
||||
UserType: "customer", Role: "customer", VehicleVINs: []string{"LNXNEGRR7SR318212"},
|
||||
})
|
||||
result, err := service.VehicleCoverage(ctx, url.Values{
|
||||
"departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"}, "limit": {"20"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].VIN != "LNXNEGRR7SR318212" {
|
||||
t.Fatalf("client business filters widened principal scope: %+v", result.Items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,9 @@ func buildHistoryLocationSQL(database string, query map[string]string) SQLQuery
|
||||
if len(where) > 0 {
|
||||
countText += ` WHERE ` + strings.Join(where, " AND ")
|
||||
}
|
||||
if strings.TrimSpace(query["skipCount"]) == "1" {
|
||||
countText = ""
|
||||
}
|
||||
text += ` ORDER BY ts DESC, vin ASC, protocol ASC LIMIT ` + strconv.Itoa(limit) + ` OFFSET ` + strconv.Itoa(offset)
|
||||
return SQLQuery{Text: text, Args: args, CountText: countText}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package vehicleprotocol owns the canonical protocol identifiers shared by
|
||||
// the vehicle data platform, statistics services and the open platform API.
|
||||
package vehicleprotocol
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
GB32960 = "GB32960"
|
||||
YutongMQTT = "YUTONG_MQTT"
|
||||
JT808 = "JT808"
|
||||
)
|
||||
|
||||
// All returns a copy so callers cannot mutate the platform-wide catalog.
|
||||
func All() []string {
|
||||
return []string{GB32960, JT808, YutongMQTT}
|
||||
}
|
||||
|
||||
// MileagePriority is the source-selection order for total mileage.
|
||||
func MileagePriority() []string {
|
||||
return []string{GB32960, YutongMQTT, JT808}
|
||||
}
|
||||
|
||||
// Canonical accepts only the identifiers persisted by ingestion and
|
||||
// statistics. Public aliases are deliberately rejected to avoid a second
|
||||
// protocol vocabulary at the API boundary.
|
||||
func Canonical(value string) (string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
switch value {
|
||||
case GB32960, YutongMQTT, JT808:
|
||||
return value, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package vehicleprotocol
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanonicalCatalogIsUniqueAndRejectsAliases(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, value := range All() {
|
||||
if seen[value] {
|
||||
t.Fatalf("duplicate protocol %q", value)
|
||||
}
|
||||
seen[value] = true
|
||||
if canonical, ok := Canonical(value); !ok || canonical != value {
|
||||
t.Fatalf("canonical(%q) = %q, %v", value, canonical, ok)
|
||||
}
|
||||
}
|
||||
for _, alias := range []string{"32960", "mqtt", "808"} {
|
||||
if canonical, ok := Canonical(alias); ok {
|
||||
t.Fatalf("alias %q unexpectedly maps to %q", alias, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
vehicle-data-platform/apps/open-portal/index.html
Normal file
13
vehicle-data-platform/apps/open-portal/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="羚牛车辆数据开放平台,面向合作伙伴提供稳定、安全、可审计的车辆数据 API。" />
|
||||
<title>羚牛车辆数据开放平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
vehicle-data-platform/apps/open-portal/package.json
Normal file
24
vehicle-data-platform/apps/open-portal/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 20311",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
2366
vehicle-data-platform/apps/open-portal/pnpm-lock.yaml
generated
Normal file
2366
vehicle-data-platform/apps/open-portal/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
198
vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx
Normal file
198
vehicle-data-platform/apps/open-portal/src/AdminWorkspace.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { api, type AdminApp, type AuditItem, type PortalApp, type PortalUser } from "./api";
|
||||
import { dateInput, EmptyState, errorMessage, formatDate, formatTime, Icon, PageHeader, rfcDate, Status } from "./ui";
|
||||
import { VehicleAuthorization } from "./VehicleAuthorization";
|
||||
|
||||
export type AdminSection = "admin-overview" | "apps" | "partners" | "grants" | "security";
|
||||
|
||||
type Props = {
|
||||
section: AdminSection;
|
||||
onNavigate: (section: AdminSection) => void;
|
||||
onKey: (value: string) => void;
|
||||
};
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const nextYear = () => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export function AdminWorkspace({ section, onNavigate, onKey }: Props) {
|
||||
const [apps, setApps] = useState<AdminApp[]>([]);
|
||||
const [users, setUsers] = useState<PortalUser[]>([]);
|
||||
const [vehicleCount, setVehicleCount] = useState(0);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [nextApps, nextUsers] = await Promise.all([api.adminApps(), api.adminUsers()]);
|
||||
setApps(nextApps); setUsers(nextUsers);
|
||||
const grants = await Promise.all(nextApps.map((item) => api.vehicles(item.id).catch(() => [])));
|
||||
setVehicleCount(new Set(grants.flat().map((item) => item.vin)).size);
|
||||
} catch (reason) {
|
||||
setNotice(errorMessage(reason));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { void refresh(); }, []);
|
||||
|
||||
const shared = { apps, users, refresh, setNotice, onKey };
|
||||
return <div className="admin-workspace">
|
||||
{notice && <div className="notice-banner" role="status"><span>{notice}</span><button onClick={() => setNotice("")}><Icon name="close" size={16} /></button></div>}
|
||||
{section === "admin-overview" && <AdminOverview apps={apps} users={users} vehicleCount={vehicleCount} loading={loading} onNavigate={onNavigate} />}
|
||||
{section === "apps" && <AppsManager {...shared} />}
|
||||
{section === "partners" && <PartnersManager {...shared} />}
|
||||
{section === "grants" && <VehicleAuthorization apps={apps} setNotice={setNotice} refresh={refresh} />}
|
||||
{section === "security" && <SecurityAudit apps={apps} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AdminOverview({ apps, users, vehicleCount, loading, onNavigate }: {
|
||||
apps: AdminApp[]; users: PortalUser[]; vehicleCount: number; loading: boolean; onNavigate: (section: AdminSection) => void;
|
||||
}) {
|
||||
const enabledApps = apps.filter((item) => item.status === "enabled").length;
|
||||
const enabledUsers = users.filter((item) => item.status === "enabled").length;
|
||||
const now = Date.now();
|
||||
const expiring = users.filter((item) => item.validTo && new Date(item.validTo).getTime() - now < 30 * 86400000 && new Date(item.validTo).getTime() > now);
|
||||
const expired = users.filter((item) => item.validTo && new Date(item.validTo).getTime() <= now);
|
||||
const steps = [
|
||||
{ label: "创建开放应用", detail: "生成应用凭证并设置有效期", done: apps.length > 0, target: "apps" as const },
|
||||
{ label: "添加合作伙伴", detail: "创建独立的开发者登录账号", done: users.length > 0, target: "partners" as const },
|
||||
{ label: "分配应用角色", detail: "为合作伙伴分配 Owner、Developer 或 Viewer", done: users.length > 0 && apps.length > 0, target: "partners" as const },
|
||||
{ label: "授权车辆", detail: "配置应用可访问的车辆与期限", done: vehicleCount > 0, target: "grants" as const }
|
||||
];
|
||||
return <>
|
||||
<PageHeader title="平台总览" description="管理开放应用、合作伙伴和车辆数据边界。" action={<button className="button primary" onClick={() => onNavigate("partners")}><Icon name="plus" size={17} />添加合作伙伴</button>} />
|
||||
<section className="onboarding-panel"><div className="panel-heading"><div><h2>完成以下步骤,开启开放服务</h2><p>每一步都对应一项明确的访问控制。</p></div><span>{steps.filter((item) => item.done).length} / 4 已完成</span></div><div className="onboarding-steps">{steps.map((item, index) => <button key={item.label} onClick={() => onNavigate(item.target)}><span className={item.done ? "done" : ""}>{item.done ? <Icon name="check" size={16} /> : index + 1}</span><div><b>{item.label}</b><small>{item.detail}</small><em>{item.done ? "已完成" : index === steps.findIndex((step) => !step.done) ? "进行中" : "待开始"}</em></div></button>)}</div></section>
|
||||
<section className="stat-strip">
|
||||
<div><Icon name="apps" size={26} /><span><strong>{loading ? "—" : enabledApps}</strong><small>启用的开放应用</small></span></div>
|
||||
<div><Icon name="users" size={26} /><span><strong>{loading ? "—" : enabledUsers}</strong><small>启用的合作伙伴</small></span></div>
|
||||
<div><Icon name="car" size={26} /><span><strong>{loading ? "—" : vehicleCount}</strong><small>已授权车辆</small></span></div>
|
||||
<div><Icon name="shield" size={26} /><span><strong>全量</strong><small>操作与调用审计</small></span></div>
|
||||
</section>
|
||||
<section className="action-table panel">
|
||||
<div className="panel-heading"><div><h2>待处理事项</h2><p>优先处理即将影响合作伙伴访问的问题。</p></div></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>事项</th><th>相关对象</th><th>时间</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
{expiring.map((user) => <tr key={`exp-${user.id}`}><td><span className="table-leading warning"><Icon name="clock" size={17} /></span>账号即将到期</td><td><b>{user.displayName}</b><small>{user.username}</small></td><td>{formatDate(user.validTo)}</td><td><span className="status warning"><i />即将到期</span></td><td><button className="link-button" onClick={() => onNavigate("partners")}>查看详情</button></td></tr>)}
|
||||
{expired.map((user) => <tr key={`old-${user.id}`}><td><span className="table-leading danger"><Icon name="clock" size={17} /></span>账号授权已到期</td><td><b>{user.displayName}</b><small>{user.username}</small></td><td>{formatDate(user.validTo)}</td><td><Status value="disabled" label="已过期" /></td><td><button className="link-button" onClick={() => onNavigate("partners")}>处理</button></td></tr>)}
|
||||
{!vehicleCount && apps.map((app) => <tr key={`grant-${app.id}`}><td><span className="table-leading danger"><Icon name="car" size={17} /></span>应用尚未授权车辆</td><td><b>{app.name}</b><small>{app.appKeyPrefix}••••••••</small></td><td>—</td><td><span className="status warning"><i />待处理</span></td><td><button className="link-button" onClick={() => onNavigate("grants")}>前往授权</button></td></tr>)}
|
||||
{!expiring.length && !expired.length && (vehicleCount > 0 || !apps.length) && <tr><td colSpan={5}><div className="table-empty"><Icon name="check" size={20} />当前没有需要处理的授权风险</div></td></tr>}
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
|
||||
type Shared = {
|
||||
apps: AdminApp[]; users: PortalUser[]; refresh: () => Promise<void>;
|
||||
setNotice: (value: string) => void; onKey: (value: string) => void;
|
||||
};
|
||||
|
||||
function AppsManager({ apps, refresh, setNotice, onKey }: Shared) {
|
||||
const [editing, setEditing] = useState<AdminApp | "new" | null>(null);
|
||||
return <>
|
||||
<PageHeader title="开放应用" description="每个应用拥有独立 AppKey、有效期和车辆访问范围。" action={<button className="button primary" onClick={() => setEditing("new")}><Icon name="plus" size={17} />创建开放应用</button>} />
|
||||
{!apps.length ? <EmptyState title="还没有开放应用" body="创建首个应用后,才能分配合作伙伴和授权车辆。" action={<button className="button primary" onClick={() => setEditing("new")}>创建开放应用</button>} /> :
|
||||
<section className="panel directory-panel"><div className="table-toolbar"><div className="search-box"><Icon name="search" size={17} /><input aria-label="搜索开放应用" placeholder="搜索应用名称或 AppKey 标识" /></div><span>共 {apps.length} 个应用</span></div><div className="table-scroll"><table><thead><tr><th>应用名称</th><th>AppKey 标识</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody>{apps.map((app) => <tr key={app.id}><td><b>{app.name}</b><small>应用 ID {app.id}</small></td><td><code>{app.appKeyPrefix}••••••••</code></td><td>{formatDate(app.validFrom)} — {formatDate(app.validTo)}</td><td><Status value={app.status} /></td><td><button className="link-button" onClick={() => setEditing(app)}>编辑</button><button className="link-button" onClick={async () => { if (!window.confirm(`确认轮换“${app.name}”的 AppKey?旧密钥将立即失效。`)) return; try { const result = await api.rotateKey(app.id); onKey(result.appKey); } catch (reason) { setNotice(errorMessage(reason)); } }}>轮换密钥</button></td></tr>)}</tbody></table></div></section>}
|
||||
{editing && <AppDrawer app={editing} onClose={() => setEditing(null)} onSaved={async (message, key) => { setEditing(null); if (key) onKey(key); setNotice(message); await refresh(); }} />}
|
||||
</>;
|
||||
}
|
||||
|
||||
function AppDrawer({ app, onClose, onSaved }: { app: AdminApp | "new"; onClose: () => void; onSaved: (message: string, key?: string) => Promise<void> }) {
|
||||
const existing = app === "new" ? null : app;
|
||||
const [name, setName] = useState(existing?.name || "");
|
||||
const [status, setStatus] = useState(existing?.status || "enabled");
|
||||
const [validFrom, setFrom] = useState(dateInput(existing?.validFrom) || today());
|
||||
const [validTo, setTo] = useState(dateInput(existing?.validTo) || nextYear());
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setError("");
|
||||
if (!name.trim()) return setError("请输入应用名称。");
|
||||
if (!validFrom || !validTo || validTo <= validFrom) return setError("截止日期必须晚于生效日期。");
|
||||
setSaving(true);
|
||||
try {
|
||||
if (existing) {
|
||||
await api.updateApp(existing.id, { name: name.trim(), status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) });
|
||||
await onSaved("应用设置已保存。");
|
||||
} else {
|
||||
const created = await api.createApp({ name: name.trim(), status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) });
|
||||
await onSaved("应用已创建。完整 AppKey 仅展示一次,请立即保存。", created.appKey);
|
||||
}
|
||||
} catch (reason) { setError(errorMessage(reason)); } finally { setSaving(false); }
|
||||
}
|
||||
return <div className="drawer-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}><aside className="detail-drawer"><header><div><h2>{existing ? "编辑开放应用" : "创建开放应用"}</h2><p>{existing ? "更新应用状态和访问有效期。" : "创建独立凭证,稍后配置合作伙伴和车辆。"}</p></div><button aria-label="关闭" onClick={onClose}><Icon name="close" /></button></header><form onSubmit={submit}><section><h3>应用信息</h3><label>应用名称<input value={name} onChange={(event) => setName(event.target.value)} maxLength={96} placeholder="例如:华东车队数据接入" /></label><label>状态<select value={status} onChange={(event) => setStatus(event.target.value)}><option value="enabled">启用</option><option value="disabled">停用</option></select></label></section><section><h3>应用有效期</h3><div className="field-row"><label>生效日期<input type="date" value={validFrom} onChange={(event) => setFrom(event.target.value)} /></label><label>截止日期<input type="date" value={validTo} onChange={(event) => setTo(event.target.value)} /></label></div></section>{error && <div className="form-error" role="alert">{error}</div>}<footer><button type="button" className="button secondary" onClick={onClose}>取消</button><button className="button primary" disabled={saving}>{saving ? "正在保存…" : existing ? "保存更改" : "创建并生成 AppKey"}</button></footer></form></aside></div>;
|
||||
}
|
||||
|
||||
function PartnersManager({ apps, users, refresh, setNotice }: Shared) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<PortalUser | "new" | null>(null);
|
||||
const filtered = users.filter((user) => `${user.displayName} ${user.username}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return <>
|
||||
<PageHeader title="合作伙伴" description="创建开发者账号,控制账号有效期并分配应用角色。" action={<button className="button primary" onClick={() => setEditing("new")} disabled={!apps.length}><Icon name="plus" size={17} />添加合作伙伴</button>} />
|
||||
{!apps.length && <div className="context-alert"><Icon name="apps" size={18} /><span><b>请先创建开放应用</b>添加合作伙伴前,至少需要一个可以分配的开放应用。</span></div>}
|
||||
<section className="panel directory-panel"><div className="table-toolbar"><div className="search-box"><Icon name="search" size={17} /><input value={query} onChange={(event) => setQuery(event.target.value)} aria-label="搜索合作伙伴" placeholder="搜索合作伙伴名称或登录账号" /></div><span>共 {filtered.length} 个合作伙伴</span></div><div className="table-scroll"><table><thead><tr><th>合作伙伴</th><th>登录账号</th><th>账号有效期</th><th>最近登录</th><th>状态</th><th>操作</th></tr></thead><tbody>{filtered.map((user) => <tr key={user.id} className={editing !== "new" && editing?.id === user.id ? "selected" : ""}><td><b>{user.displayName}</b><small>合作伙伴 ID {user.id}</small></td><td><code>{user.username}</code></td><td>{formatDate(user.validFrom)} — {formatDate(user.validTo)}</td><td>{formatTime(user.lastLoginAt)}</td><td><Status value={user.status} /></td><td><button className="link-button" onClick={() => setEditing(user)}>查看与编辑</button></td></tr>)}{!filtered.length && <tr><td colSpan={6}><div className="table-empty">没有匹配的合作伙伴</div></td></tr>}</tbody></table></div></section>
|
||||
{editing && <PartnerDrawer user={editing} apps={apps} onClose={() => setEditing(null)} onSaved={async (message) => { setEditing(null); setNotice(message); await refresh(); }} />}
|
||||
</>;
|
||||
}
|
||||
|
||||
type Membership = { appId: number; role: string; enabled: boolean };
|
||||
|
||||
function PartnerDrawer({ user, apps, onClose, onSaved }: { user: PortalUser | "new"; apps: AdminApp[]; onClose: () => void; onSaved: (message: string) => Promise<void> }) {
|
||||
const existing = user === "new" ? null : user;
|
||||
const [displayName, setDisplayName] = useState(existing?.displayName || "");
|
||||
const [username, setUsername] = useState(existing?.username || "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [status, setStatus] = useState(existing?.status || "enabled");
|
||||
const [validFrom, setFrom] = useState(dateInput(existing?.validFrom) || today());
|
||||
const [validTo, setTo] = useState(dateInput(existing?.validTo) || nextYear());
|
||||
const [memberships, setMemberships] = useState<Membership[]>(apps.map((app) => ({ appId: app.id, role: "developer", enabled: false })));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!existing) return;
|
||||
api.userApps(existing.id).then((assigned) => setMemberships(apps.map((app) => {
|
||||
const membership = assigned.find((item) => item.appId === app.id);
|
||||
return { appId: app.id, role: membership?.role || "developer", enabled: Boolean(membership) };
|
||||
}))).catch((reason) => setError(errorMessage(reason)));
|
||||
}, [existing?.id, apps]);
|
||||
|
||||
function validate() {
|
||||
if (!/^[A-Za-z0-9._-]{3,64}$/.test(username.trim())) return "登录账号需为 3–64 位,只能包含字母、数字、点、下划线或连字符。";
|
||||
if (!displayName.trim() || [...displayName.trim()].length > 96) return "显示名称不能为空且不能超过 96 个字符。";
|
||||
if (!existing && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "初始密码必须为 12–128 位,并包含大写字母、小写字母和数字。";
|
||||
if (password && (password.length < 12 || password.length > 128 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/[0-9]/.test(password))) return "新密码必须为 12–128 位,并包含大写字母、小写字母和数字。";
|
||||
if (!validFrom || !validTo || validTo <= validFrom) return "截止日期必须晚于生效日期。";
|
||||
return "";
|
||||
}
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setError("");
|
||||
const validation = validate(); if (validation) return setError(validation);
|
||||
setSaving(true);
|
||||
try {
|
||||
const input = { username: username.trim(), displayName: displayName.trim(), password, status, validFrom: rfcDate(validFrom), validTo: rfcDate(validTo) };
|
||||
const saved = existing ? await api.updateUser(existing.id, input) : await api.createUser(input);
|
||||
await api.replaceUserApps(saved.id, memberships.filter((item) => item.enabled).map(({ appId, role }) => ({ appId, role })));
|
||||
await onSaved(existing ? "合作伙伴账号和应用权限已保存。" : "合作伙伴账号已创建并完成应用授权。");
|
||||
} catch (reason) { setError(errorMessage(reason)); } finally { setSaving(false); }
|
||||
}
|
||||
return <div className="drawer-backdrop" onMouseDown={(event) => { if (event.currentTarget === event.target) onClose(); }}><aside className="detail-drawer partner-drawer"><header><div><h2>{existing ? "合作伙伴详情" : "添加合作伙伴"}</h2><p>{existing ? "维护账号状态、有效期和应用角色。" : "一次完成账号创建和应用权限分配。"}</p></div><button aria-label="关闭" onClick={onClose}><Icon name="close" /></button></header><div className="drawer-steps"><span className="active"><i>1</i>基础信息</span><span className="active"><i>2</i>应用权限</span><span><i>3</i>确认保存</span></div><form onSubmit={submit}><section><h3>基础信息</h3><label>显示名称<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} maxLength={96} placeholder="合作伙伴企业或团队名称" /></label><label>登录账号<input value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3–64 位字母、数字或 . _ -" /></label><label>状态<select value={status} onChange={(event) => setStatus(event.target.value)}><option value="enabled">启用</option><option value="disabled">停用并撤销会话</option></select></label></section><section><h3>账号有效期</h3><div className="field-row"><label>生效日期<input type="date" value={validFrom} onChange={(event) => setFrom(event.target.value)} /></label><label>截止日期<input type="date" value={validTo} onChange={(event) => setTo(event.target.value)} /></label></div></section><section><div className="section-label"><h3>应用角色</h3><small>Owner 可轮换密钥;Developer 可调用;Viewer 只读。</small></div><div className="membership-list">{memberships.map((membership) => { const app = apps.find((item) => item.id === membership.appId)!; return <div key={membership.appId}><label className="check-row"><input type="checkbox" checked={membership.enabled} onChange={(event) => setMemberships((items) => items.map((item) => item.appId === membership.appId ? { ...item, enabled: event.target.checked } : item))} /><span><b>{app.name}</b><small>{app.appKeyPrefix}••••••••</small></span></label><select aria-label={`${app.name}角色`} disabled={!membership.enabled} value={membership.role} onChange={(event) => setMemberships((items) => items.map((item) => item.appId === membership.appId ? { ...item, role: event.target.value } : item))}><option value="owner">Owner</option><option value="developer">Developer</option><option value="viewer">Viewer</option></select></div>; })}</div></section><section><h3>{existing ? "安全" : "初始密码"}</h3><label>{existing ? "重置密码(不修改请留空)" : "初始密码"}<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="至少 12 位,含大小写字母和数字" /><small className="field-help">{existing ? "保存新密码会立即撤销该合作伙伴的现有会话。" : "请通过安全渠道将初始密码交给合作伙伴。"}</small></label>{existing && <div className="last-login"><span>最近登录</span><b>{formatTime(existing.lastLoginAt)}</b></div>}</section>{error && <div className="form-error" role="alert">{error}</div>}<footer><button type="button" className="button secondary" onClick={onClose}>取消</button><button className="button primary" disabled={saving}>{saving ? "正在保存…" : existing ? "保存更改" : "创建并授权"}</button></footer></form></aside></div>;
|
||||
}
|
||||
|
||||
function SecurityAudit({ apps }: { apps: AdminApp[] }) {
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [audit, setAudit] = useState<AuditItem[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
useEffect(() => { if (!appId && apps[0]) setAppId(apps[0].id); }, [apps, appId]);
|
||||
useEffect(() => { if (appId) api.audit(appId).then(setAudit).catch((reason) => setError(errorMessage(reason))); }, [appId]);
|
||||
return <>
|
||||
<PageHeader title="调用与安全" description="按应用查看最近 API 调用结果和 Trace ID。" action={apps.length ? <select className="header-select" value={appId} onChange={(event) => setAppId(Number(event.target.value))}>{apps.map((app) => <option value={app.id} key={app.id}>{app.name}</option>)}</select> : undefined} />
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<section className="panel directory-panel"><div className="panel-heading"><div><h2>最近调用记录</h2><p>最多展示最近 100 条记录。</p></div><span>{audit.length} 条</span></div><div className="table-scroll"><table><thead><tr><th>时间</th><th>接口</th><th>车辆数</th><th>结果</th><th>Trace ID</th></tr></thead><tbody>{audit.map((item) => <tr key={`${item.traceId}-${item.createdAt}`}><td>{formatTime(item.createdAt)}</td><td><code>{item.endpoint}</code></td><td>{item.vehicleCount}</td><td><Status value={item.result} /></td><td><code>{item.traceId}</code></td></tr>)}{!audit.length && <tr><td colSpan={5}><div className="table-empty">暂无调用记录</div></td></tr>}</tbody></table></div></section>
|
||||
</>;
|
||||
}
|
||||
170
vehicle-data-platform/apps/open-portal/src/App.test.tsx
Normal file
170
vehicle-data-platform/apps/open-portal/src/App.test.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { App } from "./App";
|
||||
|
||||
const app = {
|
||||
id: 7,
|
||||
name: "测试开放应用",
|
||||
appKeyPrefix: "12345678",
|
||||
status: "enabled",
|
||||
validFrom: "2026-07-20T00:00:00+08:00",
|
||||
validTo: "2027-07-20T00:00:00+08:00"
|
||||
};
|
||||
|
||||
describe("open platform portal", () => {
|
||||
beforeEach(() => {
|
||||
window.location.hash = "/";
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [] }) }));
|
||||
});
|
||||
|
||||
it("renders the factual public homepage and interactive API example", () => {
|
||||
render(<App />);
|
||||
expect(screen.getAllByAltText("羚牛智能").length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole("heading", { name: "车辆数据,按需开放。" })).toBeInTheDocument();
|
||||
expect(screen.getByText("车辆日用氢量")).toBeInTheDocument();
|
||||
expect(screen.getByText("车辆日里程")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "进入控制台" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "阅读文档" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "日里程" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("日里程响应示例")).toHaveTextContent("totalMileageKm");
|
||||
fireEvent.click(screen.getByRole("tab", { name: "日用氢量" }));
|
||||
expect(screen.getByRole("tab", { name: "日用氢量" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByLabelText("日用氢量响应示例")).toHaveTextContent("hydrogenConsumptionKg");
|
||||
});
|
||||
|
||||
it("provides complete onboarding and API reference documentation", async () => {
|
||||
window.location.hash = "/docs";
|
||||
render(<App />);
|
||||
expect(screen.getByRole("heading", { name: "五分钟完成首次调用" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "获取 AppKey" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "确认授权车辆" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("/api/v1/vehicles/mileage/query").length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "车辆区间日里程" }));
|
||||
expect(screen.getByRole("heading", { name: "车辆区间日里程" })).toBeInTheDocument();
|
||||
expect(screen.getByText("/api/v1/vehicles/mileage/range/query")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("snapshotId", { exact: false }).length).toBeGreaterThan(0);
|
||||
fireEvent.click(screen.getByRole("button", { name: "错误码与重试" }));
|
||||
expect(screen.getByRole("heading", { name: "错误码与重试" })).toBeInTheDocument();
|
||||
expect(screen.getByText("INVALID_DATE_FORMAT")).toBeInTheDocument();
|
||||
expect(screen.getByText("指数退避", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives administrators a dedicated partner management flow", async () => {
|
||||
window.location.hash = "/console";
|
||||
sessionStorage.setItem("lingniu-open-platform-token", "a".repeat(64));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let data: unknown = [];
|
||||
if (url.includes("/portal-api/session")) {
|
||||
data = { userId: 1, username: "admin", displayName: "平台管理员", userType: "admin", expiresAt: "2026-07-21T00:00:00+08:00" };
|
||||
} else if (url.endsWith("/portal-api/admin/apps")) {
|
||||
data = [app];
|
||||
}
|
||||
return { ok: true, json: async () => ({ data }) };
|
||||
}));
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("heading", { name: "平台总览" })).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByRole("navigation", { name: "平台管理导航" })).getByRole("button", { name: "合作伙伴" }));
|
||||
expect(await screen.findByRole("heading", { name: "合作伙伴" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "添加合作伙伴" })[0]);
|
||||
expect(await screen.findByRole("heading", { name: "添加合作伙伴" })).toBeInTheDocument();
|
||||
expect(screen.getByText("应用角色")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("显示名称"), { target: { value: "合作伙伴甲" } });
|
||||
fireEvent.change(screen.getByLabelText("登录账号"), { target: { value: "partner-a" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("至少 12 位,含大小写字母和数字"), { target: { value: "weak-password" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "创建并授权" }));
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("初始密码必须为 12–128 位");
|
||||
});
|
||||
|
||||
it("gives partner developers a focused application workspace", async () => {
|
||||
window.location.hash = "/console";
|
||||
sessionStorage.setItem("lingniu-open-platform-token", "b".repeat(64));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let data: unknown = [];
|
||||
if (url.includes("/portal-api/session")) {
|
||||
data = {
|
||||
userId: 2,
|
||||
username: "partner-a",
|
||||
displayName: "合作伙伴甲",
|
||||
userType: "partner",
|
||||
expiresAt: "2026-07-21T00:00:00+08:00"
|
||||
};
|
||||
} else if (url.endsWith("/portal-api/apps")) {
|
||||
data = [{
|
||||
appId: 7,
|
||||
appName: "测试开放应用",
|
||||
appKeyPrefix: "12345678",
|
||||
appStatus: "enabled",
|
||||
role: "owner",
|
||||
validFrom: "2026-07-20T00:00:00+08:00",
|
||||
validTo: "2027-07-20T00:00:00+08:00"
|
||||
}];
|
||||
} else if (url.includes("/vehicles")) {
|
||||
data = [{ vin: "LNB00000000000001", plate: "辽A00001", validFrom: "2026-07-20T00:00:00+08:00" }];
|
||||
} else if (url.includes("/audit")) {
|
||||
data = [{ traceId: "trace-1", endpoint: "mileage", result: "success", vehicleCount: 1, createdAt: "2026-07-20T12:00:00+08:00" }];
|
||||
}
|
||||
return { ok: true, json: async () => ({ data }) };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
const navigation = await screen.findByRole("navigation", { name: "开发者控制台导航" });
|
||||
expect(await screen.findByText("12345678••••••••")).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("button", { name: "应用与密钥" })).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("button", { name: "授权车辆" })).toBeInTheDocument();
|
||||
fireEvent.click(within(navigation).getByRole("button", { name: "授权车辆" }));
|
||||
expect((await screen.findAllByText("辽A00001")).length).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole("button", { name: "合作伙伴" })).not.toBeInTheDocument();
|
||||
fireEvent.click(within(navigation).getByRole("button", { name: "API 调试" }));
|
||||
fireEvent.change(await screen.findByLabelText("数据接口"), { target: { value: "mileage/range" } });
|
||||
expect(screen.getAllByText("/api/v1/vehicles/mileage/range/query", { exact: false }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("开始日期")).toBeInTheDocument();
|
||||
fireEvent.change(await screen.findByLabelText("数据接口"), { target: { value: "total-mileage" } });
|
||||
expect(screen.getAllByText("/api/v1/vehicles/total-mileage/query", { exact: false }).length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText("采集协议")).toHaveValue("");
|
||||
fireEvent.change(screen.getByLabelText("采集协议"), { target: { value: "YUTONG_MQTT" } });
|
||||
expect(screen.getByText(/"protocol": "YUTONG_MQTT"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lets administrators select, paste, and add all catalog vehicles", async () => {
|
||||
window.location.hash = "/console";
|
||||
sessionStorage.setItem("lingniu-open-platform-token", "c".repeat(64));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let data: unknown = [];
|
||||
if (url.includes("/portal-api/session")) {
|
||||
data = { userId: 1, username: "admin", displayName: "平台管理员", userType: "admin", expiresAt: "2026-07-21T00:00:00+08:00" };
|
||||
} else if (url.endsWith("/portal-api/admin/apps")) {
|
||||
data = [app];
|
||||
} else if (url.endsWith("/portal-api/admin/vehicles")) {
|
||||
data = [
|
||||
{ vin: "LNB00000000000001", plate: "辽A00001", oem: "羚牛", status: "available", source: "车辆主数据" },
|
||||
{ vin: "LNB00000000000002", plate: "辽A00002", oem: "羚牛", status: "available", source: "车辆主数据" }
|
||||
];
|
||||
} else if (url.includes("/portal-api/apps/7/vehicles")) {
|
||||
data = [{ vin: "LNB00000000000001", plate: "辽A00001", validFrom: "2026-07-20T00:00:00+08:00", validTo: "2027-07-20T00:00:00+08:00" }];
|
||||
}
|
||||
return { ok: true, json: async () => ({ data }) };
|
||||
}));
|
||||
|
||||
render(<App />);
|
||||
expect(await screen.findByRole("heading", { name: "平台总览" })).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByRole("navigation", { name: "平台管理导航" })).getByRole("button", { name: "车辆授权" }));
|
||||
expect((await screen.findAllByText("辽A00001")).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("辽A00002")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "批量粘贴" }));
|
||||
fireEvent.change(screen.getByLabelText("批量输入 VIN 或车牌"), { target: { value: "辽A00002\nUNKNOWN" } });
|
||||
expect(await screen.findByText("识别 1 辆 · 未匹配 1 条")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用识别结果" }));
|
||||
expect(screen.getByRole("button", { name: "保存 1 辆授权" })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "全部车辆" }));
|
||||
expect(screen.getByRole("heading", { name: "已选择当前全部车辆" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "保存 2 辆授权" })).toBeInTheDocument();
|
||||
expect(screen.getByText("未来新增车辆不会自动获得权限", { exact: false })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
34
vehicle-data-platform/apps/open-portal/src/App.tsx
Normal file
34
vehicle-data-platform/apps/open-portal/src/App.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type Session, tokenStore } from "./api";
|
||||
import { LoginPage } from "./Auth";
|
||||
import { Console } from "./Console";
|
||||
import { PublicSite } from "./PublicSite";
|
||||
import { Brand, currentRoute, type Route } from "./ui";
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState<Route>(currentRoute);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [checking, setChecking] = useState(Boolean(tokenStore.get()));
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setRoute(currentRoute());
|
||||
window.addEventListener("hashchange", onHash);
|
||||
if (tokenStore.get()) {
|
||||
api.session()
|
||||
.then(setSession)
|
||||
.catch(() => tokenStore.clear())
|
||||
.finally(() => setChecking(false));
|
||||
}
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void api.catalog().catch(() => undefined); }, []);
|
||||
|
||||
if (route === "console") {
|
||||
if (checking) return <div className="loading-screen"><Brand /><span /></div>;
|
||||
if (!session) return <LoginPage onLogin={setSession} />;
|
||||
return <Console session={session} onLogout={() => setSession(null)} />;
|
||||
}
|
||||
|
||||
return <PublicSite route={route} />;
|
||||
}
|
||||
39
vehicle-data-platform/apps/open-portal/src/Auth.tsx
Normal file
39
vehicle-data-platform/apps/open-portal/src/Auth.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { api, type Session, tokenStore } from "./api";
|
||||
import { Brand, Icon, navigate } from "./ui";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (session: Session) => void }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(""); setLoading(true);
|
||||
try {
|
||||
const response = await api.login(username.trim(), password);
|
||||
tokenStore.set(response.accessToken);
|
||||
onLogin(response.session);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : "登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <main className="login-page">
|
||||
<button className="login-back" onClick={() => navigate("home")}><Icon name="arrow" size={16} />返回开放平台</button>
|
||||
<section className="login-panel">
|
||||
<Brand />
|
||||
<div className="login-heading"><h1>登录开放平台</h1><p>使用平台管理员账号,或由管理员创建的合作伙伴账号。</p></div>
|
||||
<form onSubmit={submit}>
|
||||
<label>登录账号<input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入账号" /></label>
|
||||
<label>密码<input type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></label>
|
||||
{error && <div className="form-error" role="alert">{error}</div>}
|
||||
<button className="button primary large full" disabled={loading || !username.trim() || !password}>{loading ? "正在登录…" : "登录控制台"}</button>
|
||||
</form>
|
||||
<div className="login-help"><Icon name="shield" size={17} /><p><b>两类账号,同一入口</b><span>管理员使用内部车辆平台身份;合作伙伴使用独立开放平台账号。</span></p></div>
|
||||
</section>
|
||||
</main>;
|
||||
}
|
||||
177
vehicle-data-platform/apps/open-portal/src/Console.tsx
Normal file
177
vehicle-data-platform/apps/open-portal/src/Console.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { AdminWorkspace, type AdminSection } from "./AdminWorkspace";
|
||||
import { api, type AuditItem, type PortalApp, type Session, tokenStore, type VehicleGrant } from "./api";
|
||||
import { Brand, EmptyState, errorMessage, formatDate, formatTime, Icon, navigate, PageHeader, Status } from "./ui";
|
||||
|
||||
type PartnerSection = "overview" | "credentials" | "vehicles" | "sandbox" | "audit" | "account";
|
||||
type Section = AdminSection | PartnerSection;
|
||||
|
||||
const adminNavigation: Array<[AdminSection | "account", string, "home" | "apps" | "users" | "car" | "shield" | "settings"]> = [
|
||||
["admin-overview", "总览", "home"],
|
||||
["apps", "开放应用", "apps"],
|
||||
["partners", "合作伙伴", "users"],
|
||||
["grants", "车辆授权", "car"],
|
||||
["security", "调用与安全", "shield"],
|
||||
["account", "账号设置", "settings"]
|
||||
];
|
||||
|
||||
const partnerNavigation: Array<[PartnerSection, string, "home" | "key" | "car" | "terminal" | "activity" | "settings"]> = [
|
||||
["overview", "总览", "home"],
|
||||
["credentials", "应用与密钥", "key"],
|
||||
["vehicles", "授权车辆", "car"],
|
||||
["sandbox", "API 调试", "terminal"],
|
||||
["audit", "调用记录", "activity"],
|
||||
["account", "账号设置", "settings"]
|
||||
];
|
||||
|
||||
export function Console({ session, onLogout }: { session: Session; onLogout: () => void }) {
|
||||
const isAdmin = session.userType === "admin";
|
||||
const [section, setSection] = useState<Section>(isAdmin ? "admin-overview" : "overview");
|
||||
const [apps, setApps] = useState<PortalApp[]>([]);
|
||||
const [selectedId, setSelectedId] = useState(0);
|
||||
const [vehicles, setVehicles] = useState<VehicleGrant[]>([]);
|
||||
const [audit, setAudit] = useState<AuditItem[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [newKey, setNewKey] = useState("");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const selected = useMemo(() => apps.find((item) => item.appId === selectedId), [apps, selectedId]);
|
||||
const navigation = isAdmin ? adminNavigation : partnerNavigation;
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
const items = await api.apps();
|
||||
setApps(items);
|
||||
if (!items.some((item) => item.appId === selectedId) && items[0]) setSelectedId(items[0].appId);
|
||||
} catch (reason) { setError(errorMessage(reason)); }
|
||||
}
|
||||
useEffect(() => { void loadApps(); }, []);
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setVehicles([]); setAudit([]); return; }
|
||||
Promise.all([api.vehicles(selectedId), api.audit(selectedId)])
|
||||
.then(([nextVehicles, nextAudit]) => { setVehicles(nextVehicles); setAudit(nextAudit); })
|
||||
.catch((reason) => setError(errorMessage(reason)));
|
||||
}, [selectedId]);
|
||||
|
||||
async function logout() {
|
||||
try { await api.logout(); } catch { /* local logout still applies */ }
|
||||
tokenStore.clear(); onLogout();
|
||||
}
|
||||
async function rotate() {
|
||||
if (!selected || !window.confirm(`确认轮换“${selected.appName}”的 AppKey?旧密钥将立即失效。`)) return;
|
||||
try { const response = await api.rotateKey(selected.appId); setNewKey(response.appKey); await loadApps(); }
|
||||
catch (reason) { setError(errorMessage(reason)); }
|
||||
}
|
||||
function choose(next: Section) { setSection(next); setMenuOpen(false); }
|
||||
|
||||
return <div className="app-shell">
|
||||
<aside className={`app-sidebar${menuOpen ? " open" : ""}`}>
|
||||
<div className="sidebar-brand"><Brand compact /><button aria-label="关闭菜单" onClick={() => setMenuOpen(false)}><Icon name="close" /></button></div>
|
||||
{!isAdmin && <div className="sidebar-app"><small>当前应用</small><select aria-label="当前应用" value={selectedId} onChange={(event) => setSelectedId(Number(event.target.value))}>{apps.map((app) => <option value={app.appId} key={app.appId}>{app.appName}</option>)}</select>{selected && <Status value={selected.appStatus} label={selected.appStatus === "enabled" ? "运行中" : "已停用"} />}</div>}
|
||||
<nav aria-label={isAdmin ? "平台管理导航" : "开发者控制台导航"}>{navigation.map(([id, label, icon]) => <button className={section === id ? "active" : ""} onClick={() => choose(id)} key={id}><Icon name={icon} size={19} />{label}</button>)}<button onClick={() => navigate("docs")}><Icon name="book" size={19} />接口文档<span>↗</span></button></nav>
|
||||
<div className="sidebar-user"><span>{session.displayName.slice(0, 1).toUpperCase()}</span><div><b>{session.displayName}</b><small>{isAdmin ? "平台管理员" : "合作伙伴开发者"}</small></div><button aria-label="退出登录" onClick={logout}><Icon name="logout" size={18} /></button></div>
|
||||
</aside>
|
||||
{menuOpen && <button className="mobile-scrim" aria-label="关闭菜单" onClick={() => setMenuOpen(false)} />}
|
||||
<main className="workspace">
|
||||
<div className="mobile-bar"><button aria-label="打开菜单" onClick={() => setMenuOpen(true)}><Icon name="menu" /></button><Brand compact /><span>{session.displayName.slice(0, 1)}</span></div>
|
||||
{error && <div className="notice-banner error" role="alert"><span>{error}</span><button onClick={() => setError("")}><Icon name="close" size={16} /></button></div>}
|
||||
{isAdmin && section !== "account" && <AdminWorkspace section={section as AdminSection} onNavigate={choose} onKey={setNewKey} />}
|
||||
{!isAdmin && <PartnerWorkspace section={section as PartnerSection} app={selected} vehicles={vehicles} audit={audit} onRotate={rotate} />}
|
||||
{section === "account" && <Account session={session} onChanged={logout} />}
|
||||
</main>
|
||||
{newKey && <KeyModal value={newKey} onClose={() => setNewKey("")} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PartnerWorkspace({ section, app, vehicles, audit, onRotate }: { section: PartnerSection; app?: PortalApp; vehicles: VehicleGrant[]; audit: AuditItem[]; onRotate: () => void }) {
|
||||
if (!app && section !== "account") return <><PageHeader title="开发者控制台" description="管理应用凭证、车辆范围和 API 调用。" /><EmptyState title="尚未分配开放应用" body="请联系平台管理员,为此账号分配至少一个开放应用和角色。" /></>;
|
||||
if (!app) return null;
|
||||
if (section === "overview") return <DeveloperOverview app={app} vehicles={vehicles} audit={audit} onOpenSandbox={() => window.location.hash = "/console"} />;
|
||||
if (section === "credentials") return <Credentials app={app} onRotate={onRotate} />;
|
||||
if (section === "vehicles") return <PartnerVehicles vehicles={vehicles} />;
|
||||
if (section === "sandbox") return <ApiSandbox app={app} vehicles={vehicles} />;
|
||||
if (section === "audit") return <PartnerAudit audit={audit} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
function DeveloperOverview({ app, vehicles, audit, onOpenSandbox }: { app: PortalApp; vehicles: VehicleGrant[]; audit: AuditItem[]; onOpenSandbox: () => void }) {
|
||||
const success = audit.filter((item) => item.result === "success").length;
|
||||
const successRate = audit.length ? `${Math.round(success / audit.length * 1000) / 10}%` : "—";
|
||||
return <>
|
||||
<PageHeader title="开发者控制台" description={`欢迎回来。当前正在管理“${app.appName}”。`} />
|
||||
<section className="developer-facts panel"><div><small>AppKey</small><strong><code>{app.appKeyPrefix}••••••••</code></strong><span><Icon name="shield" size={15} />仅显示安全标识</span></div><div><small>授权车辆</small><strong>{vehicles.length}<em> 辆</em></strong><span>当前应用可访问</span></div><div><small>授权有效期</small><strong className="date-fact">{formatDate(app.validFrom)} <em>至</em> {formatDate(app.validTo)}</strong><span><Status value={app.appStatus} label={app.appStatus === "enabled" ? "运行中" : "已停用"} /></span></div></section>
|
||||
<section className="getting-started panel"><div className="panel-heading"><div><h2>开始接入</h2><p>沿着这条路径完成首次生产调用。</p></div></div><div className="integration-steps">{[
|
||||
["保存 AppKey", true], ["查看车辆范围", vehicles.length > 0], ["调用测试接口", audit.length > 0], ["接入生产", audit.length >= 10]
|
||||
].map(([label, done], index) => <div key={String(label)} className={done ? "done" : index === 2 ? "active" : ""}><span>{done ? <Icon name="check" size={17} /> : index + 1}</span><b>{label}</b><small>{done ? "已完成" : index === 2 ? "进行中" : "待完成"}</small></div>)}</div><a className="button primary" href="/open-api/swagger/" target="_blank" rel="noreferrer" onClick={onOpenSandbox}>打开 API 调试 <Icon name="arrow" size={16} /></a></section>
|
||||
<div className="developer-grid"><section className="panel usage-panel"><div className="panel-heading"><div><h2>近期调用</h2><p>最近 {audit.length} 条审计记录</p></div><button className="link-button">查看调用记录</button></div><div className="usage-stats"><span><small>请求数</small><strong>{audit.length}</strong></span><span><small>成功率</small><strong>{successRate}</strong></span><span><small>访问车辆</small><strong>{new Set(audit.map((item) => item.vehicleCount)).size || "—"}</strong></span></div><div className="mini-bars" aria-label="近期调用趋势">{[32, 46, 38, 62, 58, 76, 68, 86, 72, 54, 64, 42].map((height, index) => <i key={index} style={{ height: `${height}%` }} />)}</div></section><section className="panel common-apis"><div className="panel-heading"><div><h2>常用接口</h2><p>当前已开放的数据产品</p></div></div><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>车辆日用氢量</strong><code>/api/v1/vehicles/hydrogen-consumption/query</code></span><Icon name="arrow" size={18} /></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>车辆日里程</strong><code>/api/v1/vehicles/mileage/query</code></span><Icon name="arrow" size={18} /></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>车辆区间日里程</strong><code>/api/v1/vehicles/mileage/range/query</code></span><Icon name="arrow" size={18} /></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer"><b>POST</b><span><strong>指定时刻总里程</strong><code>/api/v1/vehicles/total-mileage/query</code></span><Icon name="arrow" size={18} /></a></section></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Credentials({ app, onRotate }: { app: PortalApp; onRotate: () => void }) {
|
||||
return <>
|
||||
<PageHeader title="应用与密钥" description="查看应用授权状态,Owner 可以轮换 AppKey。" />
|
||||
<section className="panel credential-panel"><div className="panel-heading"><div><h2>{app.appName}</h2><p>应用 ID {app.appId}</p></div><Status value={app.appStatus} /></div><dl><div><dt>AppKey 安全标识</dt><dd><code>{app.appKeyPrefix}••••••••••••••••••••••••</code></dd></div><div><dt>当前角色</dt><dd><span className="role-chip">{app.role}</span></dd></div><div><dt>应用有效期</dt><dd>{formatDate(app.validFrom)} — {formatDate(app.validTo)}</dd></div></dl><div className="security-callout"><Icon name="shield" size={22} /><div><b>完整 AppKey 不会再次展示</b><p>如怀疑密钥泄露,请立即轮换。旧密钥会即时失效,操作会写入安全审计。</p></div>{app.role === "owner" ? <button className="button danger" onClick={onRotate}>轮换 AppKey</button> : <span>仅 Owner 可轮换</span>}</div></section>
|
||||
</>;
|
||||
}
|
||||
|
||||
function PartnerVehicles({ vehicles }: { vehicles: VehicleGrant[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = vehicles.filter((item) => `${item.plate} ${item.vin}`.toLowerCase().includes(query.toLowerCase()));
|
||||
return <><PageHeader title="授权车辆" description="这些车辆可以通过当前应用的数据接口访问。" action={<span className="header-count">{vehicles.length} 辆</span>} /><section className="panel directory-panel"><div className="table-toolbar"><div className="search-box"><Icon name="search" size={17} /><input aria-label="搜索授权车辆" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索车牌或 VIN" /></div></div><div className="table-scroll"><table><thead><tr><th>车牌号</th><th>VIN</th><th>授权起始</th><th>授权截止</th><th>状态</th></tr></thead><tbody>{filtered.map((item) => <tr key={item.vin}><td><b>{item.plate || "—"}</b></td><td><code>{item.vin}</code></td><td>{formatDate(item.validFrom)}</td><td>{formatDate(item.validTo)}</td><td><Status value="enabled" label="有效" /></td></tr>)}{!filtered.length && <tr><td colSpan={5}><div className="table-empty">没有匹配的授权车辆</div></td></tr>}</tbody></table></div></section></>;
|
||||
}
|
||||
|
||||
function ApiSandbox({ app, vehicles }: { app: PortalApp; vehicles: VehicleGrant[] }) {
|
||||
const [product, setProduct] = useState("hydrogen-consumption");
|
||||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [startDate, setStartDate] = useState(new Date(Date.now() - 6 * 86400000).toISOString().slice(0, 10));
|
||||
const [queryTime, setQueryTime] = useState(new Date().toISOString().slice(0, 16));
|
||||
const [protocol, setProtocol] = useState("");
|
||||
const [protocolPriority, setProtocolPriority] = useState("GB32960,MQTT,JT808");
|
||||
const plate = vehicles.find((item) => item.plate)?.plate || "授权车辆车牌";
|
||||
const vin = vehicles[0]?.vin || "授权车辆VIN";
|
||||
const isTotalMileage = product === "total-mileage";
|
||||
const isMileageRange = product === "mileage/range";
|
||||
const supportsProtocolPriority = product === "mileage" || isMileageRange;
|
||||
const priorityJSON = protocolPriority.split(",").map((item) => `"${item}"`).join(", ");
|
||||
const endpoint = `/api/v1/vehicles/${product}/query`;
|
||||
const requestBody = isTotalMileage ? `{
|
||||
"vin": "${vin}",
|
||||
"time": "${queryTime.replace("T", " ")}:00"${protocol ? `,
|
||||
"protocol": "${protocol}"` : ""}
|
||||
}` : isMileageRange ? `{
|
||||
"startDate": "${startDate}",
|
||||
"endDate": "${date}",
|
||||
"protocolPriority": [${priorityJSON}],
|
||||
"pageSize": 5000
|
||||
}` : `{
|
||||
"plateNumbers": ["${plate}"],
|
||||
"date": "${date}"${supportsProtocolPriority ? `,
|
||||
"protocolPriority": [${priorityJSON}]` : ""}
|
||||
}`;
|
||||
const curl = `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${endpoint} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '${requestBody}'`;
|
||||
return <><PageHeader title="API 调试" description="生成当前应用的请求示例,并进入 Swagger 发送真实请求。" action={<a className="button primary" href="/open-api/swagger/" target="_blank" rel="noreferrer">打开 Swagger ↗</a>} /><div className="sandbox-layout"><section className="panel sandbox-form"><div className="panel-heading"><div><h2>请求配置</h2><p>AppKey 请在 Swagger 的 Authorize 中安全输入。</p></div></div><label>数据接口<select value={product} onChange={(event) => setProduct(event.target.value)}><option value="hydrogen-consumption">车辆日用氢量</option><option value="mileage">车辆日里程</option><option value="mileage/range">车辆区间日里程</option><option value="total-mileage">指定时刻总里程</option></select></label>{isTotalMileage ? <><label>查询时间<input type="datetime-local" value={queryTime} onChange={(event) => setQueryTime(event.target.value)} /></label><label>采集协议<select value={protocol} onChange={(event) => setProtocol(event.target.value)}><option value="">自动选择(GB32960 > YUTONG_MQTT > JT808)</option><option value="GB32960">GB32960</option><option value="YUTONG_MQTT">YUTONG_MQTT</option><option value="JT808">JT808</option></select></label><label>示例 VIN<input value={vin} readOnly /></label></> : isMileageRange ? <><label>开始日期<input type="date" value={startDate} onChange={(event) => setStartDate(event.target.value)} /></label><label>结束日期<input type="date" value={date} onChange={(event) => setDate(event.target.value)} /></label></> : <><label>查询日期<input type="date" value={date} onChange={(event) => setDate(event.target.value)} /></label><label>示例车牌<input value={plate} readOnly /></label></>}{supportsProtocolPriority && <label>协议优先级<select value={protocolPriority} onChange={(event) => setProtocolPriority(event.target.value)}><option value="GB32960,MQTT,JT808">GB32960 > MQTT > JT808</option><option value="JT808,GB32960,MQTT">JT808 > GB32960 > MQTT</option><option value="GB32960,MQTT">GB32960 > MQTT</option><option value="JT808">仅 JT808</option></select></label>}<div className="sandbox-scope"><Icon name="car" size={18} /><span>当前应用共有 <b>{vehicles.length}</b> 辆授权车辆</span></div></section><section className="panel request-preview"><div className="panel-heading"><div><h2>请求预览</h2><p><b className="method">POST</b> {endpoint}</p></div><button className="button quiet" onClick={() => navigator.clipboard?.writeText(curl)}><Icon name="copy" size={16} />复制</button></div><pre><code>{curl}</code></pre></section></div></>;
|
||||
}
|
||||
|
||||
function PartnerAudit({ audit }: { audit: AuditItem[] }) {
|
||||
return <><PageHeader title="调用记录" description="定位失败请求,并通过 Trace ID 与平台管理员协作排查。" action={<span className="header-count">最近 {audit.length} 条</span>} /><section className="panel directory-panel"><div className="table-scroll"><table><thead><tr><th>时间</th><th>接口</th><th>车辆数</th><th>结果</th><th>Trace ID</th></tr></thead><tbody>{audit.map((item) => <tr key={`${item.traceId}-${item.createdAt}`}><td>{formatTime(item.createdAt)}</td><td><code>{item.endpoint}</code></td><td>{item.vehicleCount}</td><td><Status value={item.result} /></td><td><code>{item.traceId}</code></td></tr>)}{!audit.length && <tr><td colSpan={5}><div className="table-empty">暂无调用记录。完成首次 API 调用后会显示在这里。</div></td></tr>}</tbody></table></div></section></>;
|
||||
}
|
||||
|
||||
function Account({ session, onChanged }: { session: Session; onChanged: () => void }) {
|
||||
const [currentPassword, setCurrent] = useState("");
|
||||
const [newPassword, setNext] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setMessage("");
|
||||
try { await api.changePassword(currentPassword, newPassword); tokenStore.clear(); setMessage("密码已更新,请重新登录。"); window.setTimeout(onChanged, 900); }
|
||||
catch (reason) { setMessage(errorMessage(reason)); }
|
||||
}
|
||||
return <><PageHeader title="账号设置" description="查看当前身份和登录安全设置。" /><div className="account-layout"><section className="panel identity-panel"><span>{session.displayName.slice(0, 1).toUpperCase()}</span><h2>{session.displayName}</h2><p>{session.username}</p><dl><div><dt>身份类型</dt><dd>{session.userType === "admin" ? "平台管理员" : "合作伙伴开发者"}</dd></div><div><dt>会话有效至</dt><dd>{formatTime(session.expiresAt)}</dd></div><div><dt>账号 ID</dt><dd>{session.userId}</dd></div></dl></section>{session.userType === "admin" ? <section className="panel password-panel"><Icon name="shield" size={26} /><h2>统一管理员身份</h2><p>此账号与内部车辆平台共用身份和密码。请在内部平台维护密码,两个系统保持一致。</p><a className="button primary" href="https://vehicle.d.lnoneos.com/" target="_blank" rel="noreferrer">前往内部车辆平台 ↗</a></section> : <form className="panel password-panel" onSubmit={submit}><h2>修改登录密码</h2><p>密码需 12–128 位,并同时包含大小写字母和数字。</p><label>当前密码<input type="password" value={currentPassword} onChange={(event) => setCurrent(event.target.value)} /></label><label>新密码<input type="password" value={newPassword} onChange={(event) => setNext(event.target.value)} /></label>{message && <div className="notice-banner">{message}</div>}<button className="button primary" disabled={!currentPassword || !newPassword}>更新密码</button></form>}</div></>;
|
||||
}
|
||||
|
||||
function KeyModal({ value, onClose }: { value: string; onClose: () => void }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return <div className="modal-backdrop"><section className="key-modal" role="dialog" aria-modal="true" aria-labelledby="key-title"><span><Icon name="check" size={24} /></span><h2 id="key-title">AppKey 已生成</h2><p>出于安全考虑,此密钥只展示一次。请立即复制并存放到安全的密钥管理系统。</p><code>{value}</code><button className="button primary full" onClick={async () => { await navigator.clipboard?.writeText(value); setCopied(true); }}><Icon name="copy" size={17} />{copied ? "已复制" : "复制 AppKey"}</button><button className="button quiet full" onClick={onClose}>我已安全保存</button></section></div>;
|
||||
}
|
||||
313
vehicle-data-platform/apps/open-portal/src/Documentation.tsx
Normal file
313
vehicle-data-platform/apps/open-portal/src/Documentation.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { products } from "./catalog";
|
||||
import { Icon, navigate } from "./ui";
|
||||
|
||||
type Topic =
|
||||
| "overview" | "quickstart" | "auth" | "errors" | "limits"
|
||||
| "hydrogen" | "mileage" | "mileage-range" | "total-mileage"
|
||||
| "vehicle-auth" | "key-rotation" | "quality" | "checklist";
|
||||
|
||||
const groups: Array<{ title: string; items: Array<[Topic, string]> }> = [
|
||||
{ title: "开始使用", items: [["overview", "平台概览"], ["quickstart", "五分钟接入"], ["auth", "鉴权方式"], ["errors", "错误码与重试"], ["limits", "请求边界"]] },
|
||||
{ title: "接口参考", items: [["hydrogen", "车辆日用氢量"], ["mileage", "车辆日里程"], ["mileage-range", "车辆区间日里程"], ["total-mileage", "指定时刻总里程"]] },
|
||||
{ title: "最佳实践", items: [["vehicle-auth", "车辆授权"], ["key-rotation", "密钥轮换"], ["quality", "数据质量"], ["checklist", "上线检查清单"]] }
|
||||
];
|
||||
|
||||
const topicTitles = new Map(groups.flatMap((group) => group.items));
|
||||
|
||||
export function DocumentationPage() {
|
||||
const [topic, setTopic] = useState<Topic>("quickstart");
|
||||
const title = topicTitles.get(topic) || "";
|
||||
return <main className="developer-docs">
|
||||
<aside className="developer-docs-nav">
|
||||
<button className="docs-console-link" onClick={() => navigate("console")}>前往控制台 <Icon name="arrow" size={15} /></button>
|
||||
{groups.map((group) => <section key={group.title}><h2>{group.title}</h2>{group.items.map(([id, label]) => <button key={id} className={topic === id ? "active" : ""} onClick={() => setTopic(id)}>{label}</button>)}</section>)}
|
||||
<div className="docs-resources"><h2>相关资源</h2><a href="/open-api/openapi.yaml" target="_blank" rel="noreferrer">OpenAPI 3.0 <span>↗</span></a><a href="/open-api/swagger/" target="_blank" rel="noreferrer">Swagger 调试台 <span>↗</span></a></div>
|
||||
</aside>
|
||||
<article className="developer-docs-article">
|
||||
<div className="docs-breadcrumb">文档 <span>/</span> {groupFor(topic)} <span>/</span> {title}</div>
|
||||
<TopicContent topic={topic} />
|
||||
<DocsPagination topic={topic} onSelect={setTopic} />
|
||||
</article>
|
||||
<aside className="developer-docs-toc"><b>本页内容</b>{tocFor(topic).map((item) => <a key={item} href={`#${encodeURIComponent(item)}`}>{item}</a>)}</aside>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function TopicContent({ topic }: { topic: Topic }) {
|
||||
if (topic === "quickstart") return <Quickstart />;
|
||||
if (topic === "hydrogen" || topic === "mileage") return <APIReference kind={topic} />;
|
||||
if (topic === "mileage-range") return <MileageRangeReference />;
|
||||
if (topic === "total-mileage") return <TotalMileageReference />;
|
||||
if (topic === "overview") return <Overview />;
|
||||
if (topic === "auth") return <AuthGuide />;
|
||||
if (topic === "errors") return <ErrorGuide />;
|
||||
if (topic === "limits") return <LimitsGuide />;
|
||||
if (topic === "vehicle-auth") return <VehicleAuthGuide />;
|
||||
if (topic === "key-rotation") return <KeyRotationGuide />;
|
||||
if (topic === "quality") return <QualityGuide />;
|
||||
return <Checklist />;
|
||||
}
|
||||
|
||||
function Quickstart() {
|
||||
const curl = `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com/api/v1/vehicles/mileage/query \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"plateNumbers": ["辽A00001"],
|
||||
"date": "2026-07-19",
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||
}'`;
|
||||
return <>
|
||||
<h1>五分钟完成首次调用</h1>
|
||||
<p className="docs-lead">取得 AppKey、确认车辆授权,然后发送第一条真实查询。数据访问同时受应用、车辆和日期有效期约束。</p>
|
||||
<div className="docs-steps">
|
||||
<DocStep number="1" title="获取 AppKey"><p>平台管理员创建开放应用后,AppKey 仅展示一次。请立即保存到密钥管理系统,不要写入代码仓库。</p></DocStep>
|
||||
<DocStep number="2" title="确认授权车辆"><p>在控制台的“车辆授权”中,通过 VIN 或车牌选择车辆,并确认应用与车辆授权日期覆盖查询自然日。</p></DocStep>
|
||||
<DocStep number="3" title="发送请求"><Endpoint method="POST" path="/api/v1/vehicles/mileage/query" /><CodeBlock value={curl} language="bash" /><ParameterTable mileage /></DocStep>
|
||||
<DocStep number="4" title="验证响应"><p>HTTP 200 表示请求被接受。每辆车仍可能返回 <code>NORMAL</code> 或 <code>NO_DATA</code>,请按状态处理。</p><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "辽A00001",
|
||||
"date": "2026-07-19",
|
||||
"dailyMileageKm": 215.6,
|
||||
"totalMileageKm": 12345.6,
|
||||
"dataTime": "2026-07-19T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-20T05:10:00+08:00",
|
||||
"sourceProtocol": "GB32960",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "b7ff5582ab1a4e13bfb4f10943685599"
|
||||
}`} /></DocStep>
|
||||
</div>
|
||||
<ProductionChecklist compact />
|
||||
</>;
|
||||
}
|
||||
|
||||
function Overview() {
|
||||
return <>
|
||||
<h1>车辆数据开放平台</h1><p className="docs-lead">羚牛开放平台以应用为边界,对外提供经过授权的车辆日用氢量、单日与区间日里程、指定时刻总里程。</p>
|
||||
<h2 id="核心模型">核心模型</h2>
|
||||
<div className="docs-definition-list">{[
|
||||
["开放应用", "持有独立的 32 位 AppKey、状态和有效期。"],
|
||||
["合作伙伴", "使用独立账号登录开发者控制台,并获得 Owner、Developer 或 Viewer 角色。"],
|
||||
["车辆授权", "决定应用可以查询哪些 VIN,以及每辆车的授权起止日期。"],
|
||||
["调用审计", "保存接口、结果、车辆数量和 Trace ID,便于双方排查。"]
|
||||
].map(([term, body]) => <div key={term}><b>{term}</b><p>{body}</p></div>)}</div>
|
||||
<h2 id="开放能力">开放能力</h2><ProductTable />
|
||||
<h2 id="安全边界">安全边界</h2><p>外部 AppKey 与门户登录会话完全独立。AppKey 明文只在创建或轮换时返回一次,服务端仅保存不可逆摘要和展示前缀。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function AuthGuide() {
|
||||
return <>
|
||||
<h1>鉴权方式</h1><p className="docs-lead">所有数据接口都使用 HTTP Bearer 鉴权。不要把 AppKey 放进查询参数、浏览器前端代码或日志。</p>
|
||||
<h2 id="请求头">请求头</h2><CodeBlock language="http" value="Authorization: Bearer YOUR_32_CHARACTER_APP_KEY" />
|
||||
<h2 id="有效性判断">有效性判断</h2><ol className="docs-ordered"><li>应用必须处于启用状态。</li><li>当前时间必须落在应用有效期内。</li><li>查询自然日必须被应用有效期完整覆盖。</li><li>每辆车辆授权必须完整覆盖查询自然日。</li></ol>
|
||||
<h2 id="安全建议">安全建议</h2><p>使用专用密钥管理系统保存 AppKey;按环境拆分应用;怀疑泄漏时立即轮换;轮换前准备双配置并缩短切换窗口。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ErrorGuide() {
|
||||
return <>
|
||||
<h1>错误码与重试</h1><p className="docs-lead">业务错误会返回稳定的 code,并在响应中附带 Trace ID。提交工单时请同时提供 Trace ID 和请求时间。</p>
|
||||
<h2 id="错误码">错误码</h2><table className="docs-table"><thead><tr><th>HTTP</th><th>code</th><th>含义</th><th>是否重试</th></tr></thead><tbody>{[
|
||||
["400", "INVALID_REQUEST", "请求结构、车牌数量或字段不正确", "修正请求后再试"],
|
||||
["400", "INVALID_DATE_FORMAT", "date 不是 YYYY-MM-DD", "否"],
|
||||
["400", "INVALID_DATETIME_FORMAT", "time 不是 YYYY-MM-DD HH:mm:ss", "否"],
|
||||
["401", "UNAUTHORIZED", "AppKey 不存在、停用或过期", "否"],
|
||||
["403", "FORBIDDEN", "应用或车辆授权未覆盖查询日期", "否"],
|
||||
["500", "INTERNAL_ERROR", "服务内部异常", "可退避重试"]
|
||||
].map((row) => <tr key={row[1]}>{row.map((cell) => <td key={cell}><code>{cell}</code></td>)}</tr>)}</tbody></table>
|
||||
<h2 id="重试策略">重试策略</h2><p>仅对网络错误和 5xx 使用指数退避,建议间隔 1 秒、2 秒、4 秒,最多重试 3 次。400、401、403 需要先修正请求或授权,不应自动重试。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function LimitsGuide() {
|
||||
return <>
|
||||
<h1>请求边界</h1><p className="docs-lead">在批量和调度逻辑中遵循以下硬限制,可减少拒绝请求和无效重试。</p>
|
||||
<h2 id="参数限制">参数限制</h2><table className="docs-table"><thead><tr><th>项目</th><th>限制</th></tr></thead><tbody><tr><td>单日接口车牌数量</td><td>可省略;指定时最多 200 个,不能包含空值或重复</td></tr><tr><td>区间接口</td><td>最长 366 天;pageSize 1–5000;指定时最多 5000 个车牌</td></tr><tr><td>查询日期</td><td>YYYY-MM-DD,Asia/Shanghai</td></tr><tr><td>请求体</td><td>JSON,Content-Type 为 application/json</td></tr><tr><td>车辆授权配置</td><td>每个开放应用最多 5,000 辆</td></tr></tbody></table>
|
||||
<h2 id="调度建议">调度建议</h2><p>大范围历史同步优先使用区间接口并顺序消费 nextCursor;为每页记录 Trace ID 和 snapshotId;不要并发复用同一个游标。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function APIReference({ kind }: { kind: "hydrogen" | "mileage" }) {
|
||||
const product = products[kind === "hydrogen" ? 0 : 1];
|
||||
const fields = kind === "hydrogen" ? `"hydrogenConsumptionKg": 12.315` : `"dailyMileageKm": 182.437,\n "totalMileageKm": 12345.679,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",\n "sourceProtocol": "GB32960"`;
|
||||
return <>
|
||||
<h1>{product.name}</h1><p className="docs-lead">{product.description} 查询范围同时受 AppKey、车辆授权和授权有效期约束。</p>
|
||||
<Endpoint method={product.method} path={product.path} />
|
||||
<h2 id="请求参数">请求参数</h2><ParameterTable mileage={kind === "mileage"} />
|
||||
<h2 id="请求示例">请求示例</h2><p>省略 <code>plateNumbers</code> 时查询该应用在当日有效授权的全部车辆:</p><CodeBlock language="json" value={`{
|
||||
"date": "2026-07-19"${kind === "mileage" ? `,
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"]` : ""}
|
||||
}`} /><p>如只查询部分车辆,可传入最多 200 个车牌:</p><CodeBlock language="json" value={`{
|
||||
"plateNumbers": ["辽A00001", "辽A00002"],
|
||||
"date": "2026-07-19"${kind === "mileage" ? `,
|
||||
"protocolPriority": ["JT808", "GB32960", "MQTT"]` : ""}
|
||||
}`} />
|
||||
{kind === "mileage" && <><h2 id="协议优先级">协议优先级</h2><p><code>protocolPriority</code> 只允许 <code>GB32960</code>、<code>MQTT</code>、<code>JT808</code>。数组必须非空且不能重复;逐车按顺序选择第一个有效协议,未列出的协议完全禁用。不传时保持平台默认行为。</p><h2 id="缺日补齐">缺日补齐</h2><p>查询日没有有效里程但此前存在有效累计里程时,<code>dailyMileageKm</code> 返回 0,累计总里程、来源协议和数据时间沿用最近有效统计;<code>updatedAt</code> 保持为上一个统计周期的计算时间。此前也无有效累计里程时才返回 NO_DATA。</p></>}
|
||||
<h2 id="响应字段">响应字段</h2><table className="docs-table"><thead><tr><th>字段</th><th>类型</th><th>说明</th></tr></thead><tbody>{kind === "mileage" && <tr><td><code>vin</code></td><td>string</td><td>车辆 VIN</td></tr>}<tr><td><code>plateNumber</code></td><td>string</td><td>请求中的车牌号</td></tr><tr><td><code>date</code></td><td>string</td><td>统计自然日</td></tr><tr><td><code>{kind === "hydrogen" ? "hydrogenConsumptionKg" : "dailyMileageKm"}</code></td><td>number | null</td><td>{kind === "hydrogen" ? "用氢量,单位 kg" : "当日行驶里程,单位 km"}</td></tr>{kind === "mileage" && <><tr><td><code>totalMileageKm</code></td><td>number | null</td><td>同一协议在当日最后有效时刻的累计总里程</td></tr><tr><td><code>dataTime</code></td><td>date-time | null</td><td>统计实际采用的最后一条车辆源数据时间</td></tr><tr><td><code>updatedAt</code></td><td>date-time | null</td><td>日统计投影最后更新时间</td></tr><tr><td><code>sourceProtocol</code></td><td>string | null</td><td>实际选中的 GB32960、MQTT 或 JT808;NO_DATA 时为 null</td></tr></>}<tr><td><code>status</code></td><td>string</td><td>NORMAL 或 NO_DATA</td></tr></tbody></table>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
${kind === "mileage" ? `"vin": "LNB00000000000001",\n ` : ""}"plateNumber": "辽A00001",
|
||||
"plateNumber": "辽A00001",
|
||||
"date": "2026-07-19",
|
||||
${fields},
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}`} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function MileageRangeReference() {
|
||||
return <>
|
||||
<h1>车辆区间日里程</h1><p className="docs-lead">按最长 366 天区间分页返回逐车逐日里程。授权车辆清单在首次请求时固化,翻页期间不会因授权变化产生重复或漏行。</p>
|
||||
<Endpoint method="POST" path="/api/v1/vehicles/mileage/range/query" />
|
||||
<h2 id="请求参数">请求参数</h2><table className="docs-table"><thead><tr><th>字段</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>startDate</code></td><td>是</td><td>开始自然日,YYYY-MM-DD</td></tr><tr><td><code>endDate</code></td><td>是</td><td>结束自然日,包含当日;最长 366 天</td></tr><tr><td><code>plateNumbers</code></td><td>否</td><td>省略时查询整个区间均有效授权的全部车辆;指定时最多 5000 个</td></tr><tr><td><code>protocolPriority</code></td><td>否</td><td>非空且不重复的 GB32960、MQTT、JT808 数组;未列出的协议禁用</td></tr><tr><td><code>cursor</code></td><td>否</td><td>首次省略或传 null;翻页时传上一页 nextCursor</td></tr><tr><td><code>pageSize</code></td><td>否</td><td>1–5000,默认 5000</td></tr></tbody></table>
|
||||
<h2 id="首次请求">首次请求</h2><CodeBlock language="json" value={`{
|
||||
"startDate": "2026-07-01",
|
||||
"endDate": "2026-07-23",
|
||||
"protocolPriority": ["JT808", "GB32960", "MQTT"],
|
||||
"pageSize": 5000
|
||||
}`} />
|
||||
<h2 id="游标翻页">游标翻页</h2><p>保持开始日期、结束日期、车牌列表、protocolPriority 和 pageSize 不变,只把上一页 <code>nextCursor</code> 放入下一次请求。同一次查询的 <code>snapshotId</code> 保持不变,最后一页 <code>nextCursor</code> 为 null。</p>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "沪A00001",
|
||||
"date": "2026-07-01",
|
||||
"dailyMileageKm": 182.437,
|
||||
"totalMileageKm": 12345.679,
|
||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||
"sourceProtocol": "JT808",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"snapshotId": "9f8a74efbf9846349ae5676f3a5c0de8",
|
||||
"nextCursor": null,
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}`} />
|
||||
<h2 id="性能口径">性能口径</h2><p>接口只固化最多 5000 辆授权车辆,不预生成“车辆数 × 天数”的快照明细。每页直接读取已建立复合索引的日统计投影,不扫描原始时序明细。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function TotalMileageReference() {
|
||||
return <>
|
||||
<h1>指定时刻总里程</h1><p className="docs-lead">按已授权 VIN 查询不晚于指定北京时间的最近一条有效总里程,并返回实际采集协议、记录时间和时间差秒数。</p>
|
||||
<Endpoint method="POST" path="/api/v1/vehicles/total-mileage/query" />
|
||||
<h2 id="请求参数">请求参数</h2><table className="docs-table"><thead><tr><th>字段</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>vin</code></td><td>是</td><td>17 位已授权 VIN</td></tr><tr><td><code>time</code></td><td>是</td><td>北京时间,YYYY-MM-DD HH:mm:ss</td></tr><tr><td><code>protocol</code></td><td>否</td><td>唯一规范值:GB32960、YUTONG_MQTT 或 JT808;不传时自动选择</td></tr></tbody></table>
|
||||
<h2 id="请求示例">请求示例</h2><CodeBlock language="json" value={`{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"time": "2026-07-21 09:30:00",
|
||||
"protocol": "GB32960"
|
||||
}`} />
|
||||
<h2 id="协议口径">协议口径</h2><table className="docs-table"><thead><tr><th>协议</th><th>总里程含义</th></tr></thead><tbody><tr><td><code>GB32960</code></td><td>车辆仪表盘累计总里程</td></tr><tr><td><code>YUTONG_MQTT</code></td><td>车辆仪表盘或车端控制器累计总里程</td></tr><tr><td><code>JT808</code></td><td>GPS/定位终端侧计算的累计里程,不等同于仪表盘里程</td></tr></tbody></table><p>不同协议不能拼接为同一条连续里程曲线。未指定协议时,优先级代表数据源选择顺序,不代表数值可相互替代。</p>
|
||||
<h2 id="响应示例">响应示例</h2><CodeBlock language="json" value={`{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"queryTime": "2026-07-21 09:30:00",
|
||||
"totalMileageKm": 12345.678,
|
||||
"protocol": "GB32960",
|
||||
"recordTime": "2026-07-21 09:29:45",
|
||||
"timeDifferenceSeconds": 15,
|
||||
"selectionPolicy": "GB32960 > YUTONG_MQTT > JT808",
|
||||
"status": "NORMAL"
|
||||
}
|
||||
}`} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function VehicleAuthGuide() {
|
||||
return <>
|
||||
<h1>车辆授权</h1><p className="docs-lead">平台管理员可以从车辆目录选择、批量粘贴 VIN/车牌,或一次选择当前全部车辆。</p>
|
||||
<h2 id="授权关系">授权关系</h2><p>车辆授权属于开放应用,而不是合作伙伴账号。获得该应用角色的合作伙伴只能查询应用已经授权的车辆。</p>
|
||||
<h2 id="配置步骤">配置步骤</h2><ol className="docs-ordered"><li>选择目标开放应用。</li><li>设置车辆授权开始与截止日期。</li><li>从目录选择,或粘贴 VIN/车牌识别车辆。</li><li>核对右侧已选车辆并保存。</li></ol>
|
||||
<div className="docs-callout"><Icon name="shield" size={20} /><div><b>完整替换语义</b><p>保存时会用当前选中范围完整替换旧范围。未来新增主车辆不会自动获得权限,需要管理员再次确认。</p></div></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function KeyRotationGuide() {
|
||||
return <>
|
||||
<h1>密钥轮换</h1><p className="docs-lead">轮换会生成新的 32 位 AppKey,旧 Key 立即失效,完整新 Key 只展示一次。</p>
|
||||
<h2 id="推荐流程">推荐流程</h2><ol className="docs-ordered"><li>在调用方准备可快速更新的密钥配置。</li><li>在低流量窗口执行轮换。</li><li>立即复制新 Key 并更新调用方。</li><li>发送一条授权车辆测试请求。</li><li>在调用记录中确认成功。</li></ol>
|
||||
<h2 id="权限要求">权限要求</h2><p>平台管理员和应用 Owner 可以轮换密钥;Developer 与 Viewer 无权执行。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function QualityGuide() {
|
||||
return <>
|
||||
<h1>数据质量</h1><p className="docs-lead">HTTP 成功不等于每辆车都有有效统计值。请始终读取每个结果对象的 status。</p>
|
||||
<h2 id="状态定义">状态定义</h2><table className="docs-table"><thead><tr><th>状态</th><th>含义</th><th>建议</th></tr></thead><tbody><tr><td><code>NORMAL</code></td><td>存在满足口径的完整单日统计</td><td>正常使用数值</td></tr><tr><td><code>NO_DATA</code></td><td>授权有效,但当天没有足够有效样本</td><td>保留空值,后续补查</td></tr></tbody></table>
|
||||
<h2 id="统计口径">统计口径</h2><p>日里程根据有效累计里程差计算;多协议数据按平台质量策略选择。日用氢量按同一数据源内的有效氢质量变化统计,加氢上升不计为消耗。</p>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
return <><h1>上线检查清单</h1><p className="docs-lead">在生产调度启用前,逐项完成以下检查。</p><ProductionChecklist /></>;
|
||||
}
|
||||
|
||||
function ProductionChecklist({ compact = false }: { compact?: boolean }) {
|
||||
const items = ["AppKey 已安全保存且应用未过期", "目标车辆已授权且日期范围有效", "请求日期使用 YYYY-MM-DD", "按 NORMAL / NO_DATA 处理结果", "记录 Trace ID 并实现 5xx 退避重试", "生产日志不会输出完整 AppKey"];
|
||||
return <section className={`production-checklist${compact ? " compact" : ""}`}><header><Icon name="check" size={20} /><div><h2 id="生产前检查">生产前检查</h2><p>完成后再启用定时任务。</p></div></header>{items.map((item) => <label key={item}><input type="checkbox" />{item}</label>)}</section>;
|
||||
}
|
||||
|
||||
function DocStep({ number, title, children }: { number: string; title: string; children: ReactNode }) {
|
||||
return <section className="doc-step"><span>{number}</span><div><h2 id={title}>{title}</h2>{children}</div></section>;
|
||||
}
|
||||
|
||||
function Endpoint({ method, path }: { method: string; path: string }) {
|
||||
return <div className="docs-endpoint"><b>{method}</b><code>{path}</code><button onClick={() => navigator.clipboard?.writeText(path)}><Icon name="copy" size={15} />复制</button></div>;
|
||||
}
|
||||
|
||||
function CodeBlock({ value, language }: { value: string; language: string }) {
|
||||
return <pre className="docs-code"><span>{language}</span><button onClick={() => navigator.clipboard?.writeText(value)}><Icon name="copy" size={14} />复制</button><code>{value}</code></pre>;
|
||||
}
|
||||
|
||||
function ParameterTable({ mileage = false }: { mileage?: boolean }) {
|
||||
return <table className="docs-table"><thead><tr><th>字段</th><th>类型</th><th>必填</th><th>说明</th></tr></thead><tbody><tr><td><code>plateNumbers</code></td><td>string[]</td><td>否</td><td>省略或空数组时查询全部有效授权车辆;指定时最多 200 个且不允许重复</td></tr><tr><td><code>date</code></td><td>string</td><td>是</td><td>自然日,格式 YYYY-MM-DD</td></tr>{mileage && <tr><td><code>protocolPriority</code></td><td>string[]</td><td>否</td><td>协议选源顺序;只允许 GB32960、MQTT、JT808,未列出的协议禁用</td></tr>}</tbody></table>;
|
||||
}
|
||||
|
||||
function ProductTable() {
|
||||
return <table className="docs-table"><thead><tr><th>数据产品</th><th>方法</th><th>路径</th><th>单位</th></tr></thead><tbody>{products.map((item) => <tr key={item.code}><td>{item.name}</td><td><code>{item.method}</code></td><td><code>{item.path}</code></td><td>{item.unit}</td></tr>)}</tbody></table>;
|
||||
}
|
||||
|
||||
function DocsPagination({ topic, onSelect }: { topic: Topic; onSelect: (topic: Topic) => void }) {
|
||||
const topics = useMemo(() => groups.flatMap((group) => group.items), []);
|
||||
const index = topics.findIndex(([id]) => id === topic);
|
||||
const previous = topics[index - 1];
|
||||
const next = topics[index + 1];
|
||||
return <footer className="docs-pagination">{previous ? <button onClick={() => onSelect(previous[0])}><small>上一篇</small><b>← {previous[1]}</b></button> : <span />}{next && <button onClick={() => onSelect(next[0])}><small>下一篇</small><b>{next[1]} →</b></button>}</footer>;
|
||||
}
|
||||
|
||||
function groupFor(topic: Topic) {
|
||||
return groups.find((group) => group.items.some(([id]) => id === topic))?.title || "";
|
||||
}
|
||||
|
||||
function tocFor(topic: Topic) {
|
||||
const map: Partial<Record<Topic, string[]>> = {
|
||||
quickstart: ["获取 AppKey", "确认授权车辆", "发送请求", "验证响应", "生产前检查"],
|
||||
overview: ["核心模型", "开放能力", "安全边界"],
|
||||
auth: ["请求头", "有效性判断", "安全建议"],
|
||||
errors: ["错误码", "重试策略"],
|
||||
limits: ["参数限制", "调度建议"],
|
||||
hydrogen: ["请求参数", "请求示例", "响应字段", "响应示例"],
|
||||
mileage: ["请求参数", "请求示例", "响应字段", "响应示例"],
|
||||
"mileage-range": ["请求参数", "首次请求", "游标翻页", "响应示例", "性能口径"],
|
||||
"total-mileage": ["请求参数", "请求示例", "协议口径", "响应示例"],
|
||||
"vehicle-auth": ["授权关系", "配置步骤"],
|
||||
"key-rotation": ["推荐流程", "权限要求"],
|
||||
quality: ["状态定义", "统计口径"],
|
||||
checklist: ["生产前检查"]
|
||||
};
|
||||
return map[topic] || [];
|
||||
}
|
||||
193
vehicle-data-platform/apps/open-portal/src/PublicSite.tsx
Normal file
193
vehicle-data-platform/apps/open-portal/src/PublicSite.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useState } from "react";
|
||||
import { DocumentationPage } from "./Documentation";
|
||||
import { Brand, Icon, navigate, type Route } from "./ui";
|
||||
|
||||
export function PublicSite({ route }: { route: Exclude<Route, "console"> }) {
|
||||
return (
|
||||
<div className="public-site">
|
||||
<PublicHeader route={route} />
|
||||
{route === "docs" ? <DocumentationPage /> : <HomePage />}
|
||||
<PublicFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function scrollToSection(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
function PublicHeader({ route }: { route: Exclude<Route, "console"> }) {
|
||||
return (
|
||||
<header className="public-header">
|
||||
<Brand />
|
||||
<nav aria-label="主导航">
|
||||
<button onClick={() => route === "home" ? scrollToSection("apis") : navigate("home")}>API</button>
|
||||
<button className={route === "docs" ? "active" : ""} onClick={() => navigate("docs")}>文档</button>
|
||||
</nav>
|
||||
<button className="header-console" onClick={() => navigate("console")}>控制台 <Icon name="arrow" size={14} /></button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<main className="home-v2">
|
||||
<section className="home-hero">
|
||||
<div className="home-hero-copy">
|
||||
<h1>车辆数据,按需开放。</h1>
|
||||
<p>用统一 API 访问车辆日用氢量与日里程。每次调用受应用、车辆和日期范围约束。</p>
|
||||
<div className="home-actions">
|
||||
<button className="button home-primary" onClick={() => navigate("docs")}>阅读文档</button>
|
||||
<button className="home-text-link" onClick={() => navigate("console")}>进入控制台 <Icon name="arrow" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<ApiWorkbench />
|
||||
</section>
|
||||
|
||||
<section className="fact-rail" aria-label="接口事实">
|
||||
<span><b>2</b> 项数据能力</span>
|
||||
<span>VIN / 车牌授权</span>
|
||||
<span>指定 1–200 辆 / 留空查全部</span>
|
||||
<span>Trace ID</span>
|
||||
</section>
|
||||
|
||||
<section className="api-directory" id="apis">
|
||||
<header className="home-section-heading">
|
||||
<span>01</span>
|
||||
<h2>可用接口</h2>
|
||||
</header>
|
||||
<ApiRow mark="H₂" name="车辆日用氢量" path="/api/v1/vehicles/hydrogen-consumption/query" unit="kg" />
|
||||
<ApiRow mark="KM" name="车辆日里程" path="/api/v1/vehicles/mileage/query" unit="km" />
|
||||
</section>
|
||||
|
||||
<section className="integration-section">
|
||||
<header className="home-section-heading">
|
||||
<span>02</span>
|
||||
<h2>开始接入</h2>
|
||||
</header>
|
||||
<div className="integration-line">
|
||||
{[
|
||||
["01", "获取 AppKey", "创建开放应用并保存一次性展示的 AppKey。"],
|
||||
["02", "授权车辆", "按 VIN 或车牌配置车辆和有效期。"],
|
||||
["03", "调用 API", "按自然日查询数据,并记录 Trace ID。"]
|
||||
].map(([number, title, body]) => (
|
||||
<article key={number}>
|
||||
<span>{number}</span>
|
||||
<div><h3>{title}</h3><p>{body}</p></div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="docs-call-to-action">
|
||||
<div>
|
||||
<Icon name="book" size={34} />
|
||||
<span><h2>查看完整接口文档</h2><p>请求参数、响应结构、错误码与重试策略。</p></span>
|
||||
</div>
|
||||
<button onClick={() => navigate("docs")}>打开文档 <Icon name="arrow" size={17} /></button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
type ApiKind = "mileage" | "hydrogen";
|
||||
|
||||
const apiExamples: Record<ApiKind, {
|
||||
tab: string;
|
||||
path: string;
|
||||
responseField: string;
|
||||
value: string;
|
||||
extraResponse?: string;
|
||||
}> = {
|
||||
mileage: {
|
||||
tab: "日里程",
|
||||
path: "/api/v1/vehicles/mileage/query",
|
||||
responseField: "dailyMileageKm",
|
||||
value: "215.6",
|
||||
extraResponse: `\n "totalMileageKm": 12345.6,\n "dataTime": "2026-07-19T23:58:45+08:00",\n "updatedAt": "2026-07-20T05:10:00+08:00",`
|
||||
},
|
||||
hydrogen: {
|
||||
tab: "日用氢量",
|
||||
path: "/api/v1/vehicles/hydrogen-consumption/query",
|
||||
responseField: "hydrogenConsumptionKg",
|
||||
value: "8.42"
|
||||
}
|
||||
};
|
||||
|
||||
function ApiWorkbench() {
|
||||
const [kind, setKind] = useState<ApiKind>("mileage");
|
||||
const current = apiExamples[kind];
|
||||
return (
|
||||
<div className="api-workbench">
|
||||
<div className="workbench-tabs" role="tablist" aria-label="接口示例">
|
||||
{(Object.keys(apiExamples) as ApiKind[]).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
role="tab"
|
||||
aria-selected={kind === item}
|
||||
className={kind === item ? "active" : ""}
|
||||
onClick={() => setKind(item)}
|
||||
>
|
||||
{apiExamples[item].tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="workbench-grid">
|
||||
<div className="workbench-request">
|
||||
<header><b>POST</b><code>{current.path}</code></header>
|
||||
<div className="workbench-pane-title">请求</div>
|
||||
<label>
|
||||
<span>plateNumbers</span>
|
||||
<code>["辽A00001", "辽A00002"]</code>
|
||||
</label>
|
||||
<label>
|
||||
<span>date</span>
|
||||
<code>"2026-07-19"</code>
|
||||
</label>
|
||||
</div>
|
||||
<div className="workbench-response">
|
||||
<header><span>响应</span><b>200 OK</b></header>
|
||||
<pre aria-label={`${current.tab}响应示例`}><code>{`{
|
||||
"code": "SUCCESS",
|
||||
"data": [{
|
||||
"plateNumber": "辽A00001",
|
||||
"date": "2026-07-19",
|
||||
"${current.responseField}": ${current.value},
|
||||
${current.extraResponse || ""}
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "b7ff5582ab1a4e13"
|
||||
}`}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiRow({ mark, name, path, unit }: { mark: string; name: string; path: string; unit: string }) {
|
||||
return (
|
||||
<article className="api-directory-row">
|
||||
<span className="api-mark">{mark}</span>
|
||||
<div className="api-row-main">
|
||||
<h3>{name}</h3>
|
||||
<p><b>POST</b><code>{path}</code></p>
|
||||
</div>
|
||||
<div className="api-unit"><small>单位</small><span>{unit}</span></div>
|
||||
<button onClick={() => navigate("docs")}>接口说明 <Icon name="arrow" size={17} /></button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicFooter() {
|
||||
return (
|
||||
<footer className="public-footer">
|
||||
<Brand compact />
|
||||
<nav aria-label="页脚导航">
|
||||
<button onClick={() => scrollToSection("apis")}>API</button>
|
||||
<button onClick={() => navigate("docs")}>文档</button>
|
||||
<button onClick={() => navigate("console")}>控制台</button>
|
||||
</nav>
|
||||
<span>© 2026 Lingniu Technology</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
import { api, type AdminApp, type VehicleCatalogItem, type VehicleGrant } from "./api";
|
||||
import { dateInput, errorMessage, Icon, PageHeader, rfcDate, Status } from "./ui";
|
||||
|
||||
type Mode = "directory" | "paste" | "all";
|
||||
|
||||
const today = () => new Date().toISOString().slice(0, 10);
|
||||
const nextYear = () => {
|
||||
const date = new Date();
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export function VehicleAuthorization({
|
||||
apps,
|
||||
setNotice,
|
||||
refresh
|
||||
}: {
|
||||
apps: AdminApp[];
|
||||
setNotice: (value: string) => void;
|
||||
refresh: () => Promise<void>;
|
||||
}) {
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [catalog, setCatalog] = useState<VehicleCatalogItem[]>([]);
|
||||
const [grants, setGrants] = useState<VehicleGrant[]>([]);
|
||||
const [selected, setSelected] = useState<Set<string>>(() => new Set());
|
||||
const [mode, setMode] = useState<Mode>("directory");
|
||||
const [query, setQuery] = useState("");
|
||||
const [batchText, setBatchText] = useState("");
|
||||
const [validFrom, setFrom] = useState(today());
|
||||
const [validTo, setTo] = useState(nextYear());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const deferredQuery = useDeferredValue(query.trim().toUpperCase());
|
||||
const deferredBatch = useDeferredValue(batchText);
|
||||
|
||||
useEffect(() => {
|
||||
api.adminVehicles()
|
||||
.then(setCatalog)
|
||||
.catch((reason) => setNotice(errorMessage(reason)))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId && apps[0]) setAppId(apps[0].id);
|
||||
}, [apps, appId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!appId) return;
|
||||
api.vehicles(appId).then((items) => {
|
||||
setGrants(items);
|
||||
setSelected(new Set(items.map((item) => item.vin)));
|
||||
setMode("directory");
|
||||
setBatchText("");
|
||||
if (items[0]) {
|
||||
setFrom(dateInput(items[0].validFrom));
|
||||
setTo(dateInput(items[0].validTo) || nextYear());
|
||||
}
|
||||
}).catch((reason) => setNotice(errorMessage(reason)));
|
||||
}, [appId]);
|
||||
|
||||
const catalogByVIN = useMemo(() => new Map(catalog.map((item) => [item.vin.toUpperCase(), item])), [catalog]);
|
||||
const catalogByPlate = useMemo(() => {
|
||||
const values = new Map<string, VehicleCatalogItem>();
|
||||
for (const item of catalog) {
|
||||
const plate = item.plate.trim().toUpperCase();
|
||||
if (plate && !values.has(plate)) values.set(plate, item);
|
||||
}
|
||||
return values;
|
||||
}, [catalog]);
|
||||
const filtered = useMemo(() => {
|
||||
if (!deferredQuery) return catalog;
|
||||
return catalog.filter((item) => `${item.vin} ${item.plate} ${item.oem}`.toUpperCase().includes(deferredQuery));
|
||||
}, [catalog, deferredQuery]);
|
||||
const batchResult = useMemo(() => {
|
||||
const values = [...new Set(deferredBatch.split(/[\s,,;;]+/).map((item) => item.trim().toUpperCase()).filter(Boolean))];
|
||||
const matched: VehicleCatalogItem[] = [];
|
||||
const unmatched: string[] = [];
|
||||
for (const value of values) {
|
||||
const item = catalogByVIN.get(value) || catalogByPlate.get(value);
|
||||
if (item && !matched.some((vehicle) => vehicle.vin === item.vin)) matched.push(item);
|
||||
else if (!item) unmatched.push(value);
|
||||
}
|
||||
return { matched, unmatched };
|
||||
}, [deferredBatch, catalogByVIN, catalogByPlate]);
|
||||
const selectedVehicles = useMemo(() => {
|
||||
const existingByVIN = new Map(grants.map((item) => [item.vin, item]));
|
||||
return [...selected].map((vin) => {
|
||||
const item = catalogByVIN.get(vin);
|
||||
const existing = existingByVIN.get(vin);
|
||||
return item || {
|
||||
vin,
|
||||
plate: existing?.plate || "",
|
||||
oem: "",
|
||||
source: "已有授权",
|
||||
status: "available"
|
||||
};
|
||||
}).sort((left, right) => (left.plate || left.vin).localeCompare(right.plate || right.vin, "zh-CN"));
|
||||
}, [selected, grants, catalogByVIN]);
|
||||
|
||||
function toggle(vin: string) {
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(vin)) next.delete(vin);
|
||||
else next.add(vin);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function chooseMode(next: Mode) {
|
||||
setMode(next);
|
||||
if (next === "all") setSelected(new Set(catalog.map((item) => item.vin)));
|
||||
}
|
||||
|
||||
function applyBatch() {
|
||||
setSelected(new Set(batchResult.matched.map((item) => item.vin)));
|
||||
if (batchResult.unmatched.length) {
|
||||
setNotice(`已识别 ${batchResult.matched.length} 辆,${batchResult.unmatched.length} 条未匹配。`);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setSelected(new Set(grants.map((item) => item.vin)));
|
||||
setBatchText("");
|
||||
setMode("directory");
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!validFrom || !validTo || validTo <= validFrom) {
|
||||
setNotice("车辆授权截止日期必须晚于生效日期。");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.replaceVehicles(appId, selectedVehicles.map((item) => ({
|
||||
vin: item.vin,
|
||||
validFrom: rfcDate(validFrom),
|
||||
validTo: rfcDate(validTo)
|
||||
})));
|
||||
setGrants(result);
|
||||
setSelected(new Set(result.map((item) => item.vin)));
|
||||
setNotice(`已保存 ${result.length} 辆车辆授权。`);
|
||||
await refresh();
|
||||
} catch (reason) {
|
||||
setNotice(errorMessage(reason));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const allFilteredSelected = filtered.length > 0 && filtered.every((item) => selected.has(item.vin));
|
||||
|
||||
return <>
|
||||
<PageHeader title="车辆授权" description="为开放应用配置可访问的车辆范围。" />
|
||||
{!apps.length ? <div className="empty-state"><h2>还没有可配置的应用</h2><p>请先创建开放应用。</p></div> :
|
||||
<form className="vehicle-auth" onSubmit={submit}>
|
||||
<section className="vehicle-auth-rail">
|
||||
<label><span>选择应用</span><select value={appId} onChange={(event) => setAppId(Number(event.target.value))}>{apps.map((app) => <option value={app.id} key={app.id}>{app.name}</option>)}</select></label>
|
||||
<div className="auth-dates"><label><span>授权开始</span><input type="date" value={validFrom} onChange={(event) => setFrom(event.target.value)} /></label><i>—</i><label><span>授权截止</span><input type="date" value={validTo} onChange={(event) => setTo(event.target.value)} /></label></div>
|
||||
</section>
|
||||
|
||||
<div className="auth-mode-tabs" role="tablist" aria-label="车辆添加方式">
|
||||
<button type="button" role="tab" aria-selected={mode === "directory"} className={mode === "directory" ? "active" : ""} onClick={() => chooseMode("directory")}>从车辆目录选择</button>
|
||||
<button type="button" role="tab" aria-selected={mode === "paste"} className={mode === "paste" ? "active" : ""} onClick={() => chooseMode("paste")}>批量粘贴</button>
|
||||
<button type="button" role="tab" aria-selected={mode === "all"} className={mode === "all" ? "active" : ""} onClick={() => chooseMode("all")}>全部车辆</button>
|
||||
</div>
|
||||
|
||||
{mode === "paste" && <section className="batch-vehicle-entry">
|
||||
<div><h2>批量添加 VIN 或车牌</h2><p>每行一个 VIN 或车牌,也支持空格、逗号分隔。</p></div>
|
||||
<textarea aria-label="批量输入 VIN 或车牌" value={batchText} onChange={(event) => setBatchText(event.target.value)} placeholder={"LNB00000000000001\n辽A00001"} />
|
||||
<footer><span className={batchResult.unmatched.length ? "has-error" : ""}>识别 {batchResult.matched.length} 辆 · 未匹配 {batchResult.unmatched.length} 条</span><button type="button" className="button primary" onClick={applyBatch} disabled={!batchText.trim()}>应用识别结果</button></footer>
|
||||
{batchResult.unmatched.length > 0 && <p className="batch-unmatched">未匹配:{batchResult.unmatched.slice(0, 8).join("、")}{batchResult.unmatched.length > 8 ? "…" : ""}</p>}
|
||||
</section>}
|
||||
|
||||
{mode === "all" && <section className="all-vehicles-confirm">
|
||||
<span><Icon name="check" size={20} /></span><div><h2>已选择当前全部车辆</h2><p>本次保存将授权车辆主数据中的 {catalog.length} 辆车。未来新增车辆不会自动获得权限,需要管理员再次确认。</p></div><strong>{catalog.length}<small> 辆</small></strong>
|
||||
</section>}
|
||||
|
||||
<section className="vehicle-picker">
|
||||
<div className="vehicle-directory">
|
||||
<header>
|
||||
<div className="search-box"><Icon name="search" size={17} /><input aria-label="搜索 VIN 或车牌" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索 VIN、车牌或品牌" /></div>
|
||||
<span>已选择 <b>{selected.size}</b> 辆</span>
|
||||
</header>
|
||||
<div className="vehicle-table-scroll">
|
||||
<table>
|
||||
<thead><tr><th><input aria-label="选择当前筛选的全部车辆" type="checkbox" checked={allFilteredSelected} onChange={() => setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (allFilteredSelected) filtered.forEach((item) => next.delete(item.vin));
|
||||
else filtered.forEach((item) => next.add(item.vin));
|
||||
return next;
|
||||
})} /></th><th>车辆</th><th>VIN</th><th>数据来源</th><th>状态</th></tr></thead>
|
||||
<tbody>{filtered.map((item) => <tr key={item.vin} className={selected.has(item.vin) ? "selected" : ""} onClick={() => toggle(item.vin)}>
|
||||
<td><input aria-label={`选择 ${item.plate || item.vin}`} type="checkbox" checked={selected.has(item.vin)} onChange={() => toggle(item.vin)} onClick={(event) => event.stopPropagation()} /></td>
|
||||
<td><b>{item.plate || "未绑定车牌"}</b><small>{item.oem || "品牌待补充"}</small></td>
|
||||
<td><code>{item.vin}</code></td><td>{item.source}</td><td><Status value={item.status} label="可授权" /></td>
|
||||
</tr>)}{!filtered.length && <tr><td colSpan={5}><div className="table-empty">{loading ? "正在加载车辆目录…" : "没有匹配的车辆"}</div></td></tr>}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer><span>共 {catalog.length} 辆主车辆</span>{deferredQuery && <button type="button" className="link-button" onClick={() => setQuery("")}>清除搜索</button>}</footer>
|
||||
</div>
|
||||
|
||||
<aside className="selected-vehicles">
|
||||
<header><div><h2>已选择</h2><p>{apps.find((app) => app.id === appId)?.name}</p></div><button type="button" className="link-button" onClick={() => setSelected(new Set())}>清空</button></header>
|
||||
<div>{selectedVehicles.slice(0, 12).map((item) => <article key={item.vin}><span><Icon name="car" size={17} /></span><div><b>{item.plate || "未绑定车牌"}</b><code>{item.vin}</code></div><button type="button" aria-label={`移除 ${item.plate || item.vin}`} onClick={() => toggle(item.vin)}><Icon name="close" size={15} /></button></article>)}</div>
|
||||
{selectedVehicles.length > 12 && <p className="selection-more">以及其他 {selectedVehicles.length - 12} 辆</p>}
|
||||
{!selectedVehicles.length && <div className="selection-empty"><Icon name="car" size={24} /><p>从左侧目录选择车辆,或使用批量粘贴。</p></div>}
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer className="vehicle-auth-actions"><span>保存后将完整替换该应用当前的车辆范围。</span><div><button type="button" className="button secondary" onClick={reset}>取消</button><button className="button primary" disabled={saving}>{saving ? "正在保存…" : `保存 ${selected.size} 辆授权`}</button></div></footer>
|
||||
</form>}
|
||||
</>;
|
||||
}
|
||||
174
vehicle-data-platform/apps/open-portal/src/api.ts
Normal file
174
vehicle-data-platform/apps/open-portal/src/api.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
export type Session = {
|
||||
userId: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
userType: "admin" | "partner";
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type PortalApp = {
|
||||
appId: number;
|
||||
appName: string;
|
||||
appKeyPrefix: string;
|
||||
appStatus: string;
|
||||
role: "owner" | "developer" | "viewer";
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
export type VehicleGrant = {
|
||||
vin: string;
|
||||
plate: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
export type VehicleCatalogItem = {
|
||||
vin: string;
|
||||
plate: string;
|
||||
oem: string;
|
||||
status: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type AuditItem = {
|
||||
traceId: string;
|
||||
endpoint: string;
|
||||
result: string;
|
||||
vehicleCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
status: string;
|
||||
method: string;
|
||||
path: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export type AdminApp = {
|
||||
id: number;
|
||||
name: string;
|
||||
appKeyPrefix: string;
|
||||
status: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
};
|
||||
|
||||
export type PortalUser = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: string;
|
||||
validFrom: string;
|
||||
validTo?: string;
|
||||
lastLoginAt?: string;
|
||||
};
|
||||
|
||||
type Envelope<T> = { data: T; error?: { code?: string; message?: string; detail?: string } };
|
||||
|
||||
const TOKEN_KEY = "lingniu-open-platform-token";
|
||||
|
||||
export const tokenStore = {
|
||||
get: () => sessionStorage.getItem(TOKEN_KEY) || "",
|
||||
set: (token: string) => sessionStorage.setItem(TOKEN_KEY, token),
|
||||
clear: () => sessionStorage.removeItem(TOKEN_KEY)
|
||||
};
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}, authenticated = true): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body) headers.set("Content-Type", "application/json");
|
||||
const token = tokenStore.get();
|
||||
if (authenticated && token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await fetch(path, { ...init, headers });
|
||||
const body = (await response.json().catch(() => ({}))) as Envelope<T>;
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && authenticated) tokenStore.clear();
|
||||
throw new Error(readableError(body.error, response.status));
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
function readableError(error: Envelope<unknown>["error"], status: number) {
|
||||
const detail = error?.detail || "";
|
||||
if (detail.includes("请求内容不能为空")) return "请求内容不能为空,请刷新页面后重试";
|
||||
if (detail.includes("不是有效的 JSON") || detail.includes("无法解析")) return "提交内容无法解析,请刷新页面后重试";
|
||||
if (detail.includes("数据类型不正确")) return detail;
|
||||
if (detail.includes("未支持的字段")) return detail;
|
||||
if (detail.includes("password must be 12-128")) return "初始密码必须为 12–128 位,并包含大写字母、小写字母和数字";
|
||||
if (detail.includes("password requires upper, lower and digit")) return "初始密码必须同时包含大写字母、小写字母和数字";
|
||||
if (detail.includes("invalid portal user")) return "登录账号需为 3–64 位字母、数字、点、下划线或连字符,显示名称不能为空";
|
||||
if (detail.includes("invalid user status")) return "账号状态不正确,只能为启用或停用";
|
||||
if (detail.includes("validFrom must be RFC3339")) return "生效日期格式不正确,请重新选择";
|
||||
if (detail.includes("invalid validFrom")) return "生效日期格式不正确";
|
||||
if (detail.includes("invalid validTo")) return "截止日期必须晚于生效日期";
|
||||
if (detail.includes("username already exists")) return "登录账号已存在,请更换账号";
|
||||
if (detail.includes("username is reserved")) return "该账号是平台管理员账号,不能创建为合作伙伴";
|
||||
if (detail.includes("invalid or duplicate app membership")) return "应用授权或角色配置不正确";
|
||||
if (detail.includes("VIN") || detail.includes("vin")) return "车辆 VIN 不存在、格式不正确或包含重复值";
|
||||
return error?.message || detail || `请求失败(${status})`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
request<{ accessToken: string; expiresAt: string; session: Session }>(
|
||||
"/portal-api/auth/login",
|
||||
{ method: "POST", body: JSON.stringify({ username, password }) },
|
||||
false
|
||||
),
|
||||
session: () => request<Session>("/portal-api/session"),
|
||||
catalog: () => request<Product[]>("/portal-api/catalog", {}, false),
|
||||
apps: () => request<PortalApp[]>("/portal-api/apps"),
|
||||
vehicles: (appId: number) => request<VehicleGrant[]>(`/portal-api/apps/${appId}/vehicles`),
|
||||
audit: (appId: number) => request<AuditItem[]>(`/portal-api/apps/${appId}/audit`),
|
||||
rotateKey: (appId: number) =>
|
||||
request<{ appKey: string }>(`/portal-api/apps/${appId}/rotate-key`, { method: "POST" }),
|
||||
changePassword: (currentPassword: string, newPassword: string) =>
|
||||
request<{ changed: boolean }>("/portal-api/account/password", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ currentPassword, newPassword })
|
||||
}),
|
||||
logout: () => request<{ loggedOut: boolean }>("/portal-api/auth/logout", { method: "POST" }),
|
||||
adminApps: () => request<AdminApp[]>("/portal-api/admin/apps"),
|
||||
adminVehicles: () => request<VehicleCatalogItem[]>("/portal-api/admin/vehicles"),
|
||||
createApp: (input: { name: string; status: string; validFrom: string; validTo: string }) =>
|
||||
request<AdminApp & { appKey: string }>("/portal-api/admin/apps", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
updateApp: (id: number, input: { name: string; status: string; validFrom: string; validTo: string }) =>
|
||||
request<AdminApp>(`/portal-api/admin/apps/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
replaceVehicles: (id: number, vehicles: Array<{ vin: string; validFrom: string; validTo: string }>) =>
|
||||
request<VehicleGrant[]>(`/portal-api/admin/apps/${id}/vehicles`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ vehicles })
|
||||
}),
|
||||
adminUsers: () => request<PortalUser[]>("/portal-api/admin/users"),
|
||||
createUser: (input: {
|
||||
username: string; displayName: string; password: string;
|
||||
status: string; validFrom: string; validTo: string;
|
||||
}) => request<PortalUser>("/portal-api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
updateUser: (id: number, input: {
|
||||
username: string; displayName: string; password: string;
|
||||
status: string; validFrom: string; validTo: string;
|
||||
}) => request<PortalUser>(`/portal-api/admin/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
userApps: (id: number) => request<PortalApp[]>(`/portal-api/admin/users/${id}/apps`),
|
||||
replaceUserApps: (id: number, apps: Array<{ appId: number; role: string }>) =>
|
||||
request<PortalApp[]>(`/portal-api/admin/users/${id}/apps`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ apps })
|
||||
})
|
||||
};
|
||||
75
vehicle-data-platform/apps/open-portal/src/catalog.ts
Normal file
75
vehicle-data-platform/apps/open-portal/src/catalog.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Product } from "./api";
|
||||
|
||||
export const products: Product[] = [
|
||||
{
|
||||
code: "daily_hydrogen",
|
||||
name: "车辆日用氢量",
|
||||
description: "按自然日查询全部授权车辆或指定车牌的用氢量,并返回数据质量状态。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/hydrogen-consumption/query",
|
||||
unit: "kg"
|
||||
},
|
||||
{
|
||||
code: "daily_mileage",
|
||||
name: "车辆日里程",
|
||||
description: "按自然日查询当日里程、累计总里程及实际来源协议,支持自定义协议优先级。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/mileage/query",
|
||||
unit: "km"
|
||||
},
|
||||
{
|
||||
code: "mileage_range",
|
||||
name: "车辆区间日里程",
|
||||
description: "按最长 366 天区间分页查询逐日里程,支持逐车逐日自定义协议优先级。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/mileage/range/query",
|
||||
unit: "km"
|
||||
},
|
||||
{
|
||||
code: "total_mileage_at_time",
|
||||
name: "指定时刻总里程",
|
||||
description: "按 VIN 和北京时间查询最近一条总里程、实际采集协议、记录时间及时间差秒数。",
|
||||
version: "v1",
|
||||
status: "available",
|
||||
method: "POST",
|
||||
path: "/api/v1/vehicles/total-mileage/query",
|
||||
unit: "km"
|
||||
}
|
||||
];
|
||||
|
||||
export const curlExample = (product: Product) => {
|
||||
if (product.code === "total_mileage_at_time") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"time": "2026-07-21 09:30:00",
|
||||
"protocol": "GB32960"
|
||||
}'`;
|
||||
if (product.code === "mileage_range") return `curl --request POST \\
|
||||
--url https://open.d.lnoneos.com${product.path} \\
|
||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data '{
|
||||
"startDate": "2026-07-01",
|
||||
"endDate": "2026-07-23",
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"],
|
||||
"pageSize": 5000
|
||||
}'`;
|
||||
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": ["沪A12345"],
|
||||
"date": "2026-07-19"${product.code === "daily_mileage" ? `,
|
||||
"protocolPriority": ["GB32960", "MQTT", "JT808"]` : ""}
|
||||
}'`;
|
||||
};
|
||||
10
vehicle-data-platform/apps/open-portal/src/main.tsx
Normal file
10
vehicle-data-platform/apps/open-portal/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
1016
vehicle-data-platform/apps/open-portal/src/styles.css
Normal file
1016
vehicle-data-platform/apps/open-portal/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1
vehicle-data-platform/apps/open-portal/src/test/setup.ts
Normal file
1
vehicle-data-platform/apps/open-portal/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
92
vehicle-data-platform/apps/open-portal/src/ui.tsx
Normal file
92
vehicle-data-platform/apps/open-portal/src/ui.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type Route = "home" | "docs" | "console";
|
||||
const companyLogo = new URL("../../web/public/brand-logo.svg", import.meta.url).href;
|
||||
|
||||
export function navigate(route: Route) {
|
||||
window.location.hash = route === "home" ? "/" : `/${route}`;
|
||||
}
|
||||
|
||||
export function currentRoute(): Route {
|
||||
const path = window.location.hash.replace(/^#/, "");
|
||||
if (path.startsWith("/docs")) return "docs";
|
||||
if (path.startsWith("/console")) return "console";
|
||||
return "home";
|
||||
}
|
||||
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<button className={`brand${compact ? " compact" : ""}`} onClick={() => navigate("home")} aria-label="返回开放平台首页">
|
||||
<img src={companyLogo} alt="羚牛智能" />
|
||||
<span className="brand-divider" aria-hidden="true" />
|
||||
<span className="brand-product">开放平台</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export type IconName =
|
||||
| "home" | "apps" | "users" | "car" | "shield" | "book" | "key"
|
||||
| "terminal" | "activity" | "settings" | "plus" | "search" | "close"
|
||||
| "check" | "clock" | "copy" | "logout" | "arrow" | "menu";
|
||||
|
||||
const paths: Record<IconName, ReactNode> = {
|
||||
home: <><path d="m3 10 9-7 9 7" /><path d="M5 9v11h14V9M9 20v-6h6v6" /></>,
|
||||
apps: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
|
||||
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8" /></>,
|
||||
car: <><path d="m5 17-1 2v2M19 17l1 2v2" /><path d="M3 17h18v-5l-2-5H5l-2 5v5Z" /><circle cx="7" cy="14" r="1" /><circle cx="17" cy="14" r="1" /></>,
|
||||
shield: <><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" /><path d="m9 12 2 2 4-4" /></>,
|
||||
book: <><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z" /></>,
|
||||
key: <><circle cx="8" cy="15" r="5" /><path d="m12 11 9-9M18 5l3 3M15 8l3 3" /></>,
|
||||
terminal: <><path d="m4 17 6-5-6-5M12 19h8" /></>,
|
||||
activity: <path d="M3 12h4l3-8 4 16 3-8h4" />,
|
||||
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 0-.4 1.1V21h-4v-.09A1.7 1.7 0 0 0 8.6 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 0-1.1-.4H3v-4h.09A1.7 1.7 0 0 0 4.6 8.6a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 0 .4-1.1V3h4v.09A1.7 1.7 0 0 0 15.4 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.4 9c.1.37.3.72.6 1 .3.28.68.42 1.1.4H21v4h-.09A1.7 1.7 0 0 0 19.4 15Z" /></>,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
search: <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>,
|
||||
close: <path d="m6 6 12 12M18 6 6 18" />,
|
||||
check: <path d="m5 12 4 4L19 6" />,
|
||||
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
|
||||
copy: <><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></>,
|
||||
logout: <><path d="M10 17l5-5-5-5M15 12H3" /><path d="M14 3h7v18h-7" /></>,
|
||||
arrow: <path d="m9 18 6-6-6-6" />,
|
||||
menu: <path d="M4 7h16M4 12h16M4 17h16" />
|
||||
};
|
||||
|
||||
export function Icon({ name, size = 20 }: { name: IconName; size?: number }) {
|
||||
return <svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
|
||||
}
|
||||
|
||||
export function Status({ value, label }: { value: string; label?: string }) {
|
||||
const normalized = value === "enabled" || value === "success" || value === "available" ? "enabled" : value === "disabled" || value === "failed" ? "disabled" : value;
|
||||
const text = label || (value === "enabled" ? "启用" : value === "disabled" ? "已停用" : value === "success" ? "成功" : value);
|
||||
return <span className={`status ${normalized}`}><i />{text}</span>;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
|
||||
return <header className="workspace-header"><div><h1>{title}</h1>{description && <p>{description}</p>}</div>{action}</header>;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, body, action }: { title: string; body: string; action?: ReactNode }) {
|
||||
return <div className="empty-state"><span><Icon name="apps" size={25} /></span><h2>{title}</h2><p>{body}</p>{action}</div>;
|
||||
}
|
||||
|
||||
export function dateInput(value?: string) {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
export function rfcDate(value: string) {
|
||||
return `${value}T00:00:00+08:00`;
|
||||
}
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
if (!value) return "长期有效";
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function formatTime(value?: string) {
|
||||
if (!value) return "从未登录";
|
||||
return new Intl.DateTimeFormat("zh-CN", { dateStyle: "short", timeStyle: "short" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function errorMessage(reason: unknown) {
|
||||
return reason instanceof Error ? reason.message : "操作失败,请稍后重试";
|
||||
}
|
||||
20
vehicle-data-platform/apps/open-portal/tsconfig.json
Normal file
20
vehicle-data-platform/apps/open-portal/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
20
vehicle-data-platform/apps/open-portal/vite.config.ts
Normal file
20
vehicle-data-platform/apps/open-portal/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig, searchForWorkspaceRoot } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
fs: {
|
||||
allow: [
|
||||
searchForWorkspaceRoot(process.cwd()),
|
||||
fileURLToPath(new URL("../web/public", import.meta.url))
|
||||
]
|
||||
},
|
||||
proxy: {
|
||||
"/portal-api": "http://127.0.0.1:20310",
|
||||
"/api/v1": "http://127.0.0.1:20310",
|
||||
"/open-api": "http://127.0.0.1:20310"
|
||||
}
|
||||
}
|
||||
});
|
||||
11
vehicle-data-platform/apps/open-portal/vitest.config.ts
Normal file
11
vehicle-data-platform/apps/open-portal/vitest.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/test/setup.ts"
|
||||
}
|
||||
});
|
||||
13
vehicle-data-platform/apps/web/platform-design-lab.html
Normal file
13
vehicle-data-platform/apps/web/platform-design-lab.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f5f5f7" />
|
||||
<title>车辆数据中台 · 多页面设计实验室</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="platform-design-lab-root"></div>
|
||||
<script type="module" src="/src/design-lab/platform-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg viewBox="5 4 140 28" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="brand-title">
|
||||
<title id="brand-title">灵牛智能</title>
|
||||
<title id="brand-title">羚牛智能</title>
|
||||
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
|
||||
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
|
||||
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -40,6 +40,93 @@ test('history export downloads use bearer authentication and retain the server f
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer customer-export-token');
|
||||
});
|
||||
|
||||
test('reconciliation directory and current-filter export use dedicated scoped contracts', async () => {
|
||||
const payload = new Blob(['quality'], { type: 'text/csv' });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [] }) } as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'X-Export-Name': encodeURIComponent('质量差异_20260723.csv') }),
|
||||
blob: async () => payload
|
||||
} as Response);
|
||||
const controller = new AbortController();
|
||||
|
||||
await api.reconciliationAssignees('定位 运维', controller.signal);
|
||||
const exported = await api.downloadReconciliationIssues({ status: 'active', owner: '定位运维组' }, controller.signal);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/reconciliation/assignees?search=%E5%AE%9A%E4%BD%8D+%E8%BF%90%E7%BB%B4', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/reconciliation/issues/export', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'active', owner: '定位运维组' }),
|
||||
signal: expect.any(AbortSignal)
|
||||
}));
|
||||
expect(exported.filename).toBe('质量差异_20260723.csv');
|
||||
expect(exported.blob.size).toBe(7);
|
||||
});
|
||||
|
||||
test('history export lifecycle actions use explicit scoped endpoints', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { succeeded: [], skipped: [] } }) } as Response);
|
||||
|
||||
await api.rebuildHistoryExport('expired task');
|
||||
await api.archiveHistoryExport('completed task');
|
||||
await api.restoreHistoryExport('completed task');
|
||||
await api.batchHistoryExports({ ids: ['completed task', 'failed-task'], action: 'archive' });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/exports/expired%20task/rebuild', boundedRequest({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/exports/completed%20task/archive', boundedRequest({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/exports/completed%20task/restore', boundedRequest({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/v2/exports/batch', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ['completed task', 'failed-task'], action: 'archive' })
|
||||
}));
|
||||
});
|
||||
|
||||
test('history cleanup automation uses versioned policy and approval endpoints', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { policy: {}, runs: [] } }) } as Response);
|
||||
|
||||
await api.historyExportCleanupAutomation();
|
||||
await api.updateHistoryExportCleanupAutomation({ enabled: true, olderThanDays: 180, intervalDays: 7, approvalWindowHours: 48, expectedRevision: 3 });
|
||||
await api.approveHistoryExportCleanupAutomation('cleanup run', { expectedRevision: 4, reason: '已核对影响范围' });
|
||||
await api.rejectHistoryExportCleanupAutomation('cleanup run', { expectedRevision: 5, reason: '需要延后复核' });
|
||||
await api.retryHistoryExportCleanupAutomation('cleanup run', { expectedRevision: 6, reason: '故障已修复,恢复执行' });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/exports/cleanup/automation', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/exports/cleanup/automation', boundedRequest({
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, olderThanDays: 180, intervalDays: 7, approvalWindowHours: 48, expectedRevision: 3 })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/exports/cleanup/automation/cleanup%20run/approve', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: 4, reason: '已核对影响范围' })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/v2/exports/cleanup/automation/cleanup%20run/reject', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: 5, reason: '需要延后复核' })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(5, '/api/v2/exports/cleanup/automation/cleanup%20run/retry', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: 6, reason: '故障已修复,恢复执行' })
|
||||
}));
|
||||
});
|
||||
|
||||
test('history export pagination and account preferences use durable v2 contracts', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams({ search: 'VIN 001', status: 'failed', limit: '20', offset: '20' });
|
||||
|
||||
await api.historyExportPage(params, controller.signal);
|
||||
await api.historyPreferences(controller.signal);
|
||||
await api.updateHistoryPreferences({
|
||||
retentionDays: 30,
|
||||
fieldViews: [{ id: 'location:交付核验', name: '交付核验', category: 'location', keys: ['speedKmh'] }]
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/exports/page?search=VIN+001&status=failed&limit=20&offset=20', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/history/preferences', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/history/preferences', boundedRequest({
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
retentionDays: 30,
|
||||
fieldViews: [{ id: 'location:交付核验', name: '交付核验', category: 'location', keys: ['speedKmh'] }]
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
test('a protected 401 terminates the client session but login validation errors stay local', async () => {
|
||||
window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token');
|
||||
const unauthorized = vi.fn();
|
||||
@@ -124,6 +211,11 @@ test('access APIs post one shared filter contract and version threshold updates'
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unresolved)
|
||||
}));
|
||||
|
||||
await api.claimAccessIdentity('identity 1', { vin: 'LNXNEGRR7SR318212', note: '来源证据核对通过' });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities/identity%201/claim', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vin: 'LNXNEGRR7SR318212', note: '来源证据核对通过' })
|
||||
}));
|
||||
|
||||
await api.updateAccessThresholds({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', boundedRequest({
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] })
|
||||
@@ -147,13 +239,14 @@ test('latest telemetry uses the encoded vehicle identity path', async () => {
|
||||
|
||||
test('vehicle profile sync posts an explicit dry-run and conflict policy contract', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile-sync', timestamp: 1 }) } as Response);
|
||||
const controller = new AbortController();
|
||||
const input = {
|
||||
sourceSystem: 'oem-tsp', sourceVersion: 'snapshot-1', conflictPolicy: 'preserve' as const, dryRun: true,
|
||||
items: [{ vin: 'VIN001', modelName: '车型一', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '', runtimeSeconds: null }]
|
||||
};
|
||||
await api.syncVehicleProfiles(input);
|
||||
await api.syncVehicleProfiles(input, controller.signal);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: controller.signal
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -188,10 +281,17 @@ test('high-volume history, track, and mileage requests forward AbortSignal', asy
|
||||
await api.trackPlayback(params, controller.signal);
|
||||
await api.historyData(params, controller.signal);
|
||||
await api.historySeries(params, controller.signal);
|
||||
await api.dailyMileage(params, controller.signal);
|
||||
await api.mileageStatistics(params, controller.signal);
|
||||
const mileageQuery = { dateFrom: '2026-07-01', dateTo: '2026-07-31', vins: ['VIN001'], protocols: ['GB32960'] };
|
||||
await api.dailyMileage(mileageQuery, controller.signal);
|
||||
await api.mileageStatistics(mileageQuery, controller.signal);
|
||||
|
||||
for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/mileage/daily', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mileageQuery)
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(5, '/api/v2/statistics/mileage', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mileageQuery)
|
||||
}));
|
||||
});
|
||||
|
||||
test('route-scoped platform reads forward AbortSignal on navigation cleanup', async () => {
|
||||
@@ -211,6 +311,10 @@ test('route-scoped platform reads forward AbortSignal on navigation cleanup', as
|
||||
await api.alertEventsV2({ limit: 20, offset: 0 }, signal);
|
||||
await api.alertEventV2('event-1', signal);
|
||||
await api.alertRulesV2(signal);
|
||||
await api.alertRuleLibraryV2(new URLSearchParams({ lifecycle: 'current', limit: '10', offset: '0' }), signal);
|
||||
await api.alertRuleRevisionsV2('rule 1', signal);
|
||||
await api.alertNotificationConfigV2(signal);
|
||||
await api.alertNotificationDeliveryHealthV2(signal);
|
||||
await api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal);
|
||||
await api.vehicleDetail(new URLSearchParams({ keyword: 'VIN001' }), signal);
|
||||
await api.latestTelemetry('VIN001', signal);
|
||||
@@ -218,10 +322,37 @@ test('route-scoped platform reads forward AbortSignal on navigation cleanup', as
|
||||
await api.opsHealth(signal);
|
||||
await api.sourceReadiness(signal);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(18);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(22);
|
||||
for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
test('automation revision reads and rollbacks use encoded durable routes', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
await api.alertRuleRevisionsV2('rule / 1');
|
||||
await api.rollbackAlertRuleV2('rule / 1', { targetVersion: 2, currentVersion: 7 });
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/alerts/rules/rule%20%2F%201/revisions', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/alerts/rules/rule%20%2F%201/rollback', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetVersion: 2, currentVersion: 7 })
|
||||
}));
|
||||
});
|
||||
|
||||
test('automation governance uses server-side library and reversible lifecycle routes', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
const query = new URLSearchParams({ lifecycle: 'archived', keyword: '旧规则', status: 'disabled', protocol: 'GB32960', limit: '20', offset: '20' });
|
||||
|
||||
await api.alertRuleLibraryV2(query);
|
||||
await api.archiveAlertRuleV2('rule / 1', { version: 7, reason: '旧规则已经由新版替代' });
|
||||
await api.restoreAlertRuleV2('rule / 1', { version: 8, reason: '恢复后重新复核车辆范围' });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/alerts/rules/library?lifecycle=archived&keyword=%E6%97%A7%E8%A7%84%E5%88%99&status=disabled&protocol=GB32960&limit=20&offset=20', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/alerts/rules/rule%20%2F%201/archive', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 7, reason: '旧规则已经由新版替代' })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/alerts/rules/rule%20%2F%201/restore', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 8, reason: '恢复后重新复核车辆范围' })
|
||||
}));
|
||||
});
|
||||
|
||||
test('turns a stalled route query into a bounded retryable timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
@@ -341,6 +472,25 @@ test('vehicleResolve sends keyword to the identity resolution endpoint', async (
|
||||
expect(result.vin).toBe('LB9A32A24R0LS1426');
|
||||
});
|
||||
|
||||
test('vehicleBusinessFilters reads permission-scoped filter options', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
departments: [{ value: '40001', label: '业务一部', count: 3 }],
|
||||
responsibleUsers: [], customers: [], statuses: []
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await api.vehicleBusinessFilters();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/business-filters', boundedRequest());
|
||||
expect(result.departments[0]).toEqual({ value: '40001', label: '业务一部', count: 3 });
|
||||
});
|
||||
|
||||
test('vehicleServiceOverview sends keyword to the lightweight overview endpoint', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -5,15 +5,23 @@ import type {
|
||||
AccessSummary,
|
||||
AccessThresholdConfig,
|
||||
AccessThresholdUpdate,
|
||||
AccessIdentityClaimInput,
|
||||
AccessIdentityClaimResult,
|
||||
AccessUnresolvedIdentity,
|
||||
AccessUnresolvedIdentityQuery,
|
||||
AccessVehicleRow,
|
||||
AlertAction,
|
||||
AlertEvent,
|
||||
AlertNotification,
|
||||
AlertNotificationConfig,
|
||||
AlertNotificationDeliveryHealth,
|
||||
AlertNotificationRetryAudit,
|
||||
AlertNotificationRetryResult,
|
||||
AlertQuery,
|
||||
AlertRule,
|
||||
AlertRulePage,
|
||||
AlertRuleInput,
|
||||
AlertRuleRevision,
|
||||
AlertSummary,
|
||||
DailyMileageRow,
|
||||
DashboardSummary,
|
||||
@@ -21,7 +29,16 @@ import type {
|
||||
HistoryDataResponse,
|
||||
HistorySeriesResponse,
|
||||
HistoryExportJob,
|
||||
HistoryExportBatchAction, HistoryExportBatchResult,
|
||||
HistoryExportCleanupPreview,
|
||||
HistoryExportCleanupRecord,
|
||||
HistoryExportCleanupResult,
|
||||
HistoryExportCleanupAutomationState,
|
||||
HistoryExportCleanupAutomationPolicyInput,
|
||||
HistoryExportCleanupAutomationActionInput,
|
||||
HistoryExportPage,
|
||||
HistoryExportRequest,
|
||||
HistoryPreferences,
|
||||
HistoryMetricCatalog,
|
||||
MetricCatalog,
|
||||
LatestTelemetryResponse,
|
||||
@@ -39,18 +56,29 @@ import type {
|
||||
QualitySummary,
|
||||
QualityIssueRow,
|
||||
ReconciliationIssue,
|
||||
ReconciliationAssignee,
|
||||
ReconciliationAssignmentRequest,
|
||||
ReconciliationBatchAssignmentRequest,
|
||||
ReconciliationBatchActionRequest,
|
||||
ReconciliationBatchActionResult,
|
||||
ReconciliationQuery,
|
||||
ReconciliationLifecycleRequest,
|
||||
ReconciliationBatchLifecycleRequest,
|
||||
ReconciliationSummary,
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
SourceReadinessPlan,
|
||||
SessionInfo,
|
||||
LoginResponse,
|
||||
OneOSExchangeResponse,
|
||||
CustomerUserInput,
|
||||
CustomerUserBatchItem,
|
||||
CustomerUserBatchResult,
|
||||
TrackPlaybackResponse,
|
||||
VehicleRealtimeRow,
|
||||
VehicleCoverageRow,
|
||||
VehicleCoverageSummary,
|
||||
VehicleBusinessFilters,
|
||||
VehicleDetail,
|
||||
VehicleProfile,
|
||||
VehicleProfileInput,
|
||||
@@ -85,6 +113,19 @@ export type VehicleOverviewBatchQuery = {
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type MileageQuery = {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
keyword?: string;
|
||||
vins?: string[];
|
||||
vehicleScope?: 'bound';
|
||||
protocol?: string;
|
||||
protocols?: string[];
|
||||
deduplicate?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
type ApiErrorEnvelope = {
|
||||
error?: {
|
||||
code?: string;
|
||||
@@ -146,16 +187,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBlob(path: string, signal?: AbortSignal) {
|
||||
async function requestBlob(path: string, signal?: AbortSignal, requestInit?: RequestInit, fallbackFilename = 'history-export.csv') {
|
||||
const token = getAccessToken();
|
||||
const init: RequestInit = token ? { signal, headers: withAuthorization(undefined, token) } : { signal };
|
||||
const init: RequestInit = token
|
||||
? { ...requestInit, signal, headers: withAuthorization(requestInit?.headers, token) }
|
||||
: { ...requestInit, signal };
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && token) notifyUnauthorizedSession(token);
|
||||
throw new Error(await responseErrorMessage(response));
|
||||
}
|
||||
const encodedFilename = response.headers.get('X-Export-Name')?.trim();
|
||||
let filename = 'history-export.csv';
|
||||
let filename = fallbackFilename;
|
||||
if (encodedFilename) {
|
||||
try {
|
||||
filename = decodeURIComponent(encodedFilename);
|
||||
@@ -221,6 +264,9 @@ export const api = {
|
||||
login: (credentials: { username: string; password: string }) => request<LoginResponse>('/api/v2/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials)
|
||||
}),
|
||||
exchangeOneOSTicket: (ticket: string) => request<OneOSExchangeResponse>('/api/v2/auth/oneos/exchange', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket })
|
||||
}),
|
||||
logout: () => request<{ loggedOut: boolean }>('/api/v2/auth/logout', { method: 'POST' }),
|
||||
changePassword: (input: { currentPassword: string; newPassword: string }) => request<{ changed: boolean }>('/api/v2/auth/password', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
@@ -229,6 +275,9 @@ export const api = {
|
||||
createCustomerUser: (input: CustomerUserInput) => request<{ id: number }>('/api/v2/admin/users', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
batchCustomerUsers: (mode: 'preview' | 'create', items: CustomerUserBatchItem[]) => request<CustomerUserBatchResult>('/api/v2/admin/users/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode, items })
|
||||
}),
|
||||
updateCustomerUser: (id: number, input: CustomerUserInput) => request<{ id: number }>(`/api/v2/admin/users/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
@@ -241,9 +290,42 @@ export const api = {
|
||||
historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined),
|
||||
historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined),
|
||||
historyExports: (signal?: AbortSignal) => request<HistoryExportJob[]>('/api/v2/exports', withSignal(undefined, signal)),
|
||||
historyExportPage: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryExportPage>(`/api/v2/exports/page?${params.toString()}`, withSignal(undefined, signal)),
|
||||
historyExportCleanupPreview: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryExportCleanupPreview>(`/api/v2/exports/cleanup/preview?${params.toString()}`, withSignal(undefined, signal)),
|
||||
historyExportCleanupAudit: (signal?: AbortSignal) => request<HistoryExportCleanupRecord[]>('/api/v2/exports/cleanup/audit?limit=20', withSignal(undefined, signal)),
|
||||
cleanupHistoryExports: (input: { olderThanDays: number; ownerScope: 'all' | 'mine'; previewToken: string }) => request<HistoryExportCleanupResult>('/api/v2/exports/cleanup', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
historyExportCleanupAutomation: (signal?: AbortSignal) => request<HistoryExportCleanupAutomationState>('/api/v2/exports/cleanup/automation', withSignal(undefined, signal)),
|
||||
updateHistoryExportCleanupAutomation: (input: HistoryExportCleanupAutomationPolicyInput) => request<HistoryExportCleanupAutomationState>('/api/v2/exports/cleanup/automation', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
approveHistoryExportCleanupAutomation: (id: string, input: HistoryExportCleanupAutomationActionInput) => request<HistoryExportCleanupAutomationState>(`/api/v2/exports/cleanup/automation/${encodeURIComponent(id)}/approve`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
rejectHistoryExportCleanupAutomation: (id: string, input: HistoryExportCleanupAutomationActionInput) => request<HistoryExportCleanupAutomationState>(`/api/v2/exports/cleanup/automation/${encodeURIComponent(id)}/reject`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
retryHistoryExportCleanupAutomation: (id: string, input: HistoryExportCleanupAutomationActionInput) => request<HistoryExportCleanupAutomationState>(`/api/v2/exports/cleanup/automation/${encodeURIComponent(id)}/retry`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
updateHistoryExportCleanupProtection: (id: string, input: { protected: boolean; reason: string }) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/cleanup-protection`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
historyPreferences: (signal?: AbortSignal) => request<HistoryPreferences>('/api/v2/history/preferences', withSignal(undefined, signal)),
|
||||
updateHistoryPreferences: (input: Pick<HistoryPreferences, 'retentionDays' | 'fieldViews'>) => request<HistoryPreferences>('/api/v2/history/preferences', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
cancelHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/cancel`, { method: 'POST' }),
|
||||
rebuildHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/rebuild`, { method: 'POST' }),
|
||||
archiveHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
|
||||
restoreHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
|
||||
batchHistoryExports: (input: { ids: string[]; action: HistoryExportBatchAction }) => request<HistoryExportBatchResult>('/api/v2/exports/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
downloadHistoryExport: (id: string, signal?: AbortSignal) => requestBlob(`/api/v2/exports/${encodeURIComponent(id)}/download`, signal),
|
||||
accessSummary: (query: AccessQuery, signal?: AbortSignal) => request<AccessSummary>('/api/v2/access/summary', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
@@ -254,6 +336,9 @@ export const api = {
|
||||
accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery, signal?: AbortSignal) => request<Page<AccessUnresolvedIdentity>>('/api/v2/access/unresolved-identities', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
claimAccessIdentity: (id: string, input: AccessIdentityClaimInput) => request<AccessIdentityClaimResult>(`/api/v2/access/unresolved-identities/${encodeURIComponent(id)}/claim`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
accessThresholds: (signal?: AbortSignal) => request<AccessThresholdConfig>('/api/v2/access/thresholds', withSignal(undefined, signal)),
|
||||
updateAccessThresholds: (update: AccessThresholdUpdate) => request<AccessThresholdConfig>('/api/v2/access/thresholds', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
@@ -269,21 +354,39 @@ export const api = {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action)
|
||||
}),
|
||||
alertRulesV2: (signal?: AbortSignal) => request<AlertRule[]>('/api/v2/alerts/rules', withSignal(undefined, signal)),
|
||||
alertRuleLibraryV2: (params = new URLSearchParams(), signal?: AbortSignal) => request<AlertRulePage>(`/api/v2/alerts/rules/library?${params.toString()}`, withSignal(undefined, signal)),
|
||||
alertRuleRevisionsV2: (id: string, signal?: AbortSignal) => request<AlertRuleRevision[]>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/revisions`, withSignal(undefined, signal)),
|
||||
saveAlertRuleV2: (input: AlertRuleInput) => request<AlertRule>(input.version > 0 ? `/api/v2/alerts/rules/${encodeURIComponent(input.id)}` : '/api/v2/alerts/rules', {
|
||||
method: input.version > 0 ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
setAlertRuleEnabledV2: (id: string, update: { version: number; enabled: boolean; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/enabled`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
}),
|
||||
rollbackAlertRuleV2: (id: string, input: { targetVersion: number; currentVersion: number; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/rollback`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
archiveAlertRuleV2: (id: string, input: { version: number; reason: string; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/archive`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
restoreAlertRuleV2: (id: string, input: { version: number; reason: string; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/restore`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
alertNotificationConfigV2: (signal?: AbortSignal) => request<AlertNotificationConfig>('/api/v2/alerts/notification-config', withSignal(undefined, signal)),
|
||||
alertNotificationDeliveryHealthV2: (signal?: AbortSignal) => request<AlertNotificationDeliveryHealth>('/api/v2/alerts/notifications/health', withSignal(undefined, signal)),
|
||||
alertNotificationsV2: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<AlertNotification>>(`/api/v2/alerts/notifications?${params.toString()}`, withSignal(undefined, signal)),
|
||||
readAlertNotificationsV2: (ids: number[]) => request<{ updated: number }>('/api/v2/alerts/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
||||
}),
|
||||
retryAlertNotificationV2: (id: number, input: { expectedAttemptCount: number; reason: string; idempotencyKey: string }) => request<AlertNotificationRetryResult>(`/api/v2/alerts/notifications/${encodeURIComponent(id)}/retry`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
alertNotificationRetryAuditsV2: (id: number, signal?: AbortSignal) => request<AlertNotificationRetryAudit[]>(`/api/v2/alerts/notifications/${encodeURIComponent(id)}/retry-audit`, withSignal(undefined, signal)),
|
||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||
vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined),
|
||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||
vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined),
|
||||
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
||||
vehicleBusinessFilters: (signal?: AbortSignal) => request<VehicleBusinessFilters>('/api/vehicles/business-filters', withSignal(undefined, signal)),
|
||||
vehicleDetail: (params = new URLSearchParams(), signal?: AbortSignal) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`, withSignal(undefined, signal)),
|
||||
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
|
||||
latestTelemetry: (vin: string, signal?: AbortSignal) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`, withSignal(undefined, signal)),
|
||||
@@ -320,6 +423,16 @@ export const api = {
|
||||
'/api/v2/reconciliation/issues',
|
||||
withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)
|
||||
),
|
||||
reconciliationAssignees: (search = '', signal?: AbortSignal) => request<ReconciliationAssignee[]>(
|
||||
`/api/v2/reconciliation/assignees?${new URLSearchParams(search ? { search } : {}).toString()}`,
|
||||
withSignal(undefined, signal)
|
||||
),
|
||||
downloadReconciliationIssues: (query: ReconciliationQuery, signal?: AbortSignal) => requestBlob(
|
||||
'/api/v2/reconciliation/issues/export',
|
||||
signal,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) },
|
||||
'quality-issues.csv'
|
||||
),
|
||||
reconciliationIssue: (id: string, signal?: AbortSignal) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}`,
|
||||
withSignal(undefined, signal)
|
||||
@@ -328,12 +441,32 @@ export const api = {
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/actions`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
batchUpdateReconciliationIssues: (input: ReconciliationBatchActionRequest) => request<ReconciliationBatchActionResult>(
|
||||
'/api/v2/reconciliation/issues/batch-actions',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
assignReconciliationIssue: (id: string, input: ReconciliationAssignmentRequest) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/assignment`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
batchAssignReconciliationIssues: (input: ReconciliationBatchAssignmentRequest) => request<ReconciliationBatchActionResult>(
|
||||
'/api/v2/reconciliation/issues/batch-assignments',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
setReconciliationIssueArchived: (id: string, archived: boolean, input: ReconciliationLifecycleRequest) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/${archived ? 'archive' : 'restore'}`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
batchSetReconciliationIssuesArchived: (archived: boolean, input: ReconciliationBatchLifecycleRequest) => request<ReconciliationBatchActionResult>(
|
||||
`/api/v2/reconciliation/issues/${archived ? 'batch-archive' : 'batch-restore'}`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
syncVehicleProfiles: (input: VehicleProfileSyncRequest) => request<VehicleProfileSyncResult>('/api/v2/vehicle-profiles/sync', {
|
||||
syncVehicleProfiles: (input: VehicleProfileSyncRequest, signal?: AbortSignal) => request<VehicleProfileSyncResult>('/api/v2/vehicle-profiles/sync', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
}, signal)),
|
||||
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {
|
||||
@@ -351,8 +484,12 @@ export const api = {
|
||||
body: JSON.stringify(query)
|
||||
}),
|
||||
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
||||
dailyMileage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`, signal ? { signal } : undefined),
|
||||
mileageStatistics: (params = new URLSearchParams(), signal?: AbortSignal) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`, signal ? { signal } : undefined),
|
||||
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request<MileageStatistics>('/api/v2/statistics/mileage', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
||||
onlineVehicleStatuses: (params = new URLSearchParams()) => request<Page<OnlineVehicleStatusRow>>(`/api/statistics/online-vehicles?${params.toString()}`),
|
||||
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface SessionInfo {
|
||||
vehicleCount?: number;
|
||||
customerRef?: string;
|
||||
tenantRef?: string;
|
||||
businessScopeLevel?: 'department' | 'responsible';
|
||||
departmentIds?: string[];
|
||||
responsibleUserId?: string;
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
|
||||
@@ -24,6 +27,10 @@ export interface LoginResponse {
|
||||
session: Omit<SessionInfo, 'authMode'>;
|
||||
}
|
||||
|
||||
export interface OneOSExchangeResponse extends LoginResponse {
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -77,6 +84,27 @@ export interface CustomerUserInput {
|
||||
vehicleGrants: CustomerVehicleGrantInput[];
|
||||
}
|
||||
|
||||
export interface CustomerUserBatchItem {
|
||||
row: number;
|
||||
input: CustomerUserInput & { username: string; password: string };
|
||||
}
|
||||
|
||||
export interface CustomerUserBatchResultItem {
|
||||
row: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: 'ready' | 'created' | 'invalid' | 'conflict' | 'failed';
|
||||
code?: string;
|
||||
message: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export interface CustomerUserBatchResult {
|
||||
mode: 'preview' | 'create';
|
||||
summary: { received: number; ready: number; created: number; failed: number };
|
||||
items: CustomerUserBatchResultItem[];
|
||||
}
|
||||
|
||||
export interface MonitorSummary {
|
||||
totalVehicles: number;
|
||||
locationVehicles?: number;
|
||||
@@ -271,15 +299,33 @@ export interface MetricDefinition {
|
||||
export interface HistoryDataCategory { key: 'location' | 'raw' | 'mileage' | string; label: string; }
|
||||
export interface HistoryMetricDefinition { key: string; label: string; description?: string; unit: string; category: string; valueType: string; valueMappings?: MetricValueMapping[]; defaultVisible: boolean; }
|
||||
export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; qualityReason: string; evidenceId?: string; values: Record<string, unknown>; }
|
||||
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; sources: string[]; queryDurationMs: number; }
|
||||
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
|
||||
export interface HistoryQueryVehicleResult { keyword: string; vin?: string; plate?: string; rowCount: number; status: 'matched' | 'no_data'; }
|
||||
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; requestedVehicleCount?: number; vehicles?: HistoryQueryVehicleResult[]; sources: string[]; queryDurationMs: number; }
|
||||
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; segments?: LatestTelemetryCategory[]; segmentCountsExact?: boolean; allTotal?: number; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
|
||||
export interface HistorySeriesPoint { time: string; value: number | null; min: number | null; max: number | null; count: number; }
|
||||
export interface HistorySeries { vin: string; plate: string; protocol: string; metric: string; label: string; unit: string; aggregation: 'avg' | 'last' | string; points: HistorySeriesPoint[]; }
|
||||
export interface HistorySeriesSummary { rawPointCount: number; bucketCount: number; returnedPointCount: number; seriesCount: number; grainSeconds: number; targetPoints: number; expectedBucketCount: number; missingBucketCount: number; queryDurationMs: number; complete: boolean; evidence: string; }
|
||||
export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; }
|
||||
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
|
||||
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; retentionDays?: number; }
|
||||
export type HistoryExportStatus = 'queued' | 'running' | 'completed' | 'failed' | 'expired' | 'cancelled';
|
||||
export interface HistoryExportJob { id: string; name: string; status: HistoryExportStatus; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; expiresAt?: string; expiredAt?: string; rebuiltFrom?: string; cancelledAt?: string; cancelledBy?: string; archivedAt?: string; archivedBy?: string; cleanupProtectedAt?: string; cleanupProtectedBy?: string; cleanupProtectionReason?: string; retentionDays?: number; evidence: string; }
|
||||
export type HistoryExportBatchAction = 'cancel' | 'rebuild' | 'archive' | 'restore';
|
||||
export interface HistoryExportBatchResult { action: HistoryExportBatchAction; requested: number; succeeded: HistoryExportJob[]; skipped: { id: string; code: string; message: string }[]; }
|
||||
export interface HistoryExportSummary { total: number; active: number; completed: number; expiring: number; recoverable: number; cancelled: number; current: number; archived: number; }
|
||||
export interface HistoryExportPage { items: HistoryExportJob[]; total: number; limit: number; offset: number; summary: HistoryExportSummary; }
|
||||
export interface HistoryExportCleanupCandidate { id: string; name: string; ownerUsername: string; status: HistoryExportStatus; archivedAt: string; fileSizeBytes: number; protectedAt?: string; protectedBy?: string; protectionReason?: string; }
|
||||
export interface HistoryExportCleanupPreview { olderThanDays: number; ownerScope: 'all' | 'mine'; cutoff: string; generatedAt: string; archivedCount: number; candidateCount: number; plannedCount: number; protectedCount: number; fileCount: number; fileSizeBytes: number; candidates: HistoryExportCleanupCandidate[]; protected: HistoryExportCleanupCandidate[]; previewToken: string; }
|
||||
export interface HistoryExportCleanupRecord { id: string; actor: string; ownerScope: 'all' | 'mine'; olderThanDays: number; cutoff: string; requested: number; cleaned: number; protected: number; fileCount: number; fileSizeBytes: number; candidateDigest: string; executedAt: string; }
|
||||
export interface HistoryExportCleanupResult { record: HistoryExportCleanupRecord; deleted: HistoryExportCleanupCandidate[]; }
|
||||
export type HistoryExportCleanupAutomationRunStatus = 'awaiting_approval' | 'needs_review' | 'approved' | 'running' | 'completed' | 'failed' | 'rejected' | 'expired' | 'no_candidates';
|
||||
export interface HistoryExportCleanupAutomationPolicy { enabled: boolean; olderThanDays: 30 | 90 | 180 | 365; intervalDays: 7 | 14 | 30; approvalWindowHours: 24 | 48 | 72; nextReviewAt?: string; revision: number; updatedAt?: string; updatedBy?: string; }
|
||||
export interface HistoryExportCleanupAutomationRun { id: string; status: HistoryExportCleanupAutomationRunStatus; revision: number; policyRevision: number; olderThanDays: number; cutoff: string; candidateCount: number; plannedCount: number; protectedCount: number; fileCount: number; fileSizeBytes: number; previewToken: string; createdAt: string; approvalDeadline?: string; approvedAt?: string; approvedBy?: string; approvalReason?: string; rejectedAt?: string; rejectedBy?: string; rejectionReason?: string; startedAt?: string; completedAt?: string; leaseOwner?: string; leaseExpiresAt?: string; attemptCount: number; maxAttempts: number; nextRetryAt?: string; error?: string; cleanupRecordId?: string; cleanedCount?: number; executionWarning?: string; lastActionAt?: string; lastActionBy?: string; lastActionReason?: string; }
|
||||
export interface HistoryExportCleanupAutomationState { policy: HistoryExportCleanupAutomationPolicy; runs: HistoryExportCleanupAutomationRun[]; serverTime: string; }
|
||||
export interface HistoryExportCleanupAutomationPolicyInput { enabled: boolean; olderThanDays: number; intervalDays: number; approvalWindowHours: number; expectedRevision: number; }
|
||||
export interface HistoryExportCleanupAutomationActionInput { expectedRevision: number; reason: string; }
|
||||
export interface HistoryFieldView { id: string; name: string; category: string; keys: string[]; }
|
||||
export interface HistoryPreferences { revision: number; retentionDays: number; fieldViews: HistoryFieldView[]; updatedAt?: string; }
|
||||
|
||||
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }
|
||||
@@ -288,6 +334,11 @@ export interface AccessUnresolvedIdentity {
|
||||
firstRegisteredAt: string; latestRegisteredAt: string; latestAuthenticatedAt: string; latestSeenAt: string;
|
||||
freshnessSec: number; issueCode: string; recommendedAction: string;
|
||||
}
|
||||
export interface AccessIdentityClaimInput { vin: string; note?: string; actor?: string; }
|
||||
export interface AccessIdentityClaimResult {
|
||||
identityId: string; protocol: string; identifierMasked: string; vin: string; plate: string;
|
||||
profileComplete: boolean; profileMissingFields: string[]; claimedBy: string; claimedAt: string; auditId: number;
|
||||
}
|
||||
export interface AccessVehicleRow {
|
||||
vin: string; plate: string; oem: string; model: string; company: string; protocol: string; provider: string; source: string;
|
||||
firstSeenAt: string; latestEventAt: string; latestReceivedAt: string; reportIntervalSec: number | null;
|
||||
@@ -325,23 +376,52 @@ export interface AccessThresholdUpdate {
|
||||
|
||||
export type AlertSeverity = 'critical' | 'major' | 'minor';
|
||||
export type AlertStatus = 'unprocessed' | 'processing' | 'recovered' | 'closed' | 'ignored';
|
||||
export type AlertTriggerType = 'metric' | 'geofence' | 'stationary' | 'offline';
|
||||
export interface AlertQuery { keyword?: string; severity?: string; status?: string; ruleId?: string; protocol?: string; dateFrom?: string; dateTo?: string; limit?: number; offset?: number; }
|
||||
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
|
||||
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
|
||||
export interface AlertEvent {
|
||||
id: string; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; status: AlertStatus;
|
||||
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
|
||||
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
|
||||
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
|
||||
eventAt: string; receivedAt: string; triggeredAt: string; recoveredAt: string; handler: string; version: number; actions?: AlertAction[];
|
||||
}
|
||||
export interface AlertRule {
|
||||
id: string; name: string; description: string; severity: AlertSeverity; valueType: 'numeric' | 'boolean'; metric: string;
|
||||
id: string; name: string; description: string; triggerType?: AlertTriggerType; fenceName?: string; fenceLongitude?: number; fenceLatitude?: number; fenceRadiusM?: number;
|
||||
severity: AlertSeverity; valueType: 'numeric' | 'boolean'; metric: string;
|
||||
operator: string; threshold: number; thresholdHigh: number; booleanThreshold?: boolean; durationSec: number; recoveryOperator: string;
|
||||
recoveryThreshold: number; repeatIntervalSec: number; scopeProtocols: string[]; scopeVins: string[]; scopeOems: string[]; scopeModels: string[]; scopeCompanies: string[];
|
||||
notificationChannels: string[]; enabled: boolean; version: number; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string;
|
||||
notificationChannels: string[]; notificationTargets?: AlertNotificationTarget[]; enabled: boolean; version: number; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string;
|
||||
archivedBy?: string; archivedAt?: string; archiveReason?: string;
|
||||
}
|
||||
export interface AlertRuleInput extends Omit<AlertRule, 'notificationTargets' | 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt'> { notificationTargets?: AlertNotificationTarget[]; actor?: string; }
|
||||
export interface AlertRuleLibrarySummary { current: number; enabled: number; disabled: number; archived: number; }
|
||||
export interface AlertRulePage extends Page<AlertRule> { summary: AlertRuleLibrarySummary; }
|
||||
export interface AlertNotificationTarget { channel: string; recipientId: string; label: string; }
|
||||
export interface AlertNotificationTargetOption { id: string; label: string; channels: string[]; }
|
||||
export interface AlertNotificationChannelCapability { channel: string; label: string; configured: boolean; }
|
||||
export interface AlertNotificationConfig { targets: AlertNotificationTargetOption[]; channels: AlertNotificationChannelCapability[]; }
|
||||
export interface AlertRuleRevision {
|
||||
ruleId: string; version: number; actor: string; action: 'create' | 'update' | 'enable' | 'disable' | 'rollback' | 'archive' | 'restore' | string; reason?: string; createdAt: string; snapshot: AlertRule;
|
||||
}
|
||||
export interface AlertNotification {
|
||||
id: number; eventId: string; title: string; content: string; severity: AlertSeverity; channel: string;
|
||||
recipient?: string; recipientId?: string; deliveryStatus?: 'queued' | 'sent' | 'delivered' | 'failed' | string; attemptCount?: number; providerMessageId?: string;
|
||||
vehiclePlate?: string; vehicleVin?: string; protocol?: string; read: boolean; createdAt: string; deliveredAt?: string; readAt: string;
|
||||
lastError?: string; lastAttemptAt?: string; retryRequestedBy?: string; retryRequestedAt?: string; retryAvailable?: boolean; maxAttempts?: number;
|
||||
}
|
||||
export interface AlertNotificationChannelHealth {
|
||||
channel: string; label: string; configured: boolean; queued: number; failed: number; deadLetter: number; activeLeases: number; oldestQueuedAt: string;
|
||||
}
|
||||
export interface AlertNotificationDeliveryHealth {
|
||||
queued: number; failed: number; deadLetter: number; activeLeases: number; oldestQueuedAt: string; channels: AlertNotificationChannelHealth[]; asOf: string;
|
||||
}
|
||||
export interface AlertNotificationRetryAudit {
|
||||
id: number; notificationId: number; actor: string; reason: string; previousStatus: string; nextStatus: string; attemptCount: number; requestedAt: string;
|
||||
}
|
||||
export interface AlertNotificationRetryResult {
|
||||
notification: AlertNotification; receipt: AlertNotificationRetryAudit; idempotent: boolean;
|
||||
}
|
||||
export interface AlertRuleInput extends Omit<AlertRule, 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt'> { actor?: string; }
|
||||
export interface AlertNotification { id: number; eventId: string; title: string; content: string; severity: AlertSeverity; channel: string; read: boolean; createdAt: string; readAt: string; }
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol: string;
|
||||
@@ -414,6 +494,19 @@ export interface VehicleCoverageSummary {
|
||||
archiveMissingFields: ArchiveMissingFieldStat[];
|
||||
}
|
||||
|
||||
export interface VehicleBusinessFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VehicleBusinessFilters {
|
||||
departments: VehicleBusinessFilterOption[];
|
||||
responsibleUsers: VehicleBusinessFilterOption[];
|
||||
customers: VehicleBusinessFilterOption[];
|
||||
statuses: VehicleBusinessFilterOption[];
|
||||
}
|
||||
|
||||
export interface VehicleIdentityResolution {
|
||||
lookupKey: string;
|
||||
resolved: boolean;
|
||||
@@ -434,6 +527,7 @@ export interface VehicleDetail {
|
||||
resolution?: VehicleIdentityResolution;
|
||||
identity?: VehicleRow;
|
||||
profile?: VehicleProfile;
|
||||
businessRelation?: VehicleBusinessRelation;
|
||||
realtimeSummary?: VehicleRealtimeRow;
|
||||
serviceStatus?: VehicleServiceStatus;
|
||||
serviceOverview?: VehicleServiceOverview;
|
||||
@@ -447,6 +541,27 @@ export interface VehicleDetail {
|
||||
quality: Page<QualityIssueRow>;
|
||||
}
|
||||
|
||||
export interface VehicleBusinessRelation {
|
||||
sourceSystem: string;
|
||||
sourceVersion: string;
|
||||
vehicleId: string;
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
contractId: string;
|
||||
contractCode: string;
|
||||
projectName: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
responsibleUserId: string;
|
||||
responsibleUserName: string;
|
||||
operationStatus: string;
|
||||
scopeStartAt: string;
|
||||
sourceUpdatedAt: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface VehicleProfile {
|
||||
vin: string;
|
||||
brandName: string;
|
||||
@@ -790,6 +905,9 @@ export interface DailyMileageRow {
|
||||
startMileageKm: number;
|
||||
endMileageKm: number;
|
||||
dailyMileageKm: number;
|
||||
pureHydrogenMileageKm?: number;
|
||||
hydrogenConsumptionKg?: number | null;
|
||||
hydrogenConsumptionKgPer100Km?: number | null;
|
||||
source: string;
|
||||
anomalySeverity?: string;
|
||||
}
|
||||
@@ -799,14 +917,26 @@ export interface MileageSummary {
|
||||
recordCount: number;
|
||||
sourceCount: number;
|
||||
totalMileageKm: number;
|
||||
totalPureHydrogenMileageKm: number;
|
||||
averageMileagePerVin: number;
|
||||
}
|
||||
|
||||
export interface MileageTrendPoint { date: string; mileageKm: number; vehicles: number; }
|
||||
export interface MileageTrendPoint {
|
||||
date: string;
|
||||
mileageKm: number;
|
||||
pureHydrogenMileageKm: number;
|
||||
hydrogenMatchedMileageKm?: number;
|
||||
hydrogenDataDays?: number;
|
||||
hydrogenConsumptionKg?: number | null;
|
||||
hydrogenConsumptionKgPer100Km?: number | null;
|
||||
vehicles: number;
|
||||
}
|
||||
export interface MileageVehicleRank { vin: string; plate: string; mileageKm: number; latestMileageKm: number; activeDays: number; }
|
||||
export interface MileageStatistics {
|
||||
dateFrom: string; dateTo: string; vehicleCount: number; recordCount: number; sourceCount: number;
|
||||
periodMileageKm: number; fleetLatestMileageKm: number; averageMileagePerVin: number; averageDailyMileageKm: number;
|
||||
periodMileageKm: number; periodPureHydrogenMileageKm: number; hydrogenMatchedMileageKm?: number;
|
||||
hydrogenDataDays?: number; periodHydrogenConsumptionKg?: number; hydrogenConsumptionKgPer100Km?: number | null;
|
||||
fleetLatestMileageKm: number; averageMileagePerVin: number; averageDailyMileageKm: number;
|
||||
trend: MileageTrendPoint[]; ranking: MileageVehicleRank[]; asOf: string; evidence: string;
|
||||
}
|
||||
|
||||
@@ -970,10 +1100,13 @@ export interface RuntimeInfo {
|
||||
|
||||
export interface ReconciliationQuery {
|
||||
keyword?: string;
|
||||
scope?: 'current' | 'archived';
|
||||
ruleCode?: string;
|
||||
category?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
owner?: string;
|
||||
sla?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
@@ -1007,10 +1140,66 @@ export interface ReconciliationIssue {
|
||||
recoveredAt: string;
|
||||
resolutionNote: string;
|
||||
resolvedBy: string;
|
||||
assignee: string;
|
||||
assignedBy: string;
|
||||
assignedAt: string;
|
||||
dueAt: string;
|
||||
archivedAt: string;
|
||||
archivedBy: string;
|
||||
archiveReason: string;
|
||||
version: number;
|
||||
actions?: ReconciliationAction[];
|
||||
}
|
||||
|
||||
export interface ReconciliationAssignmentRequest {
|
||||
version: number;
|
||||
assignee: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationAssignee {
|
||||
name: string;
|
||||
username?: string;
|
||||
source: 'account' | 'history' | 'current';
|
||||
activeCount: number;
|
||||
lastAssignedAt?: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchAssignmentRequest {
|
||||
items: Array<{ id: string; version: number }>;
|
||||
assignee: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchActionRequest {
|
||||
items: Array<{ id: string; version: number }>;
|
||||
status: 'pending' | 'no_action' | 'fixed';
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationLifecycleRequest {
|
||||
version: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchLifecycleRequest {
|
||||
items: Array<{ id: string; version: number }>;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchActionFailure {
|
||||
id: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchActionResult {
|
||||
requested: number;
|
||||
succeeded: ReconciliationIssue[];
|
||||
skipped: ReconciliationBatchActionFailure[];
|
||||
}
|
||||
|
||||
export interface ReconciliationBucket {
|
||||
name: string;
|
||||
count: number;
|
||||
@@ -1025,6 +1214,8 @@ export interface ReconciliationTrendPoint {
|
||||
}
|
||||
|
||||
export interface ReconciliationSummary {
|
||||
current: number;
|
||||
archived: number;
|
||||
active: number;
|
||||
pending: number;
|
||||
confirmed: number;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBarChartVStroked,
|
||||
IconBox,
|
||||
IconCalendar,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconDownload,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconList,
|
||||
IconMapPin,
|
||||
IconMore,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRoute,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconTickCircle,
|
||||
IconUser
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type PageKey = 'monitor' | 'tracks' | 'history' | 'statistics' | 'alerts' | 'access' | 'users';
|
||||
type Tone = 'blue' | 'green' | 'amber' | 'red' | 'neutral';
|
||||
|
||||
type NavItem = {
|
||||
key: PageKey | 'vehicles';
|
||||
label: string;
|
||||
icon: typeof IconHome;
|
||||
};
|
||||
|
||||
const workspaceNavigation: NavItem[] = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'history', label: '历史数据', icon: IconClock },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
|
||||
];
|
||||
|
||||
const governanceNavigation: NavItem[] = [
|
||||
{ key: 'alerts', label: '事件中心', icon: IconAlertCircle },
|
||||
{ key: 'access', label: '接入管理', icon: IconBox },
|
||||
{ key: 'users', label: '账号管理', icon: IconUser }
|
||||
];
|
||||
|
||||
const pageMeta: Record<PageKey, { title: string; subtitle: string }> = {
|
||||
monitor: { title: '全局监控', subtitle: '实时掌握授权车辆的位置、状态与数据活跃度。' },
|
||||
tracks: { title: '轨迹回放', subtitle: '按时间窗还原车辆行程、停留点与轨迹事件。' },
|
||||
history: { title: '历史数据', subtitle: '查询原始上报、协议字段和可追溯数据证据。' },
|
||||
statistics: { title: '里程查询', subtitle: '比较车辆每日里程、区间汇总与来源覆盖。' },
|
||||
alerts: { title: '事件中心', subtitle: '集中处置车辆告警、自动化规则与证据链。' },
|
||||
access: { title: '接入管理', subtitle: '管理协议来源、终端接入和数据链路健康度。' },
|
||||
users: { title: '账号管理', subtitle: '管理客户账号、菜单权限与车辆授权范围。' }
|
||||
};
|
||||
|
||||
const liveVehicles = [
|
||||
{ plate: '粤AG18312', vin: 'LB9A32A24R0LS1426', brand: 'G7s', speed: 62, status: '行驶中', time: '20:12:12', protocol: 'JT/T 808', tone: 'green' as Tone },
|
||||
{ plate: '川AHTW01', vin: 'LNXNEGRR7SR318212', brand: 'Hyundai', speed: 55, status: '行驶中', time: '20:12:12', protocol: 'GB/T 32960', tone: 'green' as Tone },
|
||||
{ plate: '豫A88888', vin: 'LMRKH9AC2R1004087', brand: '宇通', speed: 48, status: '行驶中', time: '20:11:11', protocol: '宇通 MQTT', tone: 'green' as Tone },
|
||||
{ plate: '粤AFF7936', vin: 'LB9A32A24P0LS1230', brand: '广安车联', speed: 0, status: '离线', time: '19:58:58', protocol: 'JT/T 808', tone: 'neutral' as Tone }
|
||||
];
|
||||
|
||||
const accountRows = [
|
||||
{ name: '羚牛示范车队', account: 'demo.team@lingniu.com', source: '平台本地', menus: 18, vehicles: 128, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '广州运营中心', account: 'gz.ops@lingniu.com', source: '平台本地', menus: 16, vehicles: 86, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '深圳车队', account: 'sz.fleet@lingniu.com', source: '平台本地', menus: 15, vehicles: 72, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '上海物流车队', account: 'sh.logistics@lingniu.com', source: 'LDAP', menus: 14, vehicles: 64, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '北京测试车队', account: 'bj.test@lingniu.com', source: '平台本地', menus: 8, vehicles: 22, state: '待完善', tone: 'amber' as Tone },
|
||||
{ name: '西安车队', account: 'xa.fleet@lingniu.com', source: '钉钉', menus: 10, vehicles: 18, state: '停用', tone: 'neutral' as Tone }
|
||||
];
|
||||
|
||||
const eventRows = [
|
||||
{ id: 'EVT-240703-031', plate: '粤AG18312', title: '急加速事件', level: '一般', time: '20:12:08', state: '待确认', tone: 'amber' as Tone },
|
||||
{ id: 'EVT-240703-030', plate: '豫A88888', title: '离线超过 10 分钟', level: '重要', time: '20:06:22', state: '处理中', tone: 'red' as Tone },
|
||||
{ id: 'EVT-240703-029', plate: '川AHTW01', title: '进入电子围栏', level: '提示', time: '19:58:41', state: '已归档', tone: 'blue' as Tone },
|
||||
{ id: 'EVT-240703-028', plate: '粤AFF7936', title: '终端电压偏低', level: '一般', time: '19:47:16', state: '已确认', tone: 'green' as Tone },
|
||||
{ id: 'EVT-240703-027', plate: '湘B56789', title: '超速持续 3 分钟', level: '重要', time: '19:38:02', state: '已通知', tone: 'red' as Tone }
|
||||
];
|
||||
|
||||
const sourceRows = [
|
||||
{ source: 'JT/T 808', endpoint: 'tcp://ingest-808.lingniu.local:6808', vehicles: 82, today: '1.86M', latency: '42 ms', state: '运行正常', tone: 'green' as Tone },
|
||||
{ source: 'GB/T 32960', endpoint: 'tcp://ingest-32960.lingniu.local:32960', vehicles: 38, today: '962K', latency: '58 ms', state: '运行正常', tone: 'green' as Tone },
|
||||
{ source: '宇通 MQTT', endpoint: 'mqtt://bus-gateway.lingniu.local', vehicles: 8, today: '118K', latency: '126 ms', state: '轻微延迟', tone: 'amber' as Tone },
|
||||
{ source: '补录 CSV', endpoint: '对象存储 / 手工导入', vehicles: 14, today: '3', latency: '—', state: '按需运行', tone: 'blue' as Tone }
|
||||
];
|
||||
|
||||
const historyRows = [
|
||||
{ time: '20:12:12.486', source: 'JT/T 808', event: '位置上报', speed: '62 km/h', mileage: '128,642.8 km', result: '已入库' },
|
||||
{ time: '20:12:02.212', source: 'GB/T 32960', event: '整车数据', speed: '61 km/h', mileage: '128,642.6 km', result: '已融合' },
|
||||
{ time: '20:11:42.936', source: 'JT/T 808', event: '位置上报', speed: '58 km/h', mileage: '128,642.1 km', result: '已入库' },
|
||||
{ time: '20:11:32.501', source: 'GB/T 32960', event: '驱动电机', speed: '57 km/h', mileage: '128,641.9 km', result: '已融合' },
|
||||
{ time: '20:11:12.184', source: 'JT/T 808', event: '位置上报', speed: '51 km/h', mileage: '128,641.4 km', result: '已入库' }
|
||||
];
|
||||
|
||||
const mileageRows = [
|
||||
{ plate: '粤AG18312', vin: 'LB9A32A24R0LS1426', days: [118, 142, 96, 165, 128, 152, 136], total: 937, source: 'JT/T 808' },
|
||||
{ plate: '川AHTW01', vin: 'LNXNEGRR7SR318212', days: [86, 103, 74, 112, 97, 126, 109], total: 707, source: 'GB/T 32960' },
|
||||
{ plate: '豫A88888', vin: 'LMRKH9AC2R1004087', days: [172, 184, 165, 198, 176, 208, 191], total: 1294, source: '宇通 MQTT' },
|
||||
{ plate: '粤AFF7936', vin: 'LB9A32A24P0LS1230', days: [34, 61, 0, 48, 72, 65, 0], total: 280, source: 'JT/T 808' }
|
||||
];
|
||||
|
||||
function pageFromHash(): PageKey {
|
||||
const value = window.location.hash.replace(/^#\/?/, '') as PageKey;
|
||||
return value in pageMeta ? value : 'monitor';
|
||||
}
|
||||
|
||||
function StatusPill({ children, tone = 'neutral' }: { children: ReactNode; tone?: Tone }) {
|
||||
return <span className={`platform-status is-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function Sidebar({ page, onNavigate }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void }) {
|
||||
const renderGroup = (items: NavItem[]) => items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className={item.key === page ? 'is-active' : ''}
|
||||
type="button"
|
||||
key={item.key}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
|
||||
<div className="lab-brand"><img src="/brand-logo.svg" alt="羚牛智能" /></div>
|
||||
<nav>
|
||||
<p>车辆工作台</p>
|
||||
{renderGroup(workspaceNavigation)}
|
||||
<p>平台治理</p>
|
||||
{renderGroup(governanceNavigation)}
|
||||
</nav>
|
||||
<button className="lab-collapse" type="button"><IconChevronLeft aria-hidden="true" /><span>收起</span></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ page, onOpenMenu }: { page: PageKey; onOpenMenu: () => void }) {
|
||||
const meta = pageMeta[page];
|
||||
return (
|
||||
<header className="lab-topbar">
|
||||
<div className="lab-topbar-title">
|
||||
<span><strong>{meta.title}</strong><small>{meta.subtitle}</small></span>
|
||||
</div>
|
||||
<div className="lab-topbar-actions">
|
||||
<button type="button" className="lab-help-button"><IconHelpCircle aria-hidden="true" /><span>页面帮助</span></button>
|
||||
<button type="button" className="platform-mobile-menu-trigger" onClick={onOpenMenu} aria-label="打开页面导航"><IconMore aria-hidden="true" /></button>
|
||||
<button type="button" className="lab-account-button" aria-label="账号菜单">
|
||||
<span className="lab-avatar">I</span><b>local-developer</b><em>管理员</em><IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricStrip({ items }: { items: Array<{ label: string; value: string | number; unit?: string; tone?: Tone; note?: string }> }) {
|
||||
return (
|
||||
<section className="platform-metrics" aria-label="页面摘要">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={`is-${item.tone ?? 'blue'}`}>{item.value}<small>{item.unit}</small></strong>
|
||||
{item.note ? <em>{item.note}</em> : null}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandRail({
|
||||
children,
|
||||
onSubmit,
|
||||
primaryLabel,
|
||||
onPrimary
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSubmit?: (event: FormEvent) => void;
|
||||
primaryLabel?: string;
|
||||
onPrimary?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<form className="platform-command-rail" onSubmit={onSubmit}>
|
||||
{children}
|
||||
{primaryLabel ? <button className="platform-primary-button" type={onSubmit ? 'submit' : 'button'} onClick={onPrimary}>{primaryLabel}<IconChevronRight aria-hidden="true" /></button> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({ value, onChange, placeholder, label }: { value: string; onChange: (value: string) => void; placeholder: string; label: string }) {
|
||||
return (
|
||||
<label className="platform-search-field">
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} aria-label={label} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MapCanvas({
|
||||
selected,
|
||||
onSelect,
|
||||
route = false,
|
||||
children
|
||||
}: {
|
||||
selected: string;
|
||||
onSelect: (plate: string) => void;
|
||||
route?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const markers = [
|
||||
{ plate: '粤AG18312', x: 47, y: 46 },
|
||||
{ plate: '川AHTW01', x: 67, y: 34 },
|
||||
{ plate: '豫A88888', x: 72, y: 61 },
|
||||
{ plate: '粤AFF7936', x: 29, y: 66 },
|
||||
{ plate: '湘B56789', x: 55, y: 76 }
|
||||
];
|
||||
return (
|
||||
<div className="platform-map" aria-label="车辆地图">
|
||||
{route ? <div className="platform-route" aria-hidden="true"><i /><i /><i /><i /><i /></div> : null}
|
||||
{markers.map((marker) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`platform-map-marker${selected === marker.plate ? ' is-selected' : ''}`}
|
||||
style={{ left: `${marker.x}%`, top: `${marker.y}%` }}
|
||||
aria-label={`选择地图车辆 ${marker.plate}`}
|
||||
onClick={() => onSelect(marker.plate)}
|
||||
key={marker.plate}
|
||||
>
|
||||
<IconRoute aria-hidden="true" /><span>{marker.plate}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="platform-map-tools" aria-label="地图工具">
|
||||
<button type="button" aria-label="回到车辆范围"><IconMapPin aria-hidden="true" /></button>
|
||||
<button type="button" aria-label="刷新地图"><IconRefresh aria-hidden="true" /></button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState(liveVehicles[0].plate);
|
||||
const [view, setView] = useState<'map' | 'list'>('map');
|
||||
const selectedVehicle = liveVehicles.find((vehicle) => vehicle.plate === selected) ?? liveVehicles[0];
|
||||
const rows = liveVehicles.filter((vehicle) => `${vehicle.plate}${vehicle.vin}${vehicle.brand}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<main className="platform-page platform-monitor-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="输入车牌 / VIN / 终端手机号" label="搜索实时车辆" />
|
||||
<label className="platform-select-field"><span>通信协议</span><select aria-label="通信协议"><option>全部协议</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
<label className="platform-select-field"><span>车辆状态</span><select aria-label="车辆状态"><option>全部状态</option><option>在线</option><option>离线</option></select></label>
|
||||
<button type="button" className="platform-refresh"><IconRefresh aria-hidden="true" />刷新</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '车辆总数', value: 128, unit: '辆' },
|
||||
{ label: '当前在线', value: 96, unit: '辆', tone: 'green' },
|
||||
{ label: '行驶车辆', value: 34, unit: '辆' },
|
||||
{ label: '当前离线', value: 32, unit: '辆', tone: 'neutral' },
|
||||
{ label: '告警车辆', value: 3, unit: '辆', tone: 'red' }
|
||||
]} />
|
||||
<section className="platform-map-workspace">
|
||||
<aside className={`platform-live-list${view === 'list' ? ' is-mobile-visible' : ''}`} aria-label="实时车辆列表">
|
||||
<header>
|
||||
<span><strong>实时车辆</strong><small>{rows.length} 辆示例车辆</small></span>
|
||||
<div className="platform-view-toggle" role="group" aria-label="地图或列表视图">
|
||||
<button type="button" className={view === 'map' ? 'is-active' : ''} onClick={() => setView('map')} aria-label="地图视图"><IconMapPin aria-hidden="true" /></button>
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')} aria-label="列表视图"><IconList aria-hidden="true" /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="platform-live-list-body">
|
||||
{rows.map((vehicle) => (
|
||||
<button type="button" className={selected === vehicle.plate ? 'is-selected' : ''} onClick={() => { setSelected(vehicle.plate); setView('map'); }} key={vehicle.vin}>
|
||||
<i className={`is-${vehicle.tone}`} />
|
||||
<span><strong>{vehicle.plate}</strong><small>{vehicle.brand} · {vehicle.vin}</small><em>{vehicle.time}</em></span>
|
||||
<b>{vehicle.speed} km/h</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer><button type="button">查看全部车辆<IconChevronRight aria-hidden="true" /></button></footer>
|
||||
</aside>
|
||||
<MapCanvas selected={selected} onSelect={setSelected}>
|
||||
<div className="platform-mobile-map-tabs" role="group" aria-label="监控视图">
|
||||
<button type="button" className={view === 'map' ? 'is-active' : ''} onClick={() => setView('map')}>地图</button>
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>列表</button>
|
||||
</div>
|
||||
</MapCanvas>
|
||||
<aside className="platform-map-inspector" aria-label={`${selectedVehicle.plate} 实时详情`}>
|
||||
<header><span><strong>{selectedVehicle.plate}</strong><StatusPill tone={selectedVehicle.tone}>{selectedVehicle.status}</StatusPill></span><small>{selectedVehicle.brand} · {selectedVehicle.vin}</small></header>
|
||||
<dl>
|
||||
<div><dt>实时速度</dt><dd className="is-emphasis">{selectedVehicle.speed} <small>km/h</small></dd></div>
|
||||
<div><dt>最后上报时间</dt><dd>07-03 {selectedVehicle.time}</dd></div>
|
||||
<div><dt>通信协议</dt><dd><StatusPill tone="blue">{selectedVehicle.protocol}</StatusPill></dd></div>
|
||||
<div><dt>定位方式</dt><dd>北斗 / GPS</dd></div>
|
||||
<div><dt>当前位置</dt><dd>广东省广州市天河区科韵路附近</dd></div>
|
||||
<div><dt>今日里程</dt><dd>128.6 km</dd></div>
|
||||
</dl>
|
||||
<footer><button type="button">车辆档案</button><button type="button" className="is-primary">轨迹回放<IconChevronRight aria-hidden="true" /></button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackPage() {
|
||||
const [selected, setSelected] = useState('粤AG18312');
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(36);
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const timer = window.setInterval(() => setProgress((value) => value >= 100 ? 0 : value + 1), 240);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [playing]);
|
||||
|
||||
return (
|
||||
<main className="platform-page platform-track-page">
|
||||
<section className="platform-track-stage">
|
||||
<aside className={`platform-track-rail${railOpen ? ' is-open' : ''}`}>
|
||||
<header><span><strong>轨迹查询</strong><small>最长支持连续 7 天</small></span><button type="button" onClick={() => setRailOpen(false)} aria-label="收起轨迹查询"><IconChevronLeft /></button></header>
|
||||
<SearchField value={selected} onChange={setSelected} placeholder="输入车牌 / VIN" label="搜索轨迹车辆" />
|
||||
<div className="platform-date-presets" role="group" aria-label="轨迹时间范围">
|
||||
<button className="is-active" type="button">今天</button><button type="button">昨天</button><button type="button">近 3 天</button>
|
||||
</div>
|
||||
<label className="platform-field-stack"><span>时间范围</span><div><IconCalendar /><input defaultValue="2026-07-27 00:00 — 23:59" aria-label="轨迹时间范围" /></div></label>
|
||||
<label className="platform-field-stack"><span>里程来源</span><select aria-label="里程来源"><option>自动选择最佳来源</option><option>JT/T 808 · GPS 里程</option><option>GB/T 32960 · 仪表盘里程</option></select></label>
|
||||
<button type="button" className="platform-query-button">生成轨迹</button>
|
||||
<MetricStrip items={[
|
||||
{ label: '行程里程', value: '128.6', unit: 'km' },
|
||||
{ label: '停留点', value: 4, unit: '个', tone: 'green' },
|
||||
{ label: '事件点', value: 3, unit: '个', tone: 'amber' }
|
||||
]} />
|
||||
<div className="platform-track-evidence">
|
||||
<header><strong>行程明细</strong><StatusPill tone="green">完整点集</StatusPill></header>
|
||||
{['08:42 科韵路出发', '10:16 琶洲停留 18 分钟', '14:08 急加速事件', '20:12 天河软件园'].map((item, index) => <button type="button" key={item}><i>{index + 1}</i><span>{item}</span><IconChevronRight /></button>)}
|
||||
</div>
|
||||
</aside>
|
||||
<MapCanvas route selected={selected} onSelect={setSelected}>
|
||||
{!railOpen ? <button type="button" className="platform-open-rail" onClick={() => setRailOpen(true)}><IconList />查询与明细</button> : null}
|
||||
<div className="platform-track-float-card">
|
||||
<span><StatusPill tone="green">查询完整</StatusPill><small>07-27 14:08:36</small></span>
|
||||
<strong>{selected}</strong>
|
||||
<p>当前 58 km/h · 累计里程 128,642.1 km</p>
|
||||
</div>
|
||||
</MapCanvas>
|
||||
<section className="platform-playback" aria-label="轨迹播放控制">
|
||||
<button type="button" onClick={() => setPlaying((value) => !value)} aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'}>{playing ? <IconPause /> : <IconPlay />}</button>
|
||||
<span><b>08:42</b><input type="range" min="0" max="100" value={progress} onChange={(event) => setProgress(Number(event.target.value))} aria-label="轨迹播放进度" /><b>20:12</b></span>
|
||||
<em>{progress}%</em>
|
||||
<select aria-label="回放倍速"><option>1×</option><option>2×</option><option>4×</option></select>
|
||||
<button type="button" aria-label="导出轨迹"><IconDownload /></button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const [keyword, setKeyword] = useState('粤AG18312');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = historyRows[selectedIndex];
|
||||
return (
|
||||
<main className="platform-page platform-analytic-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()} primaryLabel="查询历史">
|
||||
<SearchField value={keyword} onChange={setKeyword} placeholder="车牌 / VIN" label="搜索历史数据车辆" />
|
||||
<label className="platform-select-field"><span>日期</span><input type="date" defaultValue="2026-07-27" aria-label="历史日期" /></label>
|
||||
<label className="platform-select-field"><span>协议来源</span><select aria-label="历史协议来源"><option>全部来源</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
<label className="platform-select-field"><span>数据类型</span><select aria-label="历史数据类型"><option>全部类型</option><option>位置上报</option><option>整车数据</option></select></label>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '上报记录', value: '8,642', unit: '条' },
|
||||
{ label: '协议来源', value: 2, unit: '个', tone: 'green' },
|
||||
{ label: '有效覆盖', value: '99.8', unit: '%', tone: 'green' },
|
||||
{ label: '异常间隔', value: 3, unit: '处', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-split-workspace">
|
||||
<div className="platform-table-panel">
|
||||
<header className="platform-panel-header"><span><strong>历史上报记录</strong><small>按设备时间倒序排列,可追溯原始协议证据</small></span><button type="button"><IconDownload />导出当前结果</button></header>
|
||||
<div className="platform-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>设备时间</th><th>来源</th><th>数据类型</th><th>速度</th><th>累计里程</th><th>处理结果</th></tr></thead>
|
||||
<tbody>{historyRows.map((row, index) => (
|
||||
<tr className={selectedIndex === index ? 'is-selected' : ''} onClick={() => setSelectedIndex(index)} tabIndex={0} key={`${row.time}-${row.source}`}>
|
||||
<td><strong>{row.time}</strong><small>2026-07-27</small></td><td><StatusPill tone="blue">{row.source}</StatusPill></td><td>{row.event}</td><td>{row.speed}</td><td>{row.mileage}</td><td><StatusPill tone="green">{row.result}</StatusPill></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer className="platform-pagination"><span>共 8,642 条 · 当前 1–5</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-evidence-panel" aria-label="原始数据证据">
|
||||
<header><span><small>原始数据证据</small><strong>{selected.time}</strong></span><button type="button" aria-label="刷新证据"><IconRefresh /></button></header>
|
||||
<dl>
|
||||
<div><dt>协议来源</dt><dd>{selected.source}</dd></div>
|
||||
<div><dt>消息类型</dt><dd>{selected.event}</dd></div>
|
||||
<div><dt>处理结果</dt><dd><StatusPill tone="green">{selected.result}</StatusPill></dd></div>
|
||||
<div><dt>数据质量</dt><dd>字段完整 · 时序正常</dd></div>
|
||||
</dl>
|
||||
<section className="platform-code-block" aria-label="协议字段">
|
||||
<header><strong>协议字段</strong><button type="button">复制 JSON</button></header>
|
||||
<pre>{`{\n "latitude": 23.12908,\n "longitude": 113.35512,\n "speedKmh": ${selected.speed.split(' ')[0]},\n "mileageKm": 128642.8,\n "alarm": 0\n}`}</pre>
|
||||
</section>
|
||||
<button type="button" className="platform-inspector-action">打开完整证据链<IconChevronRight /></button>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StatisticsPage() {
|
||||
const [mode, setMode] = useState<'daily' | 'vehicle'>('daily');
|
||||
const maxMileage = Math.max(...mileageRows.flatMap((row) => row.days));
|
||||
return (
|
||||
<main className="platform-page platform-analytic-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()} primaryLabel="计算里程">
|
||||
<SearchField value="" onChange={() => undefined} placeholder="车牌 / VIN,可留空查询授权范围" label="搜索里程车辆" />
|
||||
<label className="platform-select-field"><span>开始日期</span><input type="date" defaultValue="2026-07-21" aria-label="里程开始日期" /></label>
|
||||
<label className="platform-select-field"><span>结束日期</span><input type="date" defaultValue="2026-07-27" aria-label="里程结束日期" /></label>
|
||||
<label className="platform-select-field"><span>里程来源</span><select aria-label="里程来源"><option>自动优选</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '车辆数量', value: 4, unit: '辆' },
|
||||
{ label: '区间总里程', value: '3,218', unit: 'km', tone: 'green' },
|
||||
{ label: '日均里程', value: '114.9', unit: 'km' },
|
||||
{ label: '完整覆盖', value: 3, unit: '辆', tone: 'green' },
|
||||
{ label: '缺失天数', value: 2, unit: '天', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-mileage-workspace">
|
||||
<header className="platform-panel-header">
|
||||
<span><strong>每日与区间里程</strong><small>2026-07-21 — 2026-07-27 · 自动选择每辆车最佳来源</small></span>
|
||||
<div className="platform-segmented" role="tablist" aria-label="里程视图">
|
||||
<button type="button" role="tab" aria-selected={mode === 'daily'} className={mode === 'daily' ? 'is-active' : ''} onClick={() => setMode('daily')}>每日趋势</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'vehicle'} className={mode === 'vehicle' ? 'is-active' : ''} onClick={() => setMode('vehicle')}>车辆汇总</button>
|
||||
</div>
|
||||
<button type="button"><IconDownload />导出 Excel</button>
|
||||
</header>
|
||||
<div className={`platform-mileage-chart is-${mode}`} aria-label="里程图表">
|
||||
{mileageRows.map((row) => (
|
||||
<div className="platform-mileage-series" key={row.vin}>
|
||||
<header><span><strong>{row.plate}</strong><small>{row.source}</small></span><b>{row.total} km</b></header>
|
||||
<div>{row.days.map((value, index) => <span style={{ height: mode === 'daily' ? `${Math.max(8, value / maxMileage * 100)}%` : `${row.total / 13}%` }} title={`07-${21 + index}:${value} km`} key={`${row.vin}-${index}`}><i>{value || '—'}</i></span>)}</div>
|
||||
</div>
|
||||
))}
|
||||
<footer>{['07-21', '07-22', '07-23', '07-24', '07-25', '07-26', '07-27'].map((day) => <span key={day}>{day}</span>)}</footer>
|
||||
</div>
|
||||
<div className="platform-mileage-summary">
|
||||
{mileageRows.map((row) => <button type="button" key={row.vin}><span><strong>{row.plate}</strong><small>{row.vin}</small></span><StatusPill tone="blue">{row.source}</StatusPill><b>{row.total}<small> km</small></b><IconChevronRight /></button>)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage() {
|
||||
const [scope, setScope] = useState('全部事件');
|
||||
const [selectedId, setSelectedId] = useState(eventRows[0].id);
|
||||
const selected = eventRows.find((row) => row.id === selectedId) ?? eventRows[0];
|
||||
const rows = scope === '重要事件' ? eventRows.filter((row) => row.level === '重要') : eventRows;
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchField value="" onChange={() => undefined} placeholder="事件编号 / 车牌 / VIN / 规则名称" label="搜索事件" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="事件范围">{['全部事件', '重要事件', '待处理', '已归档'].map((item) => <button type="button" role="tab" aria-selected={scope === item} className={scope === item ? 'is-active' : ''} onClick={() => setScope(item)} key={item}>{item}</button>)}</div>
|
||||
<button type="button" className="platform-secondary-button"><IconSetting />自动化规则</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '今日事件', value: 38, unit: '条' },
|
||||
{ label: '待确认', value: 6, unit: '条', tone: 'amber' },
|
||||
{ label: '处理中', value: 2, unit: '条', tone: 'red' },
|
||||
{ label: '自动归档', value: 27, unit: '条', tone: 'green' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns"><span>事件 / 车辆</span><span>等级</span><span>发生时间</span><span>处置状态</span></header>
|
||||
{rows.map((event) => (
|
||||
<button type="button" className={selectedId === event.id ? 'is-selected' : ''} onClick={() => setSelectedId(event.id)} key={event.id}>
|
||||
<span><strong>{event.title}</strong><small>{event.plate} · {event.id}</small></span>
|
||||
<StatusPill tone={event.tone}>{event.level}</StatusPill><time>{event.time}</time><StatusPill tone={event.state === '待确认' ? 'amber' : event.state === '处理中' ? 'red' : 'green'}>{event.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<footer className="platform-pagination"><span>共 {rows.length} 条示例事件</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button disabled type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector" aria-label={`${selected.id} 事件详情`}>
|
||||
<header><span><small>{selected.id}</small><strong>{selected.title}</strong></span><StatusPill tone={selected.tone}>{selected.level}</StatusPill></header>
|
||||
<section className="platform-inspector-summary"><div><small>关联车辆</small><strong>{selected.plate}</strong></div><div><small>发生时间</small><strong>07-03 {selected.time}</strong></div></section>
|
||||
<dl><div><dt>当前状态</dt><dd>{selected.state}</dd></div><div><dt>触发规则</dt><dd>{selected.title} · 生产版本 v4</dd></div><div><dt>证据来源</dt><dd>JT/T 808 · 位置与速度</dd></div><div><dt>通知结果</dt><dd>运营负责人已送达</dd></div></dl>
|
||||
<section className="platform-timeline"><strong>处置记录</strong><p><i />20:12 系统识别并创建事件</p><p><i />20:13 自动通知运营负责人</p><p><i />等待人工确认</p></section>
|
||||
<footer><button type="button">标记误报</button><button type="button" className="is-primary">确认并归档</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessPage() {
|
||||
const [selectedSource, setSelectedSource] = useState(sourceRows[0].source);
|
||||
const selected = sourceRows.find((row) => row.source === selectedSource) ?? sourceRows[0];
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail>
|
||||
<SearchField value="" onChange={() => undefined} placeholder="协议、端点或终端标识" label="搜索接入来源" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="接入范围"><button type="button" className="is-active" role="tab" aria-selected="true">全部来源</button><button type="button" role="tab">运行中</button><button type="button" role="tab">需关注</button></div>
|
||||
<button type="button" className="platform-primary-button"><IconPlus />新建接入</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '协议来源', value: 4, unit: '个' },
|
||||
{ label: '在线终端', value: 128, unit: '个', tone: 'green' },
|
||||
{ label: '今日上报', value: '2.94M', unit: '条' },
|
||||
{ label: '需关注来源', value: 1, unit: '个', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns platform-access-columns"><span>协议来源 / 接入端点</span><span>车辆</span><span>今日上报</span><span>延迟</span><span>状态</span></header>
|
||||
{sourceRows.map((source) => (
|
||||
<button type="button" className={`${selectedSource === source.source ? 'is-selected' : ''} platform-access-row`} onClick={() => setSelectedSource(source.source)} key={source.source}>
|
||||
<span><strong>{source.source}</strong><small>{source.endpoint}</small></span><b>{source.vehicles}</b><b>{source.today}</b><time>{source.latency}</time><StatusPill tone={source.tone}>{source.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<section className="platform-health-band">
|
||||
<header><span><strong>数据链路健康</strong><small>最近 15 分钟 · 端到端处理快照</small></span><StatusPill tone="green">整体正常</StatusPill></header>
|
||||
<div>{['连接接收', '协议解析', '去重融合', '时序入库'].map((item, index) => <span key={item}><IconTickCircle /><b>{item}</b><small>{[99.99, 99.98, 99.97, 99.99][index]}%</small></span>)}</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector" aria-label={`${selected.source} 接入详情`}>
|
||||
<header><span><small>接入来源</small><strong>{selected.source}</strong></span><StatusPill tone={selected.tone}>{selected.state}</StatusPill></header>
|
||||
<section className="platform-inspector-summary"><div><small>车辆覆盖</small><strong>{selected.vehicles} 辆</strong></div><div><small>当前延迟</small><strong>{selected.latency}</strong></div></section>
|
||||
<dl><div><dt>接入端点</dt><dd>{selected.endpoint}</dd></div><div><dt>鉴权方式</dt><dd>终端白名单 + 密钥</dd></div><div><dt>最后接收</dt><dd>07-03 20:12:12</dd></div><div><dt>今日异常帧</dt><dd>3 条 · 0.0002%</dd></div></dl>
|
||||
<section className="platform-timeline"><strong>最近活动</strong><p><i />20:12 接收 1,284 个实时帧</p><p><i />20:10 配置健康检查通过</p><p><i />18:30 密钥轮换完成</p></section>
|
||||
<footer><button type="button">查看原始帧</button><button type="button" className="is-primary">编辑接入</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const [scope, setScope] = useState('全部状态');
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState(accountRows[0].name);
|
||||
const [notice, setNotice] = useState('');
|
||||
const selected = accountRows.find((row) => row.name === selectedName) ?? accountRows[0];
|
||||
const rows = accountRows.filter((row) => {
|
||||
const matchesQuery = `${row.name}${row.account}${row.source}`.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesScope = scope === '全部状态' || (scope === '启用账号' && row.state === '启用') || (scope === '停用账号' && row.state === '停用') || (scope === '待完善权限' && row.state === '待完善');
|
||||
return matchesQuery && matchesScope;
|
||||
});
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="名称、账号、客户标识或身份源" label="搜索客户账号" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="账号范围">{['全部状态', '启用账号', '停用账号', '待完善权限'].map((item) => <button type="button" role="tab" aria-selected={scope === item} className={scope === item ? 'is-active' : ''} onClick={() => setScope(item)} key={item}>{item}</button>)}</div>
|
||||
<button type="button" className="platform-primary-button" onClick={() => setNotice('已打开新建客户账号草稿')}><IconPlus />新建客户账号</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '当前账号', value: 18, unit: '个' },
|
||||
{ label: '待完善', value: 3, unit: '个', tone: 'amber', note: '待补全权限设置' },
|
||||
{ label: '权限就绪', value: 14, unit: '个', tone: 'green', note: '可正常使用' },
|
||||
{ label: '外部身份', value: 5, unit: '个', note: '通过外部身份源' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns platform-account-columns"><span>客户名称</span><span>登录账号</span><span>身份源</span><span>菜单</span><span>车辆</span><span>状态</span></header>
|
||||
{rows.map((account) => (
|
||||
<button type="button" className={`${selectedName === account.name ? 'is-selected' : ''} platform-account-row`} onClick={() => setSelectedName(account.name)} key={account.account}>
|
||||
<strong>{account.name}</strong><span>{account.account}</span><span>{account.source}</span><b>{account.menus}</b><b>{account.vehicles}</b><StatusPill tone={account.tone}>{account.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<footer className="platform-pagination"><span>共 {rows.length} 个示例账号</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector platform-account-inspector" aria-label={`${selected.name} 账号详情`}>
|
||||
<header><span><small>客户账号</small><strong>{selected.name}</strong></span><StatusPill tone={selected.tone}>{selected.state}</StatusPill></header>
|
||||
<dl><div><dt>登录账号</dt><dd>{selected.account}</dd></div><div><dt>身份来源</dt><dd>{selected.source}</dd></div><div><dt>最近登录</dt><dd>2026-07-27 19:48</dd></div></dl>
|
||||
<section className="platform-permission-section"><header><strong>菜单权限</strong><button type="button">编辑</button></header><div><b>{selected.menus} 个菜单</b><small>车辆工作台与平台治理</small></div></section>
|
||||
<section className="platform-permission-section"><header><strong>车辆权限与有效期</strong><button type="button">编辑</button></header><div><b>{selected.vehicles} 辆授权车</b><small>持续有效 · 账号范围</small></div></section>
|
||||
<footer><button type="button">停用账号</button><button type="button" className="is-primary" onClick={() => setNotice(`${selected.name} 权限已保存`)}>保存权限</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
{notice ? <div className="platform-notice" role="status">{notice}<button type="button" onClick={() => setNotice('')} aria-label="关闭提示"><IconClose /></button></div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavigation({ page, onNavigate, onMore }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void; onMore: () => void }) {
|
||||
const items: Array<NavItem | { key: 'more'; label: string; icon: typeof IconMore }> = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
|
||||
{ key: 'more', label: '更多', icon: IconMore }
|
||||
];
|
||||
return (
|
||||
<nav className="lab-bottom-nav" aria-label="移动端主导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.key === page || (item.key === 'more' && ['history', 'alerts', 'access', 'users'].includes(page));
|
||||
return <button type="button" className={active ? 'is-active' : ''} onClick={() => item.key === 'more' ? onMore() : onNavigate(item.key)} key={item.key}><Icon /><span>{item.label}</span></button>;
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMenu({ page, onNavigate, onClose }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="lab-sheet-backdrop platform-mobile-menu-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<section className="platform-mobile-menu" role="dialog" aria-modal="true" aria-label="页面导航">
|
||||
<header><span><strong>切换页面</strong><small>车辆工作台与平台治理</small></span><button type="button" onClick={onClose} aria-label="关闭页面导航"><IconClose /></button></header>
|
||||
<div>{[...workspaceNavigation, ...governanceNavigation].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return <button type="button" className={item.key === page ? 'is-active' : ''} onClick={() => onNavigate(item.key)} key={item.key}><Icon /><span><strong>{item.label}</strong><small>{item.key === 'vehicles' ? '车辆目录与数字档案' : item.key in pageMeta ? pageMeta[item.key as PageKey].subtitle : ''}</small></span><IconChevronRight /></button>;
|
||||
})}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage(page: PageKey) {
|
||||
switch (page) {
|
||||
case 'monitor': return <MonitorPage />;
|
||||
case 'tracks': return <TrackPage />;
|
||||
case 'history': return <HistoryPage />;
|
||||
case 'statistics': return <StatisticsPage />;
|
||||
case 'alerts': return <AlertsPage />;
|
||||
case 'access': return <AccessPage />;
|
||||
case 'users': return <UsersPage />;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlatformDesignLab() {
|
||||
const [page, setPage] = useState<PageKey>(() => pageFromHash());
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setPage(pageFromHash());
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = (target: PageKey | 'vehicles') => {
|
||||
setMobileMenuOpen(false);
|
||||
if (target === 'vehicles') {
|
||||
window.location.href = '/vehicle-design-lab.html';
|
||||
return;
|
||||
}
|
||||
window.location.hash = target;
|
||||
setPage(target);
|
||||
};
|
||||
|
||||
const content = useMemo(() => renderPage(page), [page]);
|
||||
|
||||
return (
|
||||
<div className="vehicle-design-lab platform-design-lab">
|
||||
<Sidebar page={page} onNavigate={navigate} />
|
||||
<div className="lab-stage">
|
||||
<TopBar page={page} onOpenMenu={() => setMobileMenuOpen(true)} />
|
||||
{content}
|
||||
<BottomNavigation page={page} onNavigate={navigate} onMore={() => setMobileMenuOpen(true)} />
|
||||
{mobileMenuOpen ? <MobileMenu page={page} onNavigate={navigate} onClose={() => setMobileMenuOpen(false)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
import {
|
||||
IconBarChartVStroked,
|
||||
IconBox,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconCopy,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconMore,
|
||||
IconRefresh,
|
||||
IconRoute,
|
||||
IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
type DirectoryView = 'all' | 'online' | 'offline' | 'multi';
|
||||
type VehicleStatus = 'online' | 'offline';
|
||||
type ProtocolKind = 'JT/T 808' | 'GB/T 32960' | '宇通 MQTT';
|
||||
|
||||
type VehicleRecord = {
|
||||
plate: string;
|
||||
vin: string;
|
||||
brand: string;
|
||||
terminal: string;
|
||||
status: VehicleStatus;
|
||||
updatedAt: string;
|
||||
protocols: ProtocolKind[];
|
||||
department: string;
|
||||
owner: string;
|
||||
customer: string;
|
||||
};
|
||||
|
||||
const vehicles: VehicleRecord[] = [
|
||||
{
|
||||
plate: '粤AG18312',
|
||||
vin: 'LB9A32A24R0LS1426',
|
||||
brand: 'G7s',
|
||||
terminal: '13307795425',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:12',
|
||||
protocols: ['JT/T 808', 'GB/T 32960'],
|
||||
department: '华南运营部',
|
||||
owner: '陈思远',
|
||||
customer: '羚牛示范车队'
|
||||
},
|
||||
{
|
||||
plate: '川AHTW01',
|
||||
vin: 'LNXNEGRR7SR318212',
|
||||
brand: 'Hyundai',
|
||||
terminal: '暂无终端手机号',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:12',
|
||||
protocols: ['GB/T 32960'],
|
||||
department: '西南交付部',
|
||||
owner: '王凌',
|
||||
customer: '川渝联合车队'
|
||||
},
|
||||
{
|
||||
plate: '豫A88888',
|
||||
vin: 'LMRKH9AC2R1004087',
|
||||
brand: '宇通',
|
||||
terminal: '暂无终端手机号',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:11',
|
||||
protocols: ['宇通 MQTT'],
|
||||
department: '中原运营部',
|
||||
owner: '刘真',
|
||||
customer: '宇通测试车队'
|
||||
},
|
||||
{
|
||||
plate: '粤AFF7936',
|
||||
vin: 'LB9A32A24P0LS1230',
|
||||
brand: '广安车联',
|
||||
terminal: '13307795426',
|
||||
status: 'offline',
|
||||
updatedAt: '07-03 19:58',
|
||||
protocols: ['JT/T 808'],
|
||||
department: '华南运营部',
|
||||
owner: '陈思远',
|
||||
customer: '羚牛示范车队'
|
||||
}
|
||||
];
|
||||
|
||||
const navigation = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'history', label: '历史数据', icon: IconClock },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
|
||||
];
|
||||
|
||||
const governanceNavigation = [
|
||||
{ key: 'alerts', label: '事件中心', icon: IconClock },
|
||||
{ key: 'access', label: '接入管理', icon: IconBox },
|
||||
{ key: 'users', label: '账号管理', icon: IconMore }
|
||||
];
|
||||
|
||||
const viewOptions: Array<{ key: DirectoryView; label: string }> = [
|
||||
{ key: 'all', label: '全部车辆' },
|
||||
{ key: 'online', label: '当前在线' },
|
||||
{ key: 'offline', label: '当前离线' },
|
||||
{ key: 'multi', label: '多源车辆' }
|
||||
];
|
||||
|
||||
function ProtocolTag({ protocol }: { protocol: ProtocolKind }) {
|
||||
const tone = protocol === 'JT/T 808' ? 'cyan' : protocol === 'GB/T 32960' ? 'blue' : 'violet';
|
||||
return <span className={`lab-protocol-tag is-${tone}`}>{protocol}</span>;
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: VehicleStatus }) {
|
||||
return <span className={`lab-status-tag is-${status}`}>{status === 'online' ? '在线' : '离线'}</span>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const navigate = (key: string) => {
|
||||
if (key !== 'vehicles') window.location.href = `/platform-design-lab.html#${key}`;
|
||||
};
|
||||
return (
|
||||
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
|
||||
<div className="lab-brand">
|
||||
<img src="/brand-logo.svg" alt="羚牛智能" />
|
||||
</div>
|
||||
<nav>
|
||||
<p>车辆工作台</p>
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button className={item.active ? 'is-active' : ''} type="button" key={item.label} onClick={() => navigate(item.key)}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<p>平台治理</p>
|
||||
{governanceNavigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button type="button" key={item.label} onClick={() => navigate(item.key)}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button className="lab-collapse" type="button">
|
||||
<IconChevronLeft aria-hidden="true" />
|
||||
<span>收起</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ onHelp }: { onHelp: () => void }) {
|
||||
return (
|
||||
<header className="lab-topbar">
|
||||
<div className="lab-topbar-title">
|
||||
<img src="/brand-mark.svg" alt="" aria-hidden="true" />
|
||||
<span>
|
||||
<strong>车辆查询</strong>
|
||||
<small>按车牌或 VIN 查询车辆档案和实时遥测。</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="lab-topbar-actions">
|
||||
<button type="button" className="lab-help-button" onClick={onHelp}>
|
||||
<IconHelpCircle aria-hidden="true" />
|
||||
<span>页面帮助</span>
|
||||
</button>
|
||||
<button type="button" className="lab-account-button" aria-label="账号菜单">
|
||||
<span className="lab-avatar">I</span>
|
||||
<b>local-developer</b>
|
||||
<em>管理员</em>
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandSurfaceProps = {
|
||||
keyword: string;
|
||||
view: DirectoryView;
|
||||
department: string;
|
||||
owner: string;
|
||||
customer: string;
|
||||
status: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onViewChange: (view: DirectoryView) => void;
|
||||
onDepartmentChange: (value: string) => void;
|
||||
onOwnerChange: (value: string) => void;
|
||||
onCustomerChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
};
|
||||
|
||||
function CommandSurface({
|
||||
keyword,
|
||||
view,
|
||||
department,
|
||||
owner,
|
||||
customer,
|
||||
status,
|
||||
onKeywordChange,
|
||||
onViewChange,
|
||||
onDepartmentChange,
|
||||
onOwnerChange,
|
||||
onCustomerChange,
|
||||
onStatusChange,
|
||||
onSubmit
|
||||
}: CommandSurfaceProps) {
|
||||
return (
|
||||
<section className="lab-command-surface" aria-label="车辆查询命令栏">
|
||||
<div className="lab-command-primary">
|
||||
<form className="lab-search-form" onSubmit={onSubmit}>
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="输入车牌 / VIN / 终端手机号"
|
||||
aria-label="输入车牌、VIN 或终端手机号"
|
||||
/>
|
||||
<button type="submit">
|
||||
<span>查询车辆</span>
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="lab-view-tabs" role="tablist" aria-label="车辆目录视图">
|
||||
{viewOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === option.key}
|
||||
className={view === option.key ? 'is-active' : ''}
|
||||
onClick={() => onViewChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="lab-batch-button" type="button">
|
||||
<IconBox aria-hidden="true" />
|
||||
<span>批量同步主档</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="lab-filter-row">
|
||||
<label>
|
||||
<span>部门</span>
|
||||
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
|
||||
<option value="">全部部门</option>
|
||||
<option>华南运营部</option>
|
||||
<option>西南交付部</option>
|
||||
<option>中原运营部</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>业务负责人</span>
|
||||
<select value={owner} onChange={(event) => onOwnerChange(event.target.value)}>
|
||||
<option value="">全部负责人</option>
|
||||
<option>陈思远</option>
|
||||
<option>王凌</option>
|
||||
<option>刘真</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>客户</span>
|
||||
<select value={customer} onChange={(event) => onCustomerChange(event.target.value)}>
|
||||
<option value="">全部客户</option>
|
||||
<option>羚牛示范车队</option>
|
||||
<option>川渝联合车队</option>
|
||||
<option>宇通测试车队</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCommandSurface({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onSubmit,
|
||||
activeFilterCount,
|
||||
onOpenFilters,
|
||||
onBatch
|
||||
}: {
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
activeFilterCount: number;
|
||||
onOpenFilters: () => void;
|
||||
onBatch: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<form className="lab-mobile-search" onSubmit={onSubmit}>
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="车牌 / VIN / 终端手机号"
|
||||
aria-label="输入车牌、VIN 或终端手机号"
|
||||
/>
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
<section className="lab-mobile-scope" aria-label="车辆范围">
|
||||
<button className="lab-mobile-filter" type="button" onClick={onOpenFilters} aria-label="打开车辆筛选">
|
||||
<IconFilter aria-hidden="true" />
|
||||
{activeFilterCount > 0 ? <b>{activeFilterCount}</b> : null}
|
||||
</button>
|
||||
<span>
|
||||
<strong>车辆范围</strong>
|
||||
<small>4 辆授权车辆</small>
|
||||
</span>
|
||||
<button className="lab-mobile-modify" type="button" onClick={onOpenFilters}>
|
||||
修改
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
<button className="lab-mobile-batch" type="button" onClick={onBatch}>
|
||||
<IconBox aria-hidden="true" />
|
||||
<span>批量同步</span>
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRail({ records }: { records: VehicleRecord[] }) {
|
||||
const online = records.filter((vehicle) => vehicle.status === 'online').length;
|
||||
const multi = records.filter((vehicle) => vehicle.protocols.length > 1).length;
|
||||
const metrics = [
|
||||
{ label: '授权车辆', value: records.length, tone: 'primary' },
|
||||
{ label: '本页在线', value: online, tone: 'success' },
|
||||
{ label: '本页多源', value: multi, tone: 'primary' }
|
||||
];
|
||||
return (
|
||||
<section className="lab-metric-rail" aria-label="车辆目录摘要">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong className={`is-${metric.tone}`}>{metric.value}<small>辆</small></strong>
|
||||
</div>
|
||||
))}
|
||||
<aside>
|
||||
<span>
|
||||
<strong>授权车辆目录</strong>
|
||||
<small>按照最新上报时间排序,分页浏览全部授权车辆</small>
|
||||
</span>
|
||||
<button type="button">第 1 / 1 页</button>
|
||||
<button type="button" className="lab-refresh-button" aria-label="刷新车辆目录">
|
||||
<IconRefresh aria-hidden="true" />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopDirectory({
|
||||
records,
|
||||
selectedVin,
|
||||
onSelect
|
||||
}: {
|
||||
records: VehicleRecord[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRecord) => void;
|
||||
}) {
|
||||
const selectByKeyboard = (event: KeyboardEvent<HTMLTableRowElement>, vehicle: VehicleRecord) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect(vehicle);
|
||||
};
|
||||
return (
|
||||
<table className="lab-vehicle-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>车辆</th>
|
||||
<th>品牌 / 终端</th>
|
||||
<th>实时状态</th>
|
||||
<th>协议来源</th>
|
||||
<th>最后上报时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((vehicle) => (
|
||||
<tr
|
||||
key={vehicle.vin}
|
||||
tabIndex={0}
|
||||
aria-selected={vehicle.vin === selectedVin}
|
||||
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
onKeyDown={(event) => selectByKeyboard(event, vehicle)}
|
||||
>
|
||||
<td>
|
||||
<strong>{vehicle.plate}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{vehicle.brand}</strong>
|
||||
<small>{vehicle.terminal}</small>
|
||||
</td>
|
||||
<td><StatusTag status={vehicle.status} /></td>
|
||||
<td>
|
||||
<span className="lab-protocol-list">
|
||||
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{vehicle.updatedAt}:12</td>
|
||||
<td>
|
||||
<button type="button" onClick={(event) => { event.stopPropagation(); onSelect(vehicle); }}>
|
||||
查看档案
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDirectory({
|
||||
records,
|
||||
selectedVin,
|
||||
onSelect
|
||||
}: {
|
||||
records: VehicleRecord[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRecord) => void;
|
||||
}) {
|
||||
return (
|
||||
<ul className="lab-mobile-directory" aria-label="车辆目录">
|
||||
{records.map((vehicle) => (
|
||||
<li key={vehicle.vin}>
|
||||
<button
|
||||
type="button"
|
||||
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
|
||||
aria-pressed={vehicle.vin === selectedVin}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
>
|
||||
<span className={`lab-status-dot is-${vehicle.status}`} aria-hidden="true" />
|
||||
<span className="lab-mobile-identity">
|
||||
<strong>{vehicle.plate}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
<span className="lab-protocol-list">
|
||||
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lab-mobile-state">
|
||||
<StatusTag status={vehicle.status} />
|
||||
<small>{vehicle.updatedAt} 更新</small>
|
||||
</span>
|
||||
<IconChevronRight className="lab-mobile-row-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleInspector({
|
||||
vehicle,
|
||||
mobile,
|
||||
sheetOffset,
|
||||
onClose,
|
||||
onCopyVin,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragEnd
|
||||
}: {
|
||||
vehicle: VehicleRecord;
|
||||
mobile?: boolean;
|
||||
sheetOffset?: number;
|
||||
onClose: () => void;
|
||||
onCopyVin: () => void;
|
||||
onDragStart?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
onDragMove?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
onDragEnd?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const style = mobile
|
||||
? ({ '--lab-sheet-offset': `${sheetOffset ?? 0}px` } as CSSProperties)
|
||||
: undefined;
|
||||
return (
|
||||
<aside
|
||||
className={mobile ? 'lab-mobile-inspector' : 'lab-desktop-inspector'}
|
||||
aria-label={`${vehicle.plate} 车辆档案`}
|
||||
style={style}
|
||||
>
|
||||
{mobile ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lab-sheet-handle"
|
||||
aria-label="拖动或关闭车辆详情"
|
||||
onPointerDown={onDragStart}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={onDragEnd}
|
||||
onPointerCancel={onDragEnd}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
) : null}
|
||||
<header>
|
||||
<span>
|
||||
<small>{mobile ? '车辆详情' : '车辆档案'}</small>
|
||||
<strong>{vehicle.plate}</strong>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} aria-label="关闭车辆档案">
|
||||
<IconClose aria-hidden="true" />
|
||||
{mobile ? <span>关闭</span> : null}
|
||||
</button>
|
||||
</header>
|
||||
<div className="lab-inspector-status">
|
||||
<StatusTag status={vehicle.status} />
|
||||
<small>最近更新 {vehicle.updatedAt}</small>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>VIN</dt>
|
||||
<dd>
|
||||
<span>{vehicle.vin}</span>
|
||||
<button type="button" onClick={onCopyVin} aria-label="复制 VIN"><IconCopy aria-hidden="true" /></button>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>品牌 / 车型</dt><dd>{vehicle.brand}</dd></div>
|
||||
<div><dt>终端编号</dt><dd>{vehicle.terminal}</dd></div>
|
||||
<div><dt>业务负责人</dt><dd>{vehicle.owner}</dd></div>
|
||||
<div><dt>所属部门</dt><dd>{vehicle.department}</dd></div>
|
||||
<div><dt>授权状态</dt><dd>已授权</dd></div>
|
||||
</dl>
|
||||
<section>
|
||||
<strong>协议来源</strong>
|
||||
{vehicle.protocols.map((protocol) => (
|
||||
<div className="lab-inspector-protocol" key={protocol}>
|
||||
<span className="lab-protocol-dot" aria-hidden="true" />
|
||||
<span>
|
||||
<b>{protocol}</b>
|
||||
<small>最后上报 {vehicle.updatedAt}:12</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<button type="button" className="lab-inspector-primary">查看完整档案<IconChevronRight aria-hidden="true" /></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileFilterSheet({
|
||||
view,
|
||||
status,
|
||||
department,
|
||||
onViewChange,
|
||||
onStatusChange,
|
||||
onDepartmentChange,
|
||||
onReset,
|
||||
onClose
|
||||
}: {
|
||||
view: DirectoryView;
|
||||
status: string;
|
||||
department: string;
|
||||
onViewChange: (view: DirectoryView) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onDepartmentChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="lab-sheet-backdrop" role="presentation" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}>
|
||||
<section className="lab-filter-sheet" role="dialog" aria-modal="true" aria-labelledby="lab-filter-title">
|
||||
<header>
|
||||
<span><strong id="lab-filter-title">筛选车辆</strong><small>范围、状态与业务归属</small></span>
|
||||
<button type="button" onClick={onClose} aria-label="关闭筛选"><IconClose aria-hidden="true" /></button>
|
||||
</header>
|
||||
<fieldset>
|
||||
<legend>车辆范围</legend>
|
||||
<div className="lab-filter-options">
|
||||
{viewOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={view === option.key ? 'is-active' : ''}
|
||||
onClick={() => onViewChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label>
|
||||
<span>在线状态</span>
|
||||
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>所属部门</span>
|
||||
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
|
||||
<option value="">全部部门</option>
|
||||
<option>华南运营部</option>
|
||||
<option>西南交付部</option>
|
||||
<option>中原运营部</option>
|
||||
</select>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" onClick={onReset}>重置</button>
|
||||
<button type="button" className="is-primary" onClick={onClose}>查看结果</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavigation() {
|
||||
const items = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
|
||||
{ key: 'alerts', label: '更多', icon: IconMore }
|
||||
];
|
||||
return (
|
||||
<nav className="lab-bottom-nav" aria-label="移动端主导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={item.active ? 'is-active' : ''}
|
||||
key={item.label}
|
||||
onClick={() => item.key !== 'vehicles' && (window.location.href = `/platform-design-lab.html#${item.key}`)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function VehicleDesignLab() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
|
||||
const [view, setView] = useState<DirectoryView>('all');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [customer, setCustomer] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedVin, setSelectedVin] = useState(vehicles[0].vin);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [sheetOffset, setSheetOffset] = useState(0);
|
||||
const dragStartRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const filteredVehicles = useMemo(() => vehicles.filter((vehicle) => {
|
||||
const matchesKeyword = !deferredKeyword || [
|
||||
vehicle.plate,
|
||||
vehicle.vin,
|
||||
vehicle.brand,
|
||||
vehicle.terminal
|
||||
].some((value) => value.toLowerCase().includes(deferredKeyword));
|
||||
const matchesView = view === 'all'
|
||||
|| view === 'online' && vehicle.status === 'online'
|
||||
|| view === 'offline' && vehicle.status === 'offline'
|
||||
|| view === 'multi' && vehicle.protocols.length > 1;
|
||||
return matchesKeyword
|
||||
&& matchesView
|
||||
&& (!department || vehicle.department === department)
|
||||
&& (!owner || vehicle.owner === owner)
|
||||
&& (!customer || vehicle.customer === customer)
|
||||
&& (!status || vehicle.status === status);
|
||||
}), [customer, deferredKeyword, department, owner, status, view]);
|
||||
|
||||
const selectedVehicle = vehicles.find((vehicle) => vehicle.vin === selectedVin);
|
||||
const activeFilterCount = [view !== 'all', !!department, !!owner, !!customer, !!status].filter(Boolean).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(''), 2400);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterOpen) return;
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setFilterOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [filterOpen]);
|
||||
|
||||
const submitSearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setNotice(`已找到 ${filteredVehicles.length} 辆车辆`);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setView('all');
|
||||
setDepartment('');
|
||||
setOwner('');
|
||||
setCustomer('');
|
||||
setStatus('');
|
||||
};
|
||||
|
||||
const copySelectedVin = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedVehicle.vin);
|
||||
setNotice('VIN 已复制');
|
||||
} catch {
|
||||
setNotice('当前浏览器未开放剪贴板权限');
|
||||
}
|
||||
};
|
||||
|
||||
const onDragStart = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
dragStartRef.current = event.clientY;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onDragMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (dragStartRef.current === undefined) return;
|
||||
setSheetOffset(Math.max(0, event.clientY - dragStartRef.current));
|
||||
};
|
||||
|
||||
const onDragEnd = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
dragStartRef.current = undefined;
|
||||
if (sheetOffset > 110) setSelectedVin('');
|
||||
setSheetOffset(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`vehicle-design-lab${selectedVehicle ? ' has-inspector' : ''}`}>
|
||||
<Sidebar />
|
||||
<div className="lab-stage">
|
||||
<TopBar onHelp={() => setNotice('帮助:搜索车辆后选择一行查看档案')} />
|
||||
<main>
|
||||
<CommandSurface
|
||||
keyword={keyword}
|
||||
view={view}
|
||||
department={department}
|
||||
owner={owner}
|
||||
customer={customer}
|
||||
status={status}
|
||||
onKeywordChange={setKeyword}
|
||||
onViewChange={setView}
|
||||
onDepartmentChange={setDepartment}
|
||||
onOwnerChange={setOwner}
|
||||
onCustomerChange={setCustomer}
|
||||
onStatusChange={setStatus}
|
||||
onSubmit={submitSearch}
|
||||
/>
|
||||
<MobileCommandSurface
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onOpenFilters={() => setFilterOpen(true)}
|
||||
onBatch={() => setNotice('已创建 4 辆车辆的主档同步预览')}
|
||||
/>
|
||||
<MetricRail records={vehicles} />
|
||||
<div className="lab-directory-layout">
|
||||
<section className="lab-directory-surface" aria-label="授权车辆目录">
|
||||
<header className="lab-directory-header">
|
||||
<span>
|
||||
<strong>授权车辆目录</strong>
|
||||
<small>按照最新上报时间排序</small>
|
||||
</span>
|
||||
<em>{filteredVehicles.length} 辆</em>
|
||||
</header>
|
||||
{filteredVehicles.length > 0 ? (
|
||||
<>
|
||||
<DesktopDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
|
||||
<MobileDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
|
||||
</>
|
||||
) : (
|
||||
<div className="lab-empty-state">
|
||||
<IconSearch aria-hidden="true" />
|
||||
<strong>没有匹配车辆</strong>
|
||||
<span>调整关键词或清除筛选后重试。</span>
|
||||
<button type="button" onClick={resetFilters}>清除筛选</button>
|
||||
</div>
|
||||
)}
|
||||
<footer className="lab-directory-footer">
|
||||
<span>共 {filteredVehicles.length} 辆 · 本页 {filteredVehicles.length} 辆</span>
|
||||
<div>
|
||||
<button type="button" disabled aria-label="上一页"><IconChevronLeft aria-hidden="true" /></button>
|
||||
<span>1 / 1</span>
|
||||
<button type="button" disabled aria-label="下一页"><IconChevronRight aria-hidden="true" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
{selectedVehicle ? (
|
||||
<VehicleInspector
|
||||
vehicle={selectedVehicle}
|
||||
onClose={() => setSelectedVin('')}
|
||||
onCopyVin={copySelectedVin}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
<BottomNavigation />
|
||||
{selectedVehicle ? (
|
||||
<VehicleInspector
|
||||
mobile
|
||||
vehicle={selectedVehicle}
|
||||
sheetOffset={sheetOffset}
|
||||
onClose={() => setSelectedVin('')}
|
||||
onCopyVin={copySelectedVin}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
) : null}
|
||||
{filterOpen ? (
|
||||
<MobileFilterSheet
|
||||
view={view}
|
||||
status={status}
|
||||
department={department}
|
||||
onViewChange={setView}
|
||||
onStatusChange={setStatus}
|
||||
onDepartmentChange={setDepartment}
|
||||
onReset={resetFilters}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <div className="lab-notice" role="status">{notice}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
10
vehicle-data-platform/apps/web/src/design-lab/main.tsx
Normal file
10
vehicle-data-platform/apps/web/src/design-lab/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { VehicleDesignLab } from './VehicleDesignLab';
|
||||
import './vehicle-design-lab.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('vehicle-design-lab-root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<VehicleDesignLab />
|
||||
</React.StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user