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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user