From a1195fb97d4a82a8c7abb95f274d4d0b6720a74a Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 13:58:28 +0800 Subject: [PATCH] feat: add customer authentication and scoped RBAC --- vehicle-data-platform/apps/api/go.mod | 1 + vehicle-data-platform/apps/api/go.sum | 2 + .../apps/api/internal/app/auth.go | 137 +++- .../apps/api/internal/app/auth_store.go | 747 ++++++++++++++++++ .../apps/api/internal/app/identity_adapter.go | 20 + .../apps/api/internal/app/server.go | 4 +- .../apps/api/internal/config/config.go | 6 + .../apps/api/internal/platform/alert.go | 13 + .../apps/api/internal/platform/handler.go | 2 + .../api/internal/platform/mysql_queries.go | 9 + .../apps/api/internal/platform/principal.go | 45 +- .../apps/api/internal/platform/profile.go | 3 + .../apps/api/internal/platform/service.go | 105 ++- .../api/internal/platform/service_test.go | 21 + .../apps/web/src/api/client.ts | 17 + .../apps/web/src/api/types.ts | 44 +- .../apps/web/src/v2/AppV2.tsx | 39 +- .../apps/web/src/v2/auth/AuthGate.test.tsx | 20 +- .../apps/web/src/v2/auth/AuthGate.tsx | 69 +- .../apps/web/src/v2/auth/session.ts | 15 +- .../apps/web/src/v2/layout/AppShell.test.tsx | 21 +- .../apps/web/src/v2/layout/AppShell.tsx | 85 +- .../apps/web/src/v2/pages/UsersPage.tsx | 137 ++++ .../apps/web/src/v2/routing/routeModules.ts | 14 +- .../apps/web/src/v2/styles/v2.css | 99 ++- .../013_platform_identity_access.sql | 79 ++ .../docs/authentication-authorization.md | 75 ++ vehicle-data-platform/docs/deployment.md | 6 +- 28 files changed, 1738 insertions(+), 97 deletions(-) create mode 100644 vehicle-data-platform/apps/api/internal/app/auth_store.go create mode 100644 vehicle-data-platform/apps/api/internal/app/identity_adapter.go create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx create mode 100644 vehicle-data-platform/deploy/migrations/013_platform_identity_access.sql create mode 100644 vehicle-data-platform/docs/authentication-authorization.md diff --git a/vehicle-data-platform/apps/api/go.mod b/vehicle-data-platform/apps/api/go.mod index 23c3afa9..4218a1a8 100644 --- a/vehicle-data-platform/apps/api/go.mod +++ b/vehicle-data-platform/apps/api/go.mod @@ -18,4 +18,5 @@ require ( 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 ) diff --git a/vehicle-data-platform/apps/api/go.sum b/vehicle-data-platform/apps/api/go.sum index 1718857b..972db51b 100644 --- a/vehicle-data-platform/apps/api/go.sum +++ b/vehicle-data-platform/apps/api/go.sum @@ -38,6 +38,8 @@ 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= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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= diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index dd5712ba..45f8ef27 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -3,6 +3,7 @@ package app import ( "crypto/sha256" "crypto/subtle" + "database/sql" "encoding/json" "fmt" "log" @@ -26,12 +27,18 @@ type tokenPrincipal struct { } type apiAuthenticator struct { - mode string - tokens []tokenPrincipal + mode string + tokens []tokenPrincipal + local *authStore + adapters []IdentityAdapter } -func withAPIAuth(next http.Handler, cfg config.Config) http.Handler { - authenticator, err := newAPIAuthenticator(cfg) +func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler { + var db *sql.DB + if len(databases) > 0 { + db = databases[0] + } + authenticator, err := newAPIAuthenticator(cfg, db) if err != nil { log.Printf("platform API authentication misconfigured: %v", err) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -41,7 +48,7 @@ func withAPIAuth(next http.Handler, cfg config.Config) http.Handler { return authenticator.middleware(next) } -func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) { +func newAPIAuthenticator(cfg config.Config, db *sql.DB) (*apiAuthenticator, error) { mode := strings.ToLower(strings.TrimSpace(cfg.AuthMode)) if mode == "" { mode = "disabled" @@ -49,7 +56,11 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) { if mode != "disabled" && mode != "enforce" { return nil, fmt.Errorf("AUTH_MODE must be disabled or enforce") } - authenticator := &apiAuthenticator{mode: mode} + local, err := newAuthStore(db, cfg) + if err != nil { + return nil, err + } + authenticator := &apiAuthenticator{mode: mode, local: local} configured := []configuredPrincipal{} if strings.TrimSpace(cfg.AuthTokensJSON) != "" { if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil { @@ -75,22 +86,46 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) { return nil, fmt.Errorf("duplicate authentication token") } seen[hash] = true - authenticator.tokens = append(authenticator.tokens, tokenPrincipal{hash: hash, principal: platform.Principal{Name: item.Name, Role: item.Role}}) + 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 { - return nil, fmt.Errorf("enforce mode requires AUTH_TOKEN or AUTH_TOKENS_JSON") + if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == 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 } 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/login" { + if a.local == nil { + httpx.WriteError(w, http.StatusServiceUnavailable, "LOCAL_AUTH_UNAVAILABLE", "账号登录尚未启用", "", requestTraceID(r)) + return + } + if r.Method != http.MethodPost { + httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "登录接口仅支持 POST", "", requestTraceID(r)) + return + } + a.local.login(w, r) + 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" { + menu := requiredMenu(r) + allowed := principal.CanMenu(menu) + if menu == "shared" { + allowed = principal.CanMenu("monitor") || principal.CanMenu("vehicles") || principal.CanMenu("tracks") || principal.CanMenu("statistics") + } + if menu == "" || !allowed { + httpx.WriteError(w, http.StatusForbidden, "MENU_PERMISSION_DENIED", "当前账号无权访问此功能", menu, requestTraceID(r)) + return + } + } required := requiredRole(r) if roleRank(principal.Role) < roleRank(required) { httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r)) @@ -99,11 +134,33 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { ctx := platform.WithPrincipal(r.Context(), principal) r = r.WithContext(ctx) if r.URL.Path == "/api/v2/session" { + principal.VehicleCount = len(principal.VehicleVINs) httpx.WriteOK(w, requestTraceID(r), struct { - Name string `json:"name"` - Role string `json:"role"` + platform.Principal AuthMode string `json:"authMode"` - }{principal.Name, principal.Role, a.mode}) + }{principal, a.mode}) + return + } + if r.URL.Path == "/api/v2/auth/logout" { + if r.Method != http.MethodPost { + httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "退出接口仅支持 POST", "", requestTraceID(r)) + return + } + if a.local != nil && principal.AuthProvider == "local" { + a.local.logout(r.Context(), bearerToken(r)) + } + httpx.WriteOK(w, requestTraceID(r), map[string]bool{"loggedOut": true}) + return + } + if r.URL.Path == "/api/v2/auth/password" { + if r.Method != http.MethodPut || a.local == nil { + httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "密码修改接口仅支持 PUT", "", requestTraceID(r)) + return + } + a.local.changePassword(w, r, principal) + return + } + if a.local != nil && a.local.handleAdmin(w, r, principal) { return } next.ServeHTTP(w, r) @@ -112,16 +169,27 @@ func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bool) { if a.mode == "disabled" { - return platform.Principal{Name: "local-developer", Role: "admin"}, true + return platform.Principal{Name: "local-developer", Username: "local-developer", Role: "admin", UserType: "admin", AuthProvider: "disabled", MenuKeys: append([]string(nil), adminMenus...)}, true } - header := strings.TrimSpace(r.Header.Get("Authorization")) - if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") { - return platform.Principal{}, false - } - token := strings.TrimSpace(header[7:]) + token := bearerToken(r) if token == "" { return platform.Principal{}, false } + if a.local != nil { + if principal, ok := a.local.authenticate(r.Context(), token); ok { + return principal, true + } + } + for _, adapter := range a.adapters { + principal, matched, err := adapter.AuthenticateBearer(r.Context(), token) + if err != nil { + log.Printf("identity adapter %s rejected credential: %v", adapter.Name(), err) + continue + } + if matched { + return principal, true + } + } hash := sha256.Sum256([]byte(token)) var match platform.Principal matched := 0 @@ -135,6 +203,34 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo return match, matched == 1 } +func bearerToken(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 requiredMenu(r *http.Request) string { + path := r.URL.Path + switch { + 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", 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", path == "/api/vehicles/coverage", path == "/api/vehicles/coverage/summary": + return "statistics" + case path == "/api/vehicles", path == "/api/vehicles/resolve": + return "shared" + case strings.HasPrefix(path, "/api/v2/vehicles/"): + return "vehicles" + default: + return "" + } +} + func requiredRole(r *http.Request) string { if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") { return "operator" @@ -160,12 +256,15 @@ func requiredRole(r *http.Request) string { if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) { return "admin" } + if r.Method == http.MethodPut && path == "/api/v2/auth/password" { + return "viewer" + } return "admin" } func roleRank(role string) int { switch strings.ToLower(role) { - case "viewer": + case "viewer", "customer": return 1 case "operator": return 2 diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store.go b/vehicle-data-platform/apps/api/internal/app/auth_store.go new file mode 100644 index 00000000..f7b44f98 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -0,0 +1,747 @@ +package app + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + + "lingniu/vehicle-data-platform/apps/api/internal/config" + "lingniu/vehicle-data-platform/apps/api/internal/httpx" + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +const ( + localSessionCacheTTL = 30 * time.Second + loginLockDuration = 15 * time.Minute + maxLoginFailures = 5 +) + +var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$`) + +var customerMenuSet = map[string]bool{ + "monitor": true, "vehicles": true, "tracks": true, "statistics": true, +} + +var adminMenus = []string{"monitor", "vehicles", "tracks", "history", "statistics", "alerts", "access", "operations", "users"} + +type authUser struct { + ID uint64 `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + UserType string `json:"userType"` + Status string `json:"status"` + CustomerRef string `json:"customerRef"` + TenantRef string `json:"tenantRef"` + AuthProvider string `json:"authProvider"` + ExternalSubject string `json:"externalSubject,omitempty"` + MenuKeys []string `json:"menuKeys"` + VehicleVINs []string `json:"vehicleVins"` + LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type localCredential struct { + authUser + PasswordHash string + FailedLoginCount int + LockedUntil sql.NullTime +} + +type userMutation struct { + Username string `json:"username"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Status string `json:"status"` + CustomerRef string `json:"customerRef"` + TenantRef string `json:"tenantRef"` + MenuKeys []string `json:"menuKeys"` + VehicleVINs []string `json:"vehicleVins"` +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type passwordChangeRequest struct { + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + +type loginResponse struct { + AccessToken string `json:"accessToken"` + ExpiresAt time.Time `json:"expiresAt"` + Session platform.Principal `json:"session"` +} + +type cachedSession struct { + principal platform.Principal + expiresAt time.Time + cachedAt time.Time +} + +type authStore struct { + db *sql.DB + sessionTTL time.Duration + cacheMu sync.RWMutex + cache map[string]cachedSession +} + +func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) { + if db == nil { + return nil, nil + } + ttl := cfg.SessionTTL + if ttl <= 0 { + ttl = 12 * time.Hour + } + store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}} + if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil { + return nil, err + } + return store, nil +} + +func (s *authStore) ensureBootstrapAdmin(ctx context.Context, username, password string) error { + username = strings.TrimSpace(username) + if username == "" && password == "" { + return nil + } + if !usernamePattern.MatchString(username) { + return fmt.Errorf("BOOTSTRAP_ADMIN_USERNAME is invalid") + } + if err := validatePassword(password); err != nil { + return fmt.Errorf("BOOTSTRAP_ADMIN_PASSWORD: %w", err) + } + var exists int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM platform_user WHERE user_type='admin'`).Scan(&exists); err != nil { + return fmt.Errorf("check bootstrap admin: %w", err) + } + if exists > 0 { + return nil + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) + if err != nil { + return fmt.Errorf("hash bootstrap password: %w", err) + } + _, err = s.db.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,auth_provider,created_by,updated_by) VALUES(?,?,?,'admin','enabled','local','bootstrap','bootstrap')`, username, "平台管理员", string(hash)) + if err != nil { + return fmt.Errorf("create bootstrap admin: %w", err) + } + return nil +} + +func (s *authStore) login(w http.ResponseWriter, r *http.Request) { + var input loginRequest + if !decodeAuthJSON(w, r, &input) { + return + } + input.Username = strings.TrimSpace(input.Username) + if !usernamePattern.MatchString(input.Username) || len(input.Password) > 128 { + s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r)) + httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) + return + } + credential, err := s.localCredential(r.Context(), input.Username) + if err != nil { + if !errors.Is(err, sql.ErrNoRows) { + httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r)) + return + } + s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r)) + httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) + return + } + now := time.Now() + if credential.Status != "enabled" { + s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "disabled"}, remoteAddress(r)) + httpx.WriteError(w, http.StatusForbidden, "ACCOUNT_DISABLED", "账号已停用,请联系管理员", "", requestTraceID(r)) + return + } + if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) { + httpx.WriteError(w, http.StatusTooManyRequests, "ACCOUNT_LOCKED", "登录失败次数过多,请稍后再试", credential.LockedUntil.Time.Format(time.RFC3339), requestTraceID(r)) + return + } + if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil { + failures := credential.FailedLoginCount + 1 + var locked any + if failures >= maxLoginFailures { + locked = now.Add(loginLockDuration) + } + _, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=?,locked_until=? WHERE id=?`, failures, locked, credential.ID) + s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddress(r)) + httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) + return + } + principal, err := s.principalForUser(r.Context(), credential.authUser) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r)) + return + } + 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)) + 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}) +} + +func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) { + hash := sha256.Sum256([]byte(token)) + key := hex.EncodeToString(hash[:]) + if principal, ok := s.cacheGet(key); ok { + return principal, true + } + var user authUser + var sessionID string + var expiresAt time.Time + var external sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.user_type,u.status,u.customer_ref,u.tenant_ref,u.auth_provider,u.external_subject,u.last_login_at,u.created_at,u.updated_at,s.id,s.expires_at FROM platform_user_session s JOIN platform_user u ON u.id=s.user_id WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status='enabled'`, hash[:]).Scan( + &user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &sessionID, &expiresAt, + ) + if err != nil { + return platform.Principal{}, false + } + user.ExternalSubject = external.String + principal, err := s.principalForUser(ctx, user) + if err != nil { + return platform.Principal{}, false + } + principal.SessionID = sessionID + s.cachePut(key, principal, expiresAt) + return principal, true +} + +func (s *authStore) logout(ctx context.Context, token string) { + hash := sha256.Sum256([]byte(token)) + _, _ = s.db.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:]) + s.cacheMu.Lock() + delete(s.cache, hex.EncodeToString(hash[:])) + s.cacheMu.Unlock() +} + +func (s *authStore) changePassword(w http.ResponseWriter, r *http.Request, principal platform.Principal) { + if principal.AuthProvider != "local" || principal.SubjectID == "" { + httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_CHANGE_UNAVAILABLE", "当前登录方式不支持修改密码", "", requestTraceID(r)) + return + } + var input passwordChangeRequest + if !decodeAuthJSON(w, r, &input) { + return + } + if err := validatePassword(input.NewPassword); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_INVALID", err.Error(), "", requestTraceID(r)) + return + } + id, _ := strconv.ParseUint(principal.SubjectID, 10, 64) + var currentHash string + if err := s.db.QueryRowContext(r.Context(), `SELECT password_hash FROM platform_user WHERE id=? AND status='enabled'`, id).Scan(¤tHash); err != nil || bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.CurrentPassword)) != nil { + s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "denied", map[string]any{"reason": "current_password_invalid"}, remoteAddress(r)) + httpx.WriteError(w, http.StatusUnauthorized, "CURRENT_PASSWORD_INVALID", "当前密码不正确", "", requestTraceID(r)) + return + } + if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.NewPassword)) == nil { + httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_UNCHANGED", "新密码不能与当前密码相同", "", requestTraceID(r)) + return + } + hash, _ := bcrypt.GenerateFromPassword([]byte(input.NewPassword), 12) + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r)) + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user SET password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL,updated_by=? WHERE id=?`, string(hash), principal.Name, id); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r)) + return + } + if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法撤销旧会话", "", requestTraceID(r)) + return + } + if err := tx.Commit(); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r)) + return + } + s.invalidateUser(id) + s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "success", nil, remoteAddress(r)) + httpx.WriteOK(w, requestTraceID(r), map[string]bool{"changed": true}) +} + +func (s *authStore) localCredential(ctx context.Context, username string) (localCredential, error) { + var credential localCredential + var external sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,failed_login_count,locked_until,last_login_at,created_at,updated_at FROM platform_user WHERE username=? AND auth_provider='local'`, username).Scan( + &credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash, &credential.UserType, &credential.Status, &credential.CustomerRef, &credential.TenantRef, &credential.AuthProvider, &external, &credential.FailedLoginCount, &credential.LockedUntil, &credential.LastLoginAt, &credential.CreatedAt, &credential.UpdatedAt, + ) + credential.ExternalSubject = external.String + return credential, err +} + +func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) { + menus := append([]string(nil), adminMenus...) + vehicles := []string{} + 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) + if err != nil { + return platform.Principal{}, err + } + for rows.Next() { + var value string + if err := rows.Scan(&value); err != nil { + rows.Close() + return platform.Principal{}, err + } + if customerMenuSet[value] { + menus = append(menus, value) + } + } + if err := rows.Close(); err != nil { + return platform.Principal{}, err + } + rows, err = s.db.QueryContext(ctx, `SELECT vin 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`, user.ID) + if err != nil { + return platform.Principal{}, err + } + for rows.Next() { + var vin string + if err := rows.Scan(&vin); err != nil { + rows.Close() + return platform.Principal{}, err + } + vehicles = append(vehicles, vin) + } + if err := rows.Close(); err != nil { + return platform.Principal{}, err + } + } + 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, + }, nil +} + +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 + } + 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": + s.listUsers(w, r) + 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/"): + s.updateCustomer(w, r, principal) + default: + httpx.WriteError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "不支持的账号管理操作", "", requestTraceID(r)) + } + return true +} + +func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.QueryContext(r.Context(), `SELECT id,username,display_name,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,last_login_at,created_at,updated_at FROM platform_user ORDER BY user_type ASC,created_at DESC`) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r)) + return + } + defer rows.Close() + users := []authUser{} + for rows.Next() { + var user authUser + var external sql.NullString + if err := rows.Scan(&user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r)) + return + } + user.ExternalSubject = external.String + principal, err := s.principalForUser(r.Context(), user) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号权限", "", requestTraceID(r)) + return + } + user.MenuKeys = principal.MenuKeys + user.VehicleVINs = principal.VehicleVINs + users = append(users, user) + } + httpx.WriteOK(w, requestTraceID(r), users) +} + +func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) { + var input userMutation + if !decodeAuthJSON(w, r, &input) { + return + } + input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled") + menus, vins, err := s.validateMutation(r.Context(), input, true) + if err != nil { + 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) + 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() + if err := replaceGrants(r.Context(), tx, uint64(id), menus, vins, actor.Name); 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)) + return + } + s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleCount": len(vins)}, remoteAddress(r)) + httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id}) +} + +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) + 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, vins, err := s.validateMutation(r.Context(), input, false) + if err != nil { + httpx.WriteError(w, http.StatusBadRequest, "USER_INPUT_INVALID", err.Error(), "", requestTraceID(r)) + return + } + tx, err := s.db.BeginTx(r.Context(), nil) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法更新客户账号", "", requestTraceID(r)) + 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" { + httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", 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 != "" { + hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), 12) + 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) + if _, err := tx.ExecContext(r.Context(), query, args...); err != nil { + writeUserMutationError(w, r, err, "更新") + return + } + if err := replaceGrants(r.Context(), tx, id, menus, vins, actor.Name); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法保存客户权限", "", requestTraceID(r)) + return + } + if input.Status == "disabled" || input.Password != "" { + if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法撤销旧会话", "", requestTraceID(r)) + return + } + } + if err := tx.Commit(); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "无法更新客户账号", "", requestTraceID(r)) + return + } + s.invalidateUser(id) + s.audit(r.Context(), actor.Name, "user.update", "user", idText, "success", map[string]any{"status": input.Status, "menus": menus, "vehicleCount": len(vins), "passwordReset": input.Password != ""}, remoteAddress(r)) + httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id}) +} + +func (s *authStore) validateMutation(ctx context.Context, input userMutation, requirePassword bool) ([]string, []string, error) { + if requirePassword && !usernamePattern.MatchString(strings.TrimSpace(input.Username)) { + return nil, nil, fmt.Errorf("用户名需为 3-64 位字母、数字、点、下划线或短横线") + } + if strings.TrimSpace(input.DisplayName) == "" || len([]rune(strings.TrimSpace(input.DisplayName))) > 48 { + return nil, nil, fmt.Errorf("客户名称不能为空且不能超过 48 个字符") + } + if input.Status != "enabled" && input.Status != "disabled" { + return nil, nil, fmt.Errorf("账号状态无效") + } + 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, fmt.Errorf("至少分配一个客户菜单") + } + vins := normalizeVINs(input.VehicleVINs) + if len(vins) == 0 { + return nil, nil, fmt.Errorf("至少分配一辆可查看车辆") + } + if len(vins) > 5000 { + return nil, nil, fmt.Errorf("单个客户最多分配 5000 辆车") + } + found, err := existingVINs(ctx, s.db, vins) + if err != nil { + return nil, nil, fmt.Errorf("校验车辆失败") + } + if len(found) != len(vins) { + missing := []string{} + for _, vin := range vins { + if !found[vin] { + missing = append(missing, vin) + } + if len(missing) == 5 { + break + } + } + return nil, nil, fmt.Errorf("存在未接入的 VIN:%s", strings.Join(missing, "、")) + } + return menus, vins, nil +} + +func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins []string, actor string) error { + if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil { + return err + } + for _, menu := range menus { + if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`, userID, menu, actor); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil { + return err + } + for _, vin := range vins { + if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?)`, userID, vin, actor); err != nil { + return err + } + } + return nil +} + +func existingVINs(ctx context.Context, db *sql.DB, vins []string) (map[string]bool, error) { + result := map[string]bool{} + for start := 0; start < len(vins); start += 500 { + end := start + 500 + if end > len(vins) { + end = len(vins) + } + part := vins[start:end] + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(part)), ",") + args := make([]any, len(part)) + for index := range part { + args[index] = part[index] + } + rows, err := db.QueryContext(ctx, `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 (`+placeholders+`)`, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var vin string + if err := rows.Scan(&vin); err != nil { + rows.Close() + return nil, err + } + result[strings.ToUpper(strings.TrimSpace(vin))] = true + } + if err := rows.Close(); err != nil { + return nil, err + } + } + return result, nil +} + +func normalizeMenus(values []string) []string { + seen := map[string]bool{} + menus := []string{} + for _, value := range values { + value = strings.ToLower(strings.TrimSpace(value)) + if customerMenuSet[value] && !seen[value] { + seen[value] = true + menus = append(menus, value) + } + } + sort.Strings(menus) + return menus +} + +func normalizeVINs(values []string) []string { + seen := map[string]bool{} + vins := []string{} + for _, value := range values { + for _, part := range strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ',' || r == ';' || r == ';' || r == '\n' || r == '\t' || r == ' ' + }) { + vin := strings.ToUpper(strings.TrimSpace(part)) + if vin != "" && len(vin) <= 64 && !seen[vin] { + seen[vin] = true + vins = append(vins, vin) + } + } + } + sort.Strings(vins) + return vins +} + +func validatePassword(password string) error { + if len(password) < 10 || len(password) > 128 { + return fmt.Errorf("密码长度需为 10-128 位") + } + classes := 0 + if regexp.MustCompile(`[a-z]`).MatchString(password) { + classes++ + } + if regexp.MustCompile(`[A-Z]`).MatchString(password) { + classes++ + } + if regexp.MustCompile(`[0-9]`).MatchString(password) { + classes++ + } + if regexp.MustCompile(`[^A-Za-z0-9]`).MatchString(password) { + classes++ + } + if classes < 3 { + return fmt.Errorf("密码需包含大小写字母、数字、特殊字符中的至少三类") + } + return nil +} + +func randomSessionToken() (string, [sha256.Size]byte, error) { + var raw [32]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", [sha256.Size]byte{}, err + } + token := base64.RawURLEncoding.EncodeToString(raw[:]) + return token, sha256.Sum256([]byte(token)), nil +} + +func randomHex(size int) (string, error) { + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +func (s *authStore) cacheGet(key string) (platform.Principal, bool) { + s.cacheMu.RLock() + entry, ok := s.cache[key] + s.cacheMu.RUnlock() + now := time.Now() + if !ok || now.After(entry.expiresAt) || now.Sub(entry.cachedAt) > localSessionCacheTTL { + return platform.Principal{}, false + } + return entry.principal.Clone(), true +} + +func (s *authStore) cachePut(key string, principal platform.Principal, expiresAt time.Time) { + s.cacheMu.Lock() + s.cache[key] = cachedSession{principal: principal.Clone(), expiresAt: expiresAt, cachedAt: time.Now()} + s.cacheMu.Unlock() +} + +func (s *authStore) invalidateUser(userID uint64) { + subject := strconv.FormatUint(userID, 10) + s.cacheMu.Lock() + for key, entry := range s.cache { + if entry.principal.SubjectID == subject { + delete(s.cache, key) + } + } + s.cacheMu.Unlock() +} + +func (s *authStore) audit(ctx context.Context, actor, action, targetType, targetID, result string, detail any, remote string) { + var detailJSON any + if detail != nil { + if encoded, err := json.Marshal(detail); err == nil { + detailJSON = encoded + } + } + _, _ = s.db.ExecContext(ctx, `INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?)`, truncateUTF8(actor, 96), action, targetType, targetID, result, detailJSON, truncateUTF8(remote, 96)) +} + +func decodeAuthJSON(w http.ResponseWriter, r *http.Request, target any) bool { + defer r.Body.Close() + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "BAD_JSON", "请求 JSON 解析失败", "", requestTraceID(r)) + return false + } + return true +} + +func remoteAddress(r *http.Request) string { + if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]); forwarded != "" { + return forwarded + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func truncateUTF8(value string, max int) string { + runes := []rune(strings.TrimSpace(value)) + if len(runes) > max { + runes = runes[:max] + } + return string(runes) +} + +func writeUserMutationError(w http.ResponseWriter, r *http.Request, err error, action string) { + message := "无法" + action + "客户账号" + if strings.Contains(strings.ToLower(err.Error()), "duplicate") { + message = "用户名已存在" + } + httpx.WriteError(w, http.StatusConflict, "USER_MUTATION_FAILED", message, "", requestTraceID(r)) +} diff --git a/vehicle-data-platform/apps/api/internal/app/identity_adapter.go b/vehicle-data-platform/apps/api/internal/app/identity_adapter.go new file mode 100644 index 00000000..abfa9f67 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/app/identity_adapter.go @@ -0,0 +1,20 @@ +package app + +import ( + "context" + + "lingniu/vehicle-data-platform/apps/api/internal/platform" +) + +// IdentityAdapter is the narrow trust boundary for a future Yudao Cloud, +// RuoYi Sys or company SSO integration. Implementations must validate a signed, +// short-lived credential (or use server-side introspection) before returning a +// principal. Unsigned forwarding headers must never implement this interface. +// +// External identities should be mapped to platform_user through +// (auth_provider, external_subject); menu and vehicle grants remain platform +// authorization data so changing an upstream login system cannot bypass Scope. +type IdentityAdapter interface { + Name() string + AuthenticateBearer(context.Context, string) (platform.Principal, bool, error) +} diff --git a/vehicle-data-platform/apps/api/internal/app/server.go b/vehicle-data-platform/apps/api/internal/app/server.go index 690c3476..4eac09be 100644 --- a/vehicle-data-platform/apps/api/internal/app/server.go +++ b/vehicle-data-platform/apps/api/internal/app/server.go @@ -29,6 +29,7 @@ func NewServer(cfg config.Config) http.Handler { dataMode = "mock" } var store platform.Store = platform.NewMockStore() + var authDB *sql.DB var storeErr error if dataMode != "mock" && dataMode != "production" { storeErr = fmt.Errorf("DATA_MODE must be mock or production") @@ -46,6 +47,7 @@ func NewServer(cfg config.Config) http.Handler { storeErr = fmt.Errorf("connect production mysql: %w", err) } } else { + authDB = db var tdengine *sql.DB if cfg.TDengineDSN != "" { tdengine, err = platform.OpenSQL(ctx, cfg.TDengineDriver, cfg.TDengineDSN) @@ -92,7 +94,7 @@ func NewServer(cfg config.Config) http.Handler { // Reverse geocoding consumes the server-side AMap credential, so it must // stay behind the same authentication boundary as the platform API. api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient) - handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg)) + handler := static.Handler(cfg.StaticDir, withAPIAuth(api, cfg, authDB)) handler = withAppConfig(handler, cfg) handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient) return withRequestTimeout(handler, cfg.RequestTimeout) diff --git a/vehicle-data-platform/apps/api/internal/config/config.go b/vehicle-data-platform/apps/api/internal/config/config.go index a2e9768b..1fbc8770 100644 --- a/vehicle-data-platform/apps/api/internal/config/config.go +++ b/vehicle-data-platform/apps/api/internal/config/config.go @@ -22,6 +22,9 @@ type Config struct { AuthToken string AuthMode string AuthTokensJSON string + BootstrapAdminUsername string + BootstrapAdminPassword string + SessionTTL time.Duration DataMode string ExportDir string RequestTimeout time.Duration @@ -56,6 +59,9 @@ func Load() Config { 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, diff --git a/vehicle-data-platform/apps/api/internal/platform/alert.go b/vehicle-data-platform/apps/api/internal/platform/alert.go index 29fb6f85..8056811d 100644 --- a/vehicle-data-platform/apps/api/internal/platform/alert.go +++ b/vehicle-data-platform/apps/api/internal/platform/alert.go @@ -41,6 +41,19 @@ func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSumm } func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) { + if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + if strings.TrimSpace(query.Keyword) == "" { + return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"} + } + vin, err := s.resolveVehicleVIN(ctx, query.Keyword, query.Protocol) + if err != nil { + return Page[AlertEvent]{}, err + } + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return Page[AlertEvent]{}, err + } + query.Keyword = vin + } store, err := s.alertStore() if err != nil { return Page[AlertEvent]{}, err diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 82ba8896..d1e8aef8 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -480,6 +480,8 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er if clientErr, ok := asClientError(err); ok { status := http.StatusBadRequest switch { + case clientErr.Code == "VEHICLE_PERMISSION_DENIED": + status = http.StatusForbidden case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"): status = http.StatusNotFound case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED": diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 1758a9fd..dd6b92ad 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -21,6 +21,7 @@ func buildVehicleListSQL(query url.Values) SQLQuery { canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} where := []string{"1 = 1"} + where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins")) if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { where = append(where, "(b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)") like := "%" + keyword + "%" @@ -72,6 +73,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery { canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} where := []string{"v.vin IS NOT NULL", "v.vin <> ''"} + where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins")) 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 ?)") @@ -173,6 +175,7 @@ func buildVehicleCoverageSummarySQL(query url.Values) SQLQuery { canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} where := []string{"v.vin IS NOT NULL", "v.vin <> ''"} + where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins")) 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 ?)") @@ -294,6 +297,7 @@ func buildRealtimeLocationSQL(query url.Values) SQLQuery { offset := parsePositive(query.Get("offset"), 0) args := []any{} where := []string{"1 = 1"} + where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins")) if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "l.protocol = ?") args = append(args, protocol) @@ -323,6 +327,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery { canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) args := []any{} where := []string{"l.vin IS NOT NULL", "l.vin <> ''"} + where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins")) having := []string{} if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { where = append(where, "l.protocol = ?") @@ -449,6 +454,7 @@ func buildDailyMileageSQL(query url.Values) SQLQuery { args = append(args, like, like, like, like) } where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) + where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins")) protocols := parseMileageProtocols(query.Get("protocols")) if len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) @@ -510,6 +516,7 @@ func buildMileageSummarySQL(query url.Values) SQLQuery { args = append(args, like, like, like, like) } where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) + where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins")) if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { @@ -544,6 +551,7 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) { args = append(args, like, like, like, like) } where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins")) + where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins")) if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) } else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { @@ -616,6 +624,7 @@ func buildFleetLatestMileageSQL(query url.Values) SQLQuery { args = append(args, like, like, like, like) } where, args = appendVINListFilter(where, args, "l.vin", query.Get("vins")) + where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins")) protocols := parseMileageProtocols(query.Get("protocols")) if len(protocols) > 0 { where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols) diff --git a/vehicle-data-platform/apps/api/internal/platform/principal.go b/vehicle-data-platform/apps/api/internal/platform/principal.go index cc532462..dcf147b1 100644 --- a/vehicle-data-platform/apps/api/internal/platform/principal.go +++ b/vehicle-data-platform/apps/api/internal/platform/principal.go @@ -3,8 +3,49 @@ package platform import "context" type Principal struct { - Name string `json:"name"` - Role string `json:"role"` + 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:"-"` + VehicleCount int `json:"vehicleCount"` +} + +func (p Principal) Clone() Principal { + p.MenuKeys = append([]string(nil), p.MenuKeys...) + p.VehicleVINs = append([]string(nil), p.VehicleVINs...) + p.VehicleCount = len(p.VehicleVINs) + return p +} + +func (p Principal) CanMenu(key string) bool { + if p.UserType == "admin" || p.Role == "admin" { + return true + } + for _, value := range p.MenuKeys { + if value == key { + return true + } + } + return false +} + +func (p Principal) CanVIN(vin string) bool { + if p.UserType != "customer" { + return true + } + for _, allowed := range p.VehicleVINs { + if allowed == vin { + return true + } + } + return false } type principalContextKey struct{} diff --git a/vehicle-data-platform/apps/api/internal/platform/profile.go b/vehicle-data-platform/apps/api/internal/platform/profile.go index a3b52f39..e1da74d3 100644 --- a/vehicle-data-platform/apps/api/internal/platform/profile.go +++ b/vehicle-data-platform/apps/api/internal/platform/profile.go @@ -29,6 +29,9 @@ var vehicleProfileSourceSystemPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._:- func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, error) { vin = strings.TrimSpace(vin) + if err := authorizeVehicleVIN(ctx, vin); err != nil { + return VehicleProfile{}, err + } if err := validateProfileVIN(vin); err != nil { return VehicleProfile{}, err } diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index a2e217cd..5f9984cd 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -484,7 +484,11 @@ func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool { } func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (MonitorSummary, error) { - vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query)) + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return MonitorSummary{}, err + } + vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery)) if err != nil { return MonitorSummary{}, err } @@ -538,7 +542,11 @@ func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, veh } func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (MonitorWorkspaceResponse, error) { - vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query)) + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return MonitorWorkspaceResponse{}, err + } + vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery)) if err != nil { return MonitorWorkspaceResponse{}, err } @@ -566,7 +574,11 @@ func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (Monit } func (s *Service) MonitorMap(ctx context.Context, query url.Values) (MonitorMapResponse, error) { - vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(query)) + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return MonitorMapResponse{}, err + } + vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery)) if err != nil { return MonitorMapResponse{}, err } @@ -684,7 +696,11 @@ func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values } func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { - return s.store.Vehicles(ctx, query) + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return Page[VehicleRow]{}, err + } + return s.store.Vehicles(ctx, scopedQuery) } func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, protocol string) (VehicleIdentityResolution, error) { @@ -704,11 +720,19 @@ func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, pr } func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) { - return s.store.VehicleCoverage(ctx, query) + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return Page[VehicleCoverageRow]{}, err + } + return s.store.VehicleCoverage(ctx, scopedQuery) } func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) { - return s.store.VehicleCoverageSummary(ctx, query) + scopedQuery, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return VehicleCoverageSummary{}, err + } + return s.store.VehicleCoverageSummary(ctx, scopedQuery) } func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) { @@ -743,6 +767,15 @@ func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[V func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) { keyword = strings.TrimSpace(keyword) protocol = strings.TrimSpace(protocol) + if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { + resolvedScopeVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol) + if err != nil { + return VehicleServiceOverview{}, err + } + if err := authorizeVehicleVIN(ctx, resolvedScopeVIN); err != nil { + return VehicleServiceOverview{}, err + } + } if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok { page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{ Keywords: []string{keyword}, @@ -827,6 +860,10 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string keyword := strings.TrimSpace(vin) protocol = strings.TrimSpace(protocol) vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}} + vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery) + if err != nil { + return VehicleDetail{}, err + } vehicles, err := s.store.Vehicles(ctx, vehicleQuery) if err != nil { return VehicleDetail{}, err @@ -843,6 +880,9 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string if queryVIN == "" { queryVIN = keyword } + if err := authorizeVehicleVIN(ctx, queryVIN); err != nil { + return VehicleDetail{}, err + } if resolvedVIN == "" { qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}} if protocol != "" { @@ -1495,6 +1535,9 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates if err != nil { return LatestTelemetryResponse{}, err } + if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil { + return LatestTelemetryResponse{}, err + } rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols)) catalogResult := make(chan latestTelemetryCatalogResult, 1) for _, protocol := range canonicalVehicleProtocols { @@ -3616,7 +3659,10 @@ func cloneStringMap(values map[string]string) map[string]string { } func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) { - resolved := cloneValues(query) + resolved, err := applyPrincipalVehicleScope(ctx, query) + if err != nil { + return nil, err + } if strings.TrimSpace(resolved.Get("keywords")) != "" { return resolved, nil } @@ -3629,6 +3675,9 @@ func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (ur return nil, err } if resolvedVIN != "" { + if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" && !principal.CanVIN(resolvedVIN) { + return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"} + } resolved.Set("vin", resolvedVIN) } return resolved, nil @@ -3640,6 +3689,10 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco return keyword, nil } vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}} + vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery) + if err != nil { + return "", err + } if protocol = strings.TrimSpace(protocol); protocol != "" { vehicleQuery.Set("protocol", protocol) } @@ -3654,6 +3707,44 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco return identity.VIN, nil } +func applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Values, error) { + next := cloneValues(query) + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return next, nil + } + allowed := make(map[string]bool, len(principal.VehicleVINs)) + for _, vin := range principal.VehicleVINs { + allowed[strings.ToUpper(strings.TrimSpace(vin))] = true + } + for _, value := range []string{next.Get("vin"), next.Get("vins")} { + for _, vin := range strings.Split(value, ",") { + vin = strings.ToUpper(strings.TrimSpace(vin)) + if vin != "" && isLikelyVIN(vin) && !allowed[vin] { + return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"} + } + } + } + if len(principal.VehicleVINs) == 0 { + next.Set("scopeVins", "__NO_VEHICLE_SCOPE__") + } else { + next.Set("scopeVins", strings.Join(principal.VehicleVINs, ",")) + } + return next, nil +} + +func authorizeVehicleVIN(ctx context.Context, vin string) error { + principal, ok := PrincipalFromContext(ctx) + if !ok || principal.UserType != "customer" { + return nil + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + if vin == "" || !principal.CanVIN(vin) { + return clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"} + } + return nil +} + func cloneValues(values url.Values) url.Values { cloned := make(url.Values, len(values)) for key, current := range values { diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index d26b612d..1d54afe5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -51,6 +51,8 @@ type countingStore struct { vehiclesCalls int vehicleRealtimeCalls int overviewBatchCalls int + lastVehicleQuery url.Values + lastRealtimeQuery url.Values } func newCountingStore() *countingStore { @@ -59,14 +61,33 @@ func newCountingStore() *countingStore { func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { s.vehiclesCalls++ + s.lastVehicleQuery = cloneValues(query) return s.MockStore.Vehicles(ctx, query) } func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { s.vehicleRealtimeCalls++ + s.lastRealtimeQuery = cloneValues(query) return s.MockStore.VehicleRealtime(ctx, query) } +func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) { + store := newCountingStore() + service := NewService(store) + principal := Principal{Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}} + ctx := WithPrincipal(context.Background(), principal) + if _, err := service.Vehicles(ctx, url.Values{"limit": {"20"}}); err != nil { + t.Fatalf("scoped vehicle list failed: %v", err) + } + if got := store.lastVehicleQuery.Get("scopeVins"); got != "LB9A32A24R0LS1426" { + t.Fatalf("scopeVins=%q", got) + } + _, err := service.VehicleRealtime(ctx, url.Values{"vin": {"LMRKH9AC2R1004087"}}) + if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" { + t.Fatalf("cross-vehicle query should be forbidden, err=%v", err) + } +} + func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ return s.MockStore.VehicleServiceOverviews(ctx, query) diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 31f2ea10..af24af63 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -1,5 +1,6 @@ import type { ApiEnvelope, + AdminUser, AccessQuery, AccessSummary, AccessThresholdConfig, @@ -41,6 +42,8 @@ import type { RealtimeLocationRow, SourceReadinessPlan, SessionInfo, + LoginResponse, + CustomerUserInput, TrackPlaybackResponse, VehicleRealtimeRow, VehicleCoverageRow, @@ -171,6 +174,20 @@ function withTraceID(message: string, traceID?: string) { export const api = { session: (signal?: AbortSignal) => request('/api/v2/session', withSignal(undefined, signal)), + login: (credentials: { username: string; password: string }) => request('/api/v2/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) + }), + logout: () => request<{ loggedOut: boolean }>('/api/v2/auth/logout', { method: 'POST' }), + changePassword: (input: { currentPassword: string; newPassword: string }) => request<{ changed: boolean }>('/api/v2/auth/password', { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) + }), + adminUsers: (signal?: AbortSignal) => request('/api/v2/admin/users', withSignal(undefined, signal)), + createCustomerUser: (input: CustomerUserInput) => request<{ id: number }>('/api/v2/admin/users', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) + }), + updateCustomerUser: (id: number, input: CustomerUserInput) => request<{ id: number }>(`/api/v2/admin/users/${id}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) + }), monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined), monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined), monitorWorkspace: (params = new URLSearchParams(), signal?: AbortSignal) => request(`/api/v2/monitor/workspace?${params.toString()}`, signal ? { signal } : undefined), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index f702f832..e125c6d4 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -5,11 +5,53 @@ export interface ApiEnvelope { } export interface SessionInfo { + subjectId?: string; name: string; - role: 'viewer' | 'operator' | 'admin'; + username?: string; + role: 'viewer' | 'operator' | 'admin' | 'customer'; + userType?: 'viewer' | 'operator' | 'admin' | 'customer'; + authProvider?: 'local' | 'legacy-token' | 'disabled' | string; + menuKeys?: string[]; + vehicleCount?: number; + customerRef?: string; + tenantRef?: string; authMode: 'disabled' | 'enforce'; } +export interface LoginResponse { + accessToken: string; + expiresAt: string; + session: Omit; +} + +export interface AdminUser { + id: number; + username: string; + displayName: string; + userType: 'admin' | 'customer'; + status: 'enabled' | 'disabled'; + customerRef: string; + tenantRef: string; + authProvider: string; + externalSubject?: string; + menuKeys: string[]; + vehicleVins: string[]; + lastLoginAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface CustomerUserInput { + username?: string; + displayName: string; + password?: string; + status: 'enabled' | 'disabled'; + customerRef: string; + tenantRef: string; + menuKeys: string[]; + vehicleVins: string[]; +} + export interface MonitorSummary { totalVehicles: number; onlineVehicles: number; diff --git a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx index c60f6cfd..8cef1211 100644 --- a/vehicle-data-platform/apps/web/src/v2/AppV2.tsx +++ b/vehicle-data-platform/apps/web/src/v2/AppV2.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { AppShell } from './layout/AppShell'; import { AuthGate } from './auth/AuthGate'; @@ -7,6 +7,8 @@ import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary'; import { RoutePageFactories, RoutePages } from './routing/routeModules'; import { ROUTER_FUTURE } from './routing/routerConfig'; import { QUERY_MEMORY } from './queryPolicy'; +import { usePlatformSession } from './auth/AuthGate'; +import { hasMenu } from './auth/session'; export function createPlatformQueryClient() { return new QueryClient({ @@ -35,6 +37,17 @@ function PlatformReadySignal() { return null; } +function defaultPath(menuKeys: string[]) { + const first = ['monitor', 'vehicles', 'tracks', 'statistics'].find((key) => menuKeys.includes(key)); + return first ? `/${first}` : '/monitor'; +} + +function ProtectedRoute({ menu, children }: { menu: string; children: ReactNode }) { + const { session } = usePlatformSession(); + if (!hasMenu(session, menu)) return ; + return children; +} + export function AppV2() { return ( @@ -43,15 +56,16 @@ export function AppV2() { }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> @@ -60,3 +74,8 @@ export function AppV2() { ); } + +function SessionIndex() { + const { session } = usePlatformSession(); + return ; +} diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx index a3987682..bfc0a2ea 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.test.tsx @@ -4,8 +4,8 @@ import { afterEach, expect, test, vi } from 'vitest'; import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session'; import { AuthGate, usePlatformSession } from './AuthGate'; -const mocks = vi.hoisted(() => ({ session: vi.fn() })); -vi.mock('../../api/client', () => ({ api: { session: mocks.session } })); +const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn() })); +vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout } })); function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) { const { session, logout } = usePlatformSession(); @@ -21,10 +21,14 @@ function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) { afterEach(() => { cleanup(); mocks.session.mockReset(); + mocks.login.mockReset(); + mocks.logout.mockReset(); + mocks.logout.mockResolvedValue({ loggedOut: true }); window.sessionStorage.clear(); }); test('treats login and logout as complete query and mutation cache boundaries', async () => { + mocks.logout.mockResolvedValue({ loggedOut: true }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' }); client.getMutationCache().build(client, { mutationFn: async () => ({ ok: true }) }); @@ -33,11 +37,12 @@ test('treats login and logout as complete query and mutation cache boundaries', if (getAccessToken() !== 'next-user-token') throw new Error('未登录'); return { name: 'next-user', role: 'viewer', authMode: 'enforce' }; }); + mocks.login.mockResolvedValue({ accessToken: 'next-user-token', expiresAt: new Date().toISOString(), session: { name: 'next-user', role: 'customer' } }); render(); - const token = await screen.findByPlaceholderText('Bearer token'); - fireEvent.change(token, { target: { value: 'next-user-token' } }); - fireEvent.click(screen.getByRole('button', { name: '进入平台' })); + fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'next-user' } }); + fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } }); + fireEvent.click(screen.getByRole('button', { name: '登录' })); expect(await screen.findByText('next-user')).toBeInTheDocument(); expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined(); @@ -47,7 +52,7 @@ test('treats login and logout as complete query and mutation cache boundaries', await waitFor(() => expect(client.getMutationCache().getAll()).toHaveLength(1)); fireEvent.click(screen.getByRole('button', { name: '退出测试会话' })); - expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument(); + expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument(); await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1)); expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined(); expect(client.getMutationCache().getAll()).toHaveLength(0); @@ -55,6 +60,7 @@ test('treats login and logout as complete query and mutation cache boundaries', }); test('an expired protected request returns to login and removes the active user cache', async () => { + mocks.logout.mockResolvedValue({ loggedOut: true }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); setAccessToken('next-user-token'); mocks.session.mockImplementation(async () => { @@ -67,7 +73,7 @@ test('an expired protected request returns to login and removes the active user client.setQueryData(['active-user-detail'], { plate: '粤A00002' }); window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT)); - expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument(); + expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument(); expect(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined(); expect(getAccessToken()).toBe(''); }); diff --git a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx index 21746ad2..2299f267 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx +++ b/vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { createContext, FormEvent, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react'; import { api } from '../../api/client'; import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session'; @@ -19,8 +19,13 @@ export function usePlatformSession() { export function AuthGate({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const [tokenVersion, setTokenVersion] = useState(0); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const [draftToken, setDraftToken] = useState(''); + const [legacyMode, setLegacyMode] = useState(false); const [attempted, setAttempted] = useState(() => Boolean(getAccessToken())); + const [loginPending, setLoginPending] = useState(false); + const [loginError, setLoginError] = useState(''); const session = useQuery({ queryKey: ['platform-session', tokenVersion], queryFn: ({ signal }) => api.session(signal), @@ -31,31 +36,73 @@ export function AuthGate({ children }: { children: ReactNode }) { void queryClient.cancelQueries(); queryClient.clear(); }, [queryClient]); - const logout = useCallback(() => { + const finishLogout = useCallback(() => { clearAccessToken(); clearClientSession(); + setPassword(''); setDraftToken(''); setAttempted(false); + setLoginError(''); setTokenVersion((value) => value + 1); }, [clearClientSession]); + const logout = useCallback(() => { + void api.logout().catch(() => undefined).finally(finishLogout); + }, [finishLogout]); useEffect(() => { - window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout); - return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout); - }, [logout]); + window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout); + return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout); + }, [finishLogout]); - const login = (event: FormEvent) => { + const login = async (event: FormEvent) => { event.preventDefault(); - setAccessToken(draftToken); - clearClientSession(); setAttempted(true); - setTokenVersion((value) => value + 1); + setLoginError(''); + setLoginPending(true); + try { + if (legacyMode) { + setAccessToken(draftToken); + } else { + const result = await api.login({ username: username.trim(), password }); + setAccessToken(result.accessToken); + } + clearClientSession(); + setTokenVersion((value) => value + 1); + } catch (error) { + clearAccessToken(); + setLoginError(error instanceof Error ? error.message : '登录失败,请稍后重试'); + } finally { + setLoginPending(false); + } }; if (session.isPending) { - return
灵牛智能正在验证访问身份…
; + return
羚牛智能正在验证登录状态…
; } if (!session.data) { - return
灵牛智能

车辆数据中台

请输入运维人员分配的访问令牌。令牌仅保存在当前浏览器会话中。

{attempted && session.error ? {session.error.message} : null}
; + const error = loginError || (attempted && session.error ? session.error.message : ''); + return
+ +
+ 羚牛智能 +

登录车辆数据中台

+

{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}

+ {legacyMode ? : <> + + + } + {error ? {error} : null} + + + 登录即表示你同意遵守平台数据安全规范 +
+
; } return {children}; } diff --git a/vehicle-data-platform/apps/web/src/v2/auth/session.ts b/vehicle-data-platform/apps/web/src/v2/auth/session.ts index bf87a228..cccd46e7 100644 --- a/vehicle-data-platform/apps/web/src/v2/auth/session.ts +++ b/vehicle-data-platform/apps/web/src/v2/auth/session.ts @@ -1,13 +1,7 @@ const TOKEN_KEY = 'vehicle-platform.access-token'; export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized'; -export type PlatformRole = 'viewer' | 'operator' | 'admin'; - -export interface PlatformSession { - name: string; - role: PlatformRole; - authMode: 'disabled' | 'enforce'; -} +export type PlatformSession = SessionInfo; export function getAccessToken() { return window.sessionStorage.getItem(TOKEN_KEY) ?? ''; @@ -34,6 +28,13 @@ export function canOperate(session: PlatformSession) { return session.role === 'operator' || session.role === 'admin'; } +export function hasMenu(session: PlatformSession, menu: string) { + if (session.role === 'admin') return true; + if (!session.menuKeys) return session.role !== 'customer'; + return session.menuKeys.includes(menu); +} + export function canAdminister(session: PlatformSession) { return session.role === 'admin'; } +import type { SessionInfo } from '../../api/types'; diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx index 4bdfcd04..3a6aed12 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.test.tsx @@ -8,10 +8,11 @@ const routing = vi.hoisted(() => ({ scheduleIdleRoutePreloads: vi.fn(() => vi.fn()), shouldPreloadRouteOnIntent: vi.fn(() => true) })); +const auth = vi.hoisted(() => ({ session: { name: 'test-user', role: 'viewer' as const } as any })); vi.mock('../routing/routeModules', () => routing); vi.mock('../auth/AuthGate', () => ({ - usePlatformSession: () => ({ session: { name: 'test-user', role: 'viewer' }, logout: vi.fn() }) + usePlatformSession: () => ({ session: auth.session, logout: vi.fn() }) })); import { AppShell } from './AppShell'; @@ -19,6 +20,24 @@ import { AppShell } from './AppShell'; afterEach(() => { cleanup(); vi.clearAllMocks(); + auth.session = { name: 'test-user', role: 'viewer' }; +}); + +test('renders only explicitly assigned customer menus', () => { + auth.session = { name: '客户甲', role: 'customer', userType: 'customer', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'] }; + render( + }>页面内容} /> + ); + + expect(screen.getByRole('link', { name: '全局监控' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '车辆查询' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '轨迹回放' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '里程查询' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '历史数据' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '告警中心' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '接入管理' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '账号管理' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '运维质量' })).not.toBeInTheDocument(); }); test('reschedules likely route preloads when the active module changes', async () => { diff --git a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx index c3f91e48..fe47a246 100644 --- a/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx +++ b/vehicle-data-platform/apps/web/src/v2/layout/AppShell.tsx @@ -12,19 +12,22 @@ import { IconUser, IconExit } from '@douyinfe/semi-icons'; -import { useEffect, useState } from 'react'; +import { FormEvent, useEffect, useState } from 'react'; import { NavLink, Outlet, useLocation } from 'react-router-dom'; +import { api } from '../../api/client'; import { usePlatformSession } from '../auth/AuthGate'; +import { hasMenu } from '../auth/session'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; const navigation = [ - { to: '/monitor', label: '全局监控', icon: IconHome }, - { to: '/vehicles', label: '车辆查询', icon: IconSearch }, - { to: '/tracks', label: '轨迹回放', icon: IconMapPin }, - { to: '/history', label: '历史数据', icon: IconBarChartHStroked }, - { to: '/statistics', label: '里程查询', icon: IconBarChartHStroked }, - { to: '/alerts', label: '告警中心', icon: IconAlarm }, - { to: '/access', label: '接入管理', icon: IconBox } + { to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome }, + { to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch }, + { to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconMapPin }, + { to: '/history', menu: 'history', label: '历史数据', icon: IconBarChartHStroked }, + { to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartHStroked }, + { to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm }, + { to: '/access', menu: 'access', label: '接入管理', icon: IconBox }, + { to: '/users', menu: 'users', label: '账号管理', icon: IconUser } ]; const pageNames: Record = { @@ -35,7 +38,8 @@ const pageNames: Record = { statistics: '里程查询', alerts: '告警中心', access: '接入管理', - operations: '运维质量' + operations: '运维质量', + users: '账号管理' }; const pageHelp: Record = { @@ -46,7 +50,8 @@ const pageHelp: Record = { statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] }, alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] }, access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] }, - operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] } + operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] }, + users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] } }; function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) { @@ -72,8 +77,9 @@ export function AppShell() { const activeRoutePath = `/${section}`; const { session, logout } = usePlatformSession(); const [helpOpen, setHelpOpen] = useState(false); + const [passwordOpen, setPasswordOpen] = useState(false); const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches); - const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员' }[session.role]; + const roleLabel = { viewer: '只读', operator: '处置员', admin: '管理员', customer: '客户' }[session.role]; useEffect(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]); useEffect(() => { const media = window.matchMedia('(max-width: 680px)'); @@ -91,24 +97,61 @@ export function AppShell() {

{pageNames[section] ?? '车辆数据中台'}

- {session.name}{roleLabel} + +
{helpOpen ?
setHelpOpen(false)} />
: null} + {passwordOpen ? setPasswordOpen(false)} onChanged={logout} /> : null} ); } +function PasswordDialog({ onClose, onChanged }: { onClose: () => void; onChanged: () => void }) { + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [pending, setPending] = useState(false); + const [error, setError] = useState(''); + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (newPassword !== confirmPassword) { setError('两次输入的新密码不一致'); return; } + setPending(true); setError(''); + try { + await api.changePassword({ currentPassword, newPassword }); + onChanged(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : '密码修改失败'); + } finally { setPending(false); } + }; + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape' && !pending) onClose(); }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [onClose, pending]); + return
{ if (event.target === event.currentTarget && !pending) onClose(); }}>
+

修改登录密码

修改成功后,所有设备需要重新登录。

+ + + + {error ? {error} : null} +
+
; +} + const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; -const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], { to: '/operations', label: '运维质量', icon: IconSetting }]; +const mobileMoreNavigation = [navigation[3], navigation[5], navigation[6], navigation[7], { to: '/operations', menu: 'operations', label: '运维质量', icon: IconSetting }]; function MobileNavigation() { const location = useLocation(); + const { session } = usePlatformSession(); const [moreOpen, setMoreOpen] = useState(false); - const moreActive = mobileMoreNavigation.some((item) => location.pathname.startsWith(item.to)); + const primary = mobilePrimaryNavigation.filter((item) => hasMenu(session, item.menu)); + const more = mobileMoreNavigation.filter((item) => hasMenu(session, item.menu)); + const moreActive = more.some((item) => location.pathname.startsWith(item.to)); const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; useEffect(() => setMoreOpen(false), [location.pathname]); useEffect(() => { @@ -120,17 +163,19 @@ function MobileNavigation() { const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}>{label}; return <> - {moreOpen ?
{ if (event.target === event.currentTarget) setMoreOpen(false); }}>
更多功能数据分析与系统管理
: null} + {moreOpen && more.length ?
{ if (event.target === event.currentTarget) setMoreOpen(false); }}>
更多功能数据分析与系统管理
: null} ; } function Sidebar() { + const { session } = usePlatformSession(); const [collapsed, setCollapsed] = useState(false); const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; + const visibleNavigation = navigation.filter((item) => hasMenu(session, item.menu)); return (