feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View File

@@ -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:
}
}
}

View 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
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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"

View 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)
}

View File

@@ -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 != "" {

View File

@@ -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)
}
}

View File

@@ -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"},

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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"`

View File

@@ -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())
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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 &lt;32位appKey&gt;
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 &gt; YUTONG_MQTT &gt; 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 &gt; YUTONG_MQTT &gt; 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>

View File

@@ -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: 当日所选协议最后有效累计总里程kmstatus=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

View File

@@ -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"
});
});

View File

@@ -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>

View 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)
}
}

View File

@@ -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)
}
}
}

View 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",
},
}
}

View 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"`
}

View 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(&timestampMS, &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])
}

View File

@@ -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)
}
}

View 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(&currentHash); 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
}

View File

@@ -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)
}
}

View 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
}

View File

@@ -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())
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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 = &copy
}
}
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 = &copy
}
}
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), "'", "''")
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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, &currentPhone)
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 {

View File

@@ -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 {

View File

@@ -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: "长时间静止须使用 010 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{}

View File

@@ -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 ""
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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(&current)
var archivedAt sql.NullTime
err = tx.QueryRowContext(ctx, `SELECT version,archived_at FROM vehicle_alert_rule WHERE id=? FOR UPDATE`, input.ID).Scan(&current, &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
}

View File

@@ -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])

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 &copy
}
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:
}
}
}()
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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{}

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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(&currentStatus, &currentVersion); 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(&currentStatus, &currentVersion, &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(&currentStatus, &currentVersion, &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(&currentStatus, &currentVersion, &currentlyArchived); 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

View File

@@ -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)
}
}

View File

@@ -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}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}