feat: sync vehicle monitoring and accumulated platform updates
Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
@@ -5,10 +5,12 @@ import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/config"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
@@ -27,12 +29,14 @@ type tokenPrincipal struct {
|
||||
}
|
||||
|
||||
type apiAuthenticator struct {
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
adapters []IdentityAdapter
|
||||
mode string
|
||||
tokens []tokenPrincipal
|
||||
local *authStore
|
||||
profile *oneOSProfileAuth
|
||||
oneOS *oneOSIdentityAdapter
|
||||
demo *demoAuthDirectory
|
||||
localBackend bool
|
||||
adapters []IdentityAdapter
|
||||
}
|
||||
|
||||
func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler {
|
||||
@@ -62,14 +66,32 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oneOS, err := newOneOSIdentityAdapter(cfg)
|
||||
legacyConfig := cfg
|
||||
if cfg.OneOSAuthEnabled {
|
||||
legacyConfig.OneOSSSOEnabled = false
|
||||
}
|
||||
oneOS, err := newOneOSIdentityAdapter(legacyConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if oneOS != nil && local == nil {
|
||||
return nil, fmt.Errorf("ONEOS_SSO_ENABLED requires MYSQL_DSN and the platform identity schema")
|
||||
}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS}
|
||||
authenticator := &apiAuthenticator{mode: mode, local: local, oneOS: oneOS, localBackend: cfg.OneOSLocalBackend}
|
||||
if cfg.OneOSAuthEnabled {
|
||||
localMock := (cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) && strings.EqualFold(cfg.DataMode, "mock")
|
||||
if mode != "enforce" || (local == nil && !localMock) {
|
||||
return nil, fmt.Errorf("OneOS auth requires AUTH_MODE=enforce and identity DB or explicit isolated local mock")
|
||||
}
|
||||
if !validOneOSOrigin(cfg.OneOSPublicOrigin, cfg.OneOSLocalOnly || cfg.OneOSDevIntegration) {
|
||||
return nil, fmt.Errorf("ONEOS_PUBLIC_ORIGIN must be a trusted HTTPS origin or explicit local loopback origin")
|
||||
}
|
||||
client, e := newOneOSProfileClient(cfg)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
authenticator.profile = &oneOSProfileAuth{localBackend: cfg.OneOSLocalBackend, vehicleQueryScope: cfg.OneOSVehicleQueryScope, devIntegration: cfg.OneOSDevIntegration, devDataProxy: cfg.OneOSDevDataProxyURL != "", client: client, store: local, origin: strings.TrimRight(cfg.OneOSPublicOrigin, "/"), issuer: cfg.OneOSProfileIssuer, tenant: cfg.OneOSProfileAllowedTenant, passwordEnabled: cfg.OneOSPasswordEnabled, scopeEnabled: cfg.OneOSVehicleScopePath != "" || cfg.OneOSVehicleQueryScope, localOnly: localMock, ssoEnabled: localMock && cfg.OneOSLoginTicketExchangePath != ""}
|
||||
}
|
||||
if mode == "disabled" && local == nil && strings.EqualFold(strings.TrimSpace(cfg.DataMode), "mock") {
|
||||
authenticator.demo = newDemoAuthDirectory()
|
||||
}
|
||||
@@ -101,7 +123,7 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
principal := platform.Principal{Name: item.Name, Username: item.Name, Role: item.Role, UserType: item.Role, AuthProvider: "legacy-token", MenuKeys: append([]string(nil), adminMenus...)}
|
||||
authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: principal})
|
||||
}
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 {
|
||||
if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && authenticator.profile == nil && len(authenticator.adapters) == 0 {
|
||||
return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON")
|
||||
}
|
||||
return authenticator, nil
|
||||
@@ -109,6 +131,18 @@ func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, erro
|
||||
|
||||
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v2/auth/providers" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"oneos": map[string]bool{"enabled": a.profile != nil, "devIntegration": a.profile != nil && a.profile.devIntegration, "realDataEnabled": a.profile != nil && (a.profile.devDataProxy || a.profile.localBackend), "passwordEnabled": a.profile != nil && a.profile.passwordEnabled, "authorizationReady": a.profile != nil && a.profile.scopeEnabled, "ssoEnabled": a.profile != nil && a.profile.ssoEnabled}, "local": map[string]bool{"enabled": a.local != nil}})
|
||||
return
|
||||
}
|
||||
if a.profile != nil && a.profile.handle(w, r) {
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/auth/oneos/exchange" {
|
||||
if r.Method != http.MethodPost {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "OneOS 登录兑换接口仅支持 POST", "", requestTraceID(r))
|
||||
@@ -133,13 +167,79 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
a.local.login(w, r)
|
||||
return
|
||||
}
|
||||
// This route resolves the caller's own provider; request parameters never select it.
|
||||
if r.URL.Path == "/api/v2/auth/vehicle-scope" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 405, "METHOD_NOT_ALLOWED")
|
||||
return
|
||||
}
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" && a.profile.vehicleQueryScope {
|
||||
token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID")
|
||||
current, err := a.profile.client.CurrentUser(r.Context(), token, clientID)
|
||||
if err != nil || !a.profile.validProfile(current.Profile) {
|
||||
oneOSAuthError(w, r, 401, "AUTH_REQUIRED")
|
||||
return
|
||||
}
|
||||
p, _, err := a.profile.resolveVehicleQueryScope(r.Context(), token, clientID, current)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, 503, "VEHICLE_SCOPE_UNAVAILABLE", "车辆权限暂时加载失败,请重试", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{p, a.mode})
|
||||
return
|
||||
}
|
||||
p, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
oneOSAuthError(w, r, 401, "AUTH_REQUIRED")
|
||||
return
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
platform.Principal
|
||||
AuthMode string `json:"authMode"`
|
||||
}{p, a.mode})
|
||||
return
|
||||
}
|
||||
principal, ok := a.authenticate(r)
|
||||
if !ok {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" {
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
limited := principal.AuthorizationStatus == "pending" || (a.profile.devIntegration && principal.AuthorizationStatus == "asset-catalog")
|
||||
identityRoute := (r.Method == http.MethodGet && (r.URL.Path == "/api/v2/session" || r.URL.Path == "/api/v2/auth/oneos/me")) || (r.Method == http.MethodPost && r.URL.Path == "/api/v2/auth/logout")
|
||||
catalogRoute := principal.AuthorizationStatus != "pending" && r.Method == http.MethodGet && r.URL.Path == "/api/v2/auth/oneos/vehicles" && (principal.AuthorizationStatus == "asset-catalog" || (a.profile.vehicleQueryScope || a.profile.devDataProxy && principal.Role == "admin"))
|
||||
action := oneOSReadAction(r)
|
||||
admin := oneOSHasAdminPermission(principal.PermissionKeys) && principal.Role == "admin"
|
||||
if limited && admin && !identityRoute && !catalogRoute {
|
||||
httpx.WriteError(w, http.StatusServiceUnavailable, "ONEOS_DATA_UNAVAILABLE", "管理员权限已开通,本地开发环境尚未连接中台业务数据源", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if (limited && !identityRoute && !catalogRoute) || (!limited && !admin && !identityRoute && !catalogRoute && (action == "" || !oneOSPermissions(principal.PermissionKeys)[action])) {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_PERMISSION_DENIED")
|
||||
return
|
||||
}
|
||||
if catalogRoute {
|
||||
page, err := a.profile.client.VehicleAssetPage(r.Context(), bearerToken(r), r.Header.Get("X-OneOS-Client-ID"), r.URL.Query())
|
||||
if err != nil {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if errors.Is(err, errOneOSAssetQuery) {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "ONEOS_CATALOG_QUERY_INVALID", "车辆目录查询参数无效,请检查筛选条件及分页范围", "", requestTraceID(r))
|
||||
} else {
|
||||
httpx.WriteError(w, http.StatusBadGateway, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 车辆目录暂不可用,请稍后重试", "", requestTraceID(r))
|
||||
}
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"records": page.Records, "total": page.Total, "current": page.Current, "size": page.Size, "telemetryAvailable": false, "historyAvailable": false, "source": "oneos-asset-catalog"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if principal.UserType == "customer" && r.URL.Path != "/api/v2/session" && r.URL.Path != "/api/v2/auth/logout" && r.URL.Path != "/api/v2/auth/password" && r.URL.Path != "/api/v2/auth/oneos/me" {
|
||||
menu := requiredMenu(r)
|
||||
allowed := principal.CanMenu(menu)
|
||||
if menu == "shared" {
|
||||
@@ -159,8 +259,45 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.localBackend {
|
||||
principal.DataSource = "production-local"
|
||||
}
|
||||
ctx := platform.WithPrincipal(r.Context(), principal)
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/customer-mileage") || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance") {
|
||||
snapshot := platform.CustomerAssignmentSnapshot{Tenant: principal.TenantRef}
|
||||
if principal.AuthProvider != "oneos" && a.profile != nil {
|
||||
snapshot.Tenant = a.profile.tenant
|
||||
}
|
||||
if principal.AuthProvider == "oneos" && (r.URL.Path == "/api/v2/customer-mileage" || strings.HasPrefix(r.URL.Path, "/api/v2/operation-compliance")) && r.Method == http.MethodGet && a.profile != nil && a.profile.vehicleQueryScope {
|
||||
token, clientID := bearerToken(r), r.Header.Get("X-OneOS-Client-ID")
|
||||
rows, err := a.profile.vehicleQuerySnapshot(ctx, token, clientID)
|
||||
if err != nil {
|
||||
httpx.WriteError(w, 503, "ONEOS_CATALOG_UNAVAILABLE", "OneOS 客户车辆归属暂不可用,请重试", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
snapshot.Live, snapshot.ObservedAt = true, time.Now()
|
||||
key := sha256.Sum256([]byte(clientID + "\x00" + token))
|
||||
a.profile.scopeMu.Lock()
|
||||
if entry := a.profile.queryScopes[key]; entry != nil && !entry.expires.IsZero() {
|
||||
snapshot.ObservedAt = entry.expires.Add(-oneOSQueryScopeTTL)
|
||||
}
|
||||
a.profile.scopeMu.Unlock()
|
||||
for _, row := range rows {
|
||||
snapshot.Rows = append(snapshot.Rows, platform.CustomerVehicleAssignment{VIN: row.VIN, Plate: row.PlateNumber, Customer: row.CustomerName, Department: row.BusinessDepartment, ResponsiblePerson: row.BusinessResponsiblePerson})
|
||||
}
|
||||
}
|
||||
ctx = platform.WithCustomerAssignments(ctx, snapshot)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
if r.URL.Path == "/api/v2/auth/oneos/me" {
|
||||
if a.profile == nil || principal.AuthProvider != "oneos" || r.Method != http.MethodGet {
|
||||
oneOSAuthError(w, r, 403, "ONEOS_REQUIRED")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]any{"username": principal.Username, "displayName": principal.Name, "tenantId": principal.TenantRef, "menuKeys": principal.MenuKeys, "authorizationStatus": principal.AuthorizationStatus})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/v2/session" {
|
||||
principal.VehicleCount = len(principal.VehicleVINs)
|
||||
httpx.WriteOK(w, requestTraceID(r), struct {
|
||||
@@ -174,7 +311,7 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
|
||||
httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos") {
|
||||
if a.local != nil && (principal.AuthProvider == "local" || principal.AuthProvider == "oneos" || principal.AuthProvider == "oneos-profile") {
|
||||
a.local.logout(r.Context(), bearerToken(r))
|
||||
}
|
||||
httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true})
|
||||
@@ -206,8 +343,14 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
|
||||
if token == "" {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
if a.profile != nil && r.Header.Get("X-OneOS-Client-ID") != "" {
|
||||
return a.profile.authenticate(r, token)
|
||||
}
|
||||
if a.local != nil {
|
||||
if principal, ok := a.local.authenticate(r.Context(), token); ok {
|
||||
if principal.AuthProvider == "oneos-profile" || (a.profile != nil && principal.AuthProvider == "oneos") {
|
||||
return platform.Principal{}, false
|
||||
}
|
||||
return principal, true
|
||||
}
|
||||
}
|
||||
@@ -245,13 +388,17 @@ func bearerToken(r *http.Request) string {
|
||||
func requiredMenu(r *http.Request) string {
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v2/operation-compliance"):
|
||||
return "operation-compliance"
|
||||
case strings.HasPrefix(path, "/api/v2/customer-mileage"):
|
||||
return "customer-mileage"
|
||||
case strings.HasPrefix(path, "/api/v2/monitor"), path == "/api/v2/alerts/events":
|
||||
return "monitor"
|
||||
case path == "/api/map/reverse-geocode", path == "/api/realtime/vehicles", path == "/api/realtime/locations", path == "/api/vehicle-service", path == "/api/vehicle-service/overview", path == "/api/vehicle-service/overviews", strings.HasSuffix(path, "/telemetry/latest"):
|
||||
return "shared"
|
||||
case path == "/api/v2/tracks":
|
||||
return "tracks"
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage":
|
||||
case path == "/api/mileage/daily", path == "/api/mileage/summary", path == "/api/v2/statistics/mileage", path == "/api/v2/statistics/vehicles", path == "/api/v2/statistics/vehicle-options", path == "/api/v2/statistics/vehicle-resolve":
|
||||
return "statistics"
|
||||
case path == "/api/vehicles", path == "/api/vehicles/resolve", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary", path == "/api/vehicles/business-filters":
|
||||
return "shared"
|
||||
@@ -263,6 +410,9 @@ func requiredMenu(r *http.Request) string {
|
||||
}
|
||||
|
||||
func requiredRole(r *http.Request) string {
|
||||
if r.URL.Path == "/api/v2/customer-mileage/rules" && r.Method != http.MethodGet {
|
||||
return "admin"
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/v2/open-platform/") {
|
||||
return "admin"
|
||||
}
|
||||
@@ -293,7 +443,7 @@ func requiredRole(r *http.Request) string {
|
||||
switch path {
|
||||
case "/api/v2/auth/logout":
|
||||
return "viewer"
|
||||
case "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
|
||||
case "/api/v2/statistics/vehicle-resolve", "/api/vehicle-service/overviews", "/api/history/raw-frames/query", "/api/mileage/daily", "/api/v2/statistics/mileage", "/api/v2/access/summary", "/api/v2/access/vehicles", "/api/v2/alerts/summary", "/api/v2/alerts/events", "/api/v2/exports":
|
||||
return "viewer"
|
||||
case "/api/v2/alerts/notifications/read":
|
||||
return "operator"
|
||||
|
||||
Reference in New Issue
Block a user