feat: add customer authentication and scoped RBAC

This commit is contained in:
lingniu
2026-07-16 13:58:28 +08:00
parent 6d6c9ce534
commit a1195fb97d
28 changed files with 1738 additions and 97 deletions

View File

@@ -18,4 +18,5 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect
github.com/segmentio/kafka-go v0.4.49 // indirect github.com/segmentio/kafka-go v0.4.49 // indirect
golang.org/x/crypto v0.49.0 // indirect
) )

View File

@@ -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/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 h1:kkd4ABsGiU+oXDbsw/sic985LKAvnpF9Gb/TEunTnLE=
github.com/taosdata/driver-go/v3 v3.8.1/go.mod h1:S6OGOinfR0xxxaMGsvBi9cLkYxEIW1p6qqr8QJATTlg= 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/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -3,6 +3,7 @@ package app
import ( import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log"
@@ -28,10 +29,16 @@ type tokenPrincipal struct {
type apiAuthenticator struct { type apiAuthenticator struct {
mode string mode string
tokens []tokenPrincipal tokens []tokenPrincipal
local *authStore
adapters []IdentityAdapter
} }
func withAPIAuth(next http.Handler, cfg config.Config) http.Handler { func withAPIAuth(next http.Handler, cfg config.Config, databases ...*sql.DB) http.Handler {
authenticator, err := newAPIAuthenticator(cfg) var db *sql.DB
if len(databases) > 0 {
db = databases[0]
}
authenticator, err := newAPIAuthenticator(cfg, db)
if err != nil { if err != nil {
log.Printf("platform API authentication misconfigured: %v", err) log.Printf("platform API authentication misconfigured: %v", err)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) 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)) mode := strings.ToLower(strings.TrimSpace(cfg.AuthMode))
if mode == "" { if mode == "" {
mode = "disabled" mode = "disabled"
@@ -49,7 +56,11 @@ func newAPIAuthenticator(cfg config.Config) (*apiAuthenticator, error) {
if mode != "disabled" && mode != "enforce" { if mode != "disabled" && mode != "enforce" {
return nil, fmt.Errorf("AUTH_MODE must be disabled or 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{} configured := []configuredPrincipal{}
if strings.TrimSpace(cfg.AuthTokensJSON) != "" { if strings.TrimSpace(cfg.AuthTokensJSON) != "" {
if err := json.Unmarshal([]byte(cfg.AuthTokensJSON), &configured); err != nil { 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") return nil, fmt.Errorf("duplicate authentication token")
} }
seen[hash] = true 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 { if mode == "enforce" && len(authenticator.tokens) == 0 && authenticator.local == nil && len(authenticator.adapters) == 0 {
return nil, fmt.Errorf("enforce mode requires AUTH_TOKEN or AUTH_TOKENS_JSON") return nil, fmt.Errorf("enforce mode requires a local identity store, identity adapter, AUTH_TOKEN, or AUTH_TOKENS_JSON")
} }
return authenticator, nil return authenticator, nil
} }
func (a *apiAuthenticator) middleware(next http.Handler) http.Handler { func (a *apiAuthenticator) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) principal, ok := a.authenticate(r)
if !ok { if !ok {
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`) w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-platform"`)
httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r)) httpx.WriteError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "需要有效的访问令牌", "", requestTraceID(r))
return 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) required := requiredRole(r)
if roleRank(principal.Role) < roleRank(required) { if roleRank(principal.Role) < roleRank(required) {
httpx.WriteError(w, http.StatusForbidden, "PERMISSION_DENIED", "当前角色无权执行该操作", "需要 "+required+" 角色", requestTraceID(r)) 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) ctx := platform.WithPrincipal(r.Context(), principal)
r = r.WithContext(ctx) r = r.WithContext(ctx)
if r.URL.Path == "/api/v2/session" { if r.URL.Path == "/api/v2/session" {
principal.VehicleCount = len(principal.VehicleVINs)
httpx.WriteOK(w, requestTraceID(r), struct { httpx.WriteOK(w, requestTraceID(r), struct {
Name string `json:"name"` platform.Principal
Role string `json:"role"`
AuthMode string `json:"authMode"` 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 return
} }
next.ServeHTTP(w, r) 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) { func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bool) {
if a.mode == "disabled" { 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")) token := bearerToken(r)
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
return platform.Principal{}, false
}
token := strings.TrimSpace(header[7:])
if token == "" { if token == "" {
return platform.Principal{}, false 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)) hash := sha256.Sum256([]byte(token))
var match platform.Principal var match platform.Principal
matched := 0 matched := 0
@@ -135,6 +203,34 @@ func (a *apiAuthenticator) authenticate(r *http.Request) (platform.Principal, bo
return match, matched == 1 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 { func requiredRole(r *http.Request) string {
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") { if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") {
return "operator" 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/")) { if r.Method == http.MethodPut && (path == "/api/v2/access/thresholds" || strings.HasPrefix(path, "/api/v2/alerts/rules/")) {
return "admin" return "admin"
} }
if r.Method == http.MethodPut && path == "/api/v2/auth/password" {
return "viewer"
}
return "admin" return "admin"
} }
func roleRank(role string) int { func roleRank(role string) int {
switch strings.ToLower(role) { switch strings.ToLower(role) {
case "viewer": case "viewer", "customer":
return 1 return 1
case "operator": case "operator":
return 2 return 2

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ func NewServer(cfg config.Config) http.Handler {
dataMode = "mock" dataMode = "mock"
} }
var store platform.Store = platform.NewMockStore() var store platform.Store = platform.NewMockStore()
var authDB *sql.DB
var storeErr error var storeErr error
if dataMode != "mock" && dataMode != "production" { if dataMode != "mock" && dataMode != "production" {
storeErr = fmt.Errorf("DATA_MODE must be mock or 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) storeErr = fmt.Errorf("connect production mysql: %w", err)
} }
} else { } else {
authDB = db
var tdengine *sql.DB var tdengine *sql.DB
if cfg.TDengineDSN != "" { if cfg.TDengineDSN != "" {
tdengine, err = platform.OpenSQL(ctx, cfg.TDengineDriver, 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 // Reverse geocoding consumes the server-side AMap credential, so it must
// stay behind the same authentication boundary as the platform API. // stay behind the same authentication boundary as the platform API.
api = withAMapReverseGeocodeAPI(api, cfg, "https://restapi.amap.com", http.DefaultClient) 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 = withAppConfig(handler, cfg)
handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient) handler = withAMapSecurityProxy(handler, cfg, defaultAMapProxyUpstreams(), http.DefaultClient)
return withRequestTimeout(handler, cfg.RequestTimeout) return withRequestTimeout(handler, cfg.RequestTimeout)

View File

@@ -22,6 +22,9 @@ type Config struct {
AuthToken string AuthToken string
AuthMode string AuthMode string
AuthTokensJSON string AuthTokensJSON string
BootstrapAdminUsername string
BootstrapAdminPassword string
SessionTTL time.Duration
DataMode string DataMode string
ExportDir string ExportDir string
RequestTimeout time.Duration RequestTimeout time.Duration
@@ -56,6 +59,9 @@ func Load() Config {
AuthToken: os.Getenv("AUTH_TOKEN"), AuthToken: os.Getenv("AUTH_TOKEN"),
AuthMode: authMode(), AuthMode: authMode(),
AuthTokensJSON: os.Getenv("AUTH_TOKENS_JSON"), 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(), DataMode: dataMode(),
ExportDir: os.Getenv("EXPORT_DIR"), ExportDir: os.Getenv("EXPORT_DIR"),
RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond, RequestTimeout: time.Duration(envInt("REQUEST_TIMEOUT_MS", 5000)) * time.Millisecond,

View File

@@ -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) { 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() store, err := s.alertStore()
if err != nil { if err != nil {
return Page[AlertEvent]{}, err return Page[AlertEvent]{}, err

View File

@@ -480,6 +480,8 @@ func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err er
if clientErr, ok := asClientError(err); ok { if clientErr, ok := asClientError(err); ok {
status := http.StatusBadRequest status := http.StatusBadRequest
switch { switch {
case clientErr.Code == "VEHICLE_PERMISSION_DENIED":
status = http.StatusForbidden
case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"): case strings.HasSuffix(clientErr.Code, "_NOT_FOUND"):
status = http.StatusNotFound status = http.StatusNotFound
case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED": case strings.HasSuffix(clientErr.Code, "_CONFLICT"), clientErr.Code == "ALERT_ACTION_NOT_ALLOWED":

View File

@@ -21,6 +21,7 @@ func buildVehicleListSQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{} args := []any{}
where := []string{"1 = 1"} where := []string{"1 = 1"}
where, args = appendVINListFilter(where, args, "s.vin", query.Get("scopeVins"))
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { 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 ?)") where = append(where, "(b.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
like := "%" + keyword + "%" like := "%" + keyword + "%"
@@ -72,6 +73,7 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{} args := []any{}
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"} where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{} having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { 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 ?)") 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)) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{} args := []any{}
where := []string{"v.vin IS NOT NULL", "v.vin <> ''"} where := []string{"v.vin IS NOT NULL", "v.vin <> ''"}
where, args = appendVINListFilter(where, args, "v.vin", query.Get("scopeVins"))
having := []string{} having := []string{}
if keyword := strings.TrimSpace(query.Get("keyword")); keyword != "" { 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 ?)") 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) offset := parsePositive(query.Get("offset"), 0)
args := []any{} args := []any{}
where := []string{"1 = 1"} where := []string{"1 = 1"}
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?") where = append(where, "l.protocol = ?")
args = append(args, protocol) args = append(args, protocol)
@@ -323,6 +327,7 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols)) canonicalSourceCount := strconv.Itoa(len(canonicalVehicleProtocols))
args := []any{} args := []any{}
where := []string{"l.vin IS NOT NULL", "l.vin <> ''"} where := []string{"l.vin IS NOT NULL", "l.vin <> ''"}
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
having := []string{} having := []string{}
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
where = append(where, "l.protocol = ?") where = append(where, "l.protocol = ?")
@@ -449,6 +454,7 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
args = append(args, like, like, like, like) 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("vins"))
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
protocols := parseMileageProtocols(query.Get("protocols")) protocols := parseMileageProtocols(query.Get("protocols"))
if len(protocols) > 0 { if len(protocols) > 0 {
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) 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) 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("vins"))
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { } 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) 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("vins"))
where, args = appendVINListFilter(where, args, "m.vin", query.Get("scopeVins"))
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 { if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols) where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" { } 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) 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("vins"))
where, args = appendVINListFilter(where, args, "l.vin", query.Get("scopeVins"))
protocols := parseMileageProtocols(query.Get("protocols")) protocols := parseMileageProtocols(query.Get("protocols"))
if len(protocols) > 0 { if len(protocols) > 0 {
where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols) where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols)

View File

@@ -3,8 +3,49 @@ package platform
import "context" import "context"
type Principal struct { type Principal struct {
SubjectID string `json:"subjectId,omitempty"`
SessionID string `json:"-"`
Name string `json:"name"` Name string `json:"name"`
Username string `json:"username,omitempty"`
Role string `json:"role"` 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{} type principalContextKey struct{}

View File

@@ -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) { func (s *Service) VehicleProfile(ctx context.Context, vin string) (VehicleProfile, error) {
vin = strings.TrimSpace(vin) vin = strings.TrimSpace(vin)
if err := authorizeVehicleVIN(ctx, vin); err != nil {
return VehicleProfile{}, err
}
if err := validateProfileVIN(vin); err != nil { if err := validateProfileVIN(vin); err != nil {
return VehicleProfile{}, err return VehicleProfile{}, err
} }

View File

@@ -484,7 +484,11 @@ func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
} }
func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (MonitorSummary, error) { 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 { if err != nil {
return MonitorSummary{}, err 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) { 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 { if err != nil {
return MonitorWorkspaceResponse{}, err 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) { 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 { if err != nil {
return MonitorMapResponse{}, err 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) { 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) { 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) { 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) { 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) { 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) { func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) {
keyword = strings.TrimSpace(keyword) keyword = strings.TrimSpace(keyword)
protocol = strings.TrimSpace(protocol) 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 { if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{ page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{keyword}, Keywords: []string{keyword},
@@ -827,6 +860,10 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
keyword := strings.TrimSpace(vin) keyword := strings.TrimSpace(vin)
protocol = strings.TrimSpace(protocol) protocol = strings.TrimSpace(protocol)
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}} 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) vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
if err != nil { if err != nil {
return VehicleDetail{}, err return VehicleDetail{}, err
@@ -843,6 +880,9 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
if queryVIN == "" { if queryVIN == "" {
queryVIN = keyword queryVIN = keyword
} }
if err := authorizeVehicleVIN(ctx, queryVIN); err != nil {
return VehicleDetail{}, err
}
if resolvedVIN == "" { if resolvedVIN == "" {
qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}} qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
if protocol != "" { if protocol != "" {
@@ -1495,6 +1535,9 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
if err != nil { if err != nil {
return LatestTelemetryResponse{}, err return LatestTelemetryResponse{}, err
} }
if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil {
return LatestTelemetryResponse{}, err
}
rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols)) rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols))
catalogResult := make(chan latestTelemetryCatalogResult, 1) catalogResult := make(chan latestTelemetryCatalogResult, 1)
for _, protocol := range canonicalVehicleProtocols { 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) { 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")) != "" { if strings.TrimSpace(resolved.Get("keywords")) != "" {
return resolved, nil return resolved, nil
} }
@@ -3629,6 +3675,9 @@ func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (ur
return nil, err return nil, err
} }
if resolvedVIN != "" { 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) resolved.Set("vin", resolvedVIN)
} }
return resolved, nil return resolved, nil
@@ -3640,6 +3689,10 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
return keyword, nil return keyword, nil
} }
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}} vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
if err != nil {
return "", err
}
if protocol = strings.TrimSpace(protocol); protocol != "" { if protocol = strings.TrimSpace(protocol); protocol != "" {
vehicleQuery.Set("protocol", protocol) vehicleQuery.Set("protocol", protocol)
} }
@@ -3654,6 +3707,44 @@ func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protoco
return identity.VIN, nil 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 { func cloneValues(values url.Values) url.Values {
cloned := make(url.Values, len(values)) cloned := make(url.Values, len(values))
for key, current := range values { for key, current := range values {

View File

@@ -51,6 +51,8 @@ type countingStore struct {
vehiclesCalls int vehiclesCalls int
vehicleRealtimeCalls int vehicleRealtimeCalls int
overviewBatchCalls int overviewBatchCalls int
lastVehicleQuery url.Values
lastRealtimeQuery url.Values
} }
func newCountingStore() *countingStore { func newCountingStore() *countingStore {
@@ -59,14 +61,33 @@ func newCountingStore() *countingStore {
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
s.vehiclesCalls++ s.vehiclesCalls++
s.lastVehicleQuery = cloneValues(query)
return s.MockStore.Vehicles(ctx, query) return s.MockStore.Vehicles(ctx, query)
} }
func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
s.vehicleRealtimeCalls++ s.vehicleRealtimeCalls++
s.lastRealtimeQuery = cloneValues(query)
return s.MockStore.VehicleRealtime(ctx, 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) { func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
s.overviewBatchCalls++ s.overviewBatchCalls++
return s.MockStore.VehicleServiceOverviews(ctx, query) return s.MockStore.VehicleServiceOverviews(ctx, query)

View File

@@ -1,5 +1,6 @@
import type { import type {
ApiEnvelope, ApiEnvelope,
AdminUser,
AccessQuery, AccessQuery,
AccessSummary, AccessSummary,
AccessThresholdConfig, AccessThresholdConfig,
@@ -41,6 +42,8 @@ import type {
RealtimeLocationRow, RealtimeLocationRow,
SourceReadinessPlan, SourceReadinessPlan,
SessionInfo, SessionInfo,
LoginResponse,
CustomerUserInput,
TrackPlaybackResponse, TrackPlaybackResponse,
VehicleRealtimeRow, VehicleRealtimeRow,
VehicleCoverageRow, VehicleCoverageRow,
@@ -171,6 +174,20 @@ function withTraceID(message: string, traceID?: string) {
export const api = { export const api = {
session: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/session', withSignal(undefined, signal)), session: (signal?: AbortSignal) => request<SessionInfo>('/api/v2/session', withSignal(undefined, signal)),
login: (credentials: { username: string; password: string }) => request<LoginResponse>('/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<AdminUser[]>('/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<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined), monitorSummary: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorSummary>(`/api/v2/monitor/summary?${params.toString()}`, signal ? { signal } : undefined),
monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined), monitorMap: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorMapResponse>(`/api/v2/monitor/map?${params.toString()}`, signal ? { signal } : undefined),
monitorWorkspace: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorWorkspaceResponse>(`/api/v2/monitor/workspace?${params.toString()}`, signal ? { signal } : undefined), monitorWorkspace: (params = new URLSearchParams(), signal?: AbortSignal) => request<MonitorWorkspaceResponse>(`/api/v2/monitor/workspace?${params.toString()}`, signal ? { signal } : undefined),

View File

@@ -5,11 +5,53 @@ export interface ApiEnvelope<T> {
} }
export interface SessionInfo { export interface SessionInfo {
subjectId?: string;
name: 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'; authMode: 'disabled' | 'enforce';
} }
export interface LoginResponse {
accessToken: string;
expiresAt: string;
session: Omit<SessionInfo, 'authMode'>;
}
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 { export interface MonitorSummary {
totalVehicles: number; totalVehicles: number;
onlineVehicles: number; onlineVehicles: number;

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 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 { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AppShell } from './layout/AppShell'; import { AppShell } from './layout/AppShell';
import { AuthGate } from './auth/AuthGate'; import { AuthGate } from './auth/AuthGate';
@@ -7,6 +7,8 @@ import { PlatformErrorBoundary, RoutePage } from './routing/RouteBoundary';
import { RoutePageFactories, RoutePages } from './routing/routeModules'; import { RoutePageFactories, RoutePages } from './routing/routeModules';
import { ROUTER_FUTURE } from './routing/routerConfig'; import { ROUTER_FUTURE } from './routing/routerConfig';
import { QUERY_MEMORY } from './queryPolicy'; import { QUERY_MEMORY } from './queryPolicy';
import { usePlatformSession } from './auth/AuthGate';
import { hasMenu } from './auth/session';
export function createPlatformQueryClient() { export function createPlatformQueryClient() {
return new QueryClient({ return new QueryClient({
@@ -35,6 +37,17 @@ function PlatformReadySignal() {
return null; 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 <Navigate to={defaultPath(session.menuKeys ?? [])} replace />;
return children;
}
export function AppV2() { export function AppV2() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -43,15 +56,16 @@ export function AppV2() {
<AuthGate><BrowserRouter future={ROUTER_FUTURE}> <AuthGate><BrowserRouter future={ROUTER_FUTURE}>
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route index element={<Navigate to="/monitor" replace />} /> <Route index element={<SessionIndex />} />
<Route path="/monitor" element={<RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" />} /> <Route path="/monitor" element={<ProtectedRoute menu="monitor"><RoutePage page={RoutePages.Monitor} recreatePage={RoutePageFactories.Monitor} label="全局监控" /></ProtectedRoute>} />
<Route path="/vehicles/:vin?" element={<RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" />} /> <Route path="/vehicles/:vin?" element={<ProtectedRoute menu="vehicles"><RoutePage page={RoutePages.Vehicles} recreatePage={RoutePageFactories.Vehicles} label="车辆查询" /></ProtectedRoute>} />
<Route path="/tracks" element={<RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" />} /> <Route path="/tracks" element={<ProtectedRoute menu="tracks"><RoutePage page={RoutePages.Tracks} recreatePage={RoutePageFactories.Tracks} label="轨迹回放" /></ProtectedRoute>} />
<Route path="/history" element={<RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" />} /> <Route path="/history" element={<ProtectedRoute menu="history"><RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" /></ProtectedRoute>} />
<Route path="/statistics" element={<RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" />} /> <Route path="/statistics" element={<ProtectedRoute menu="statistics"><RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" /></ProtectedRoute>} />
<Route path="/access" element={<RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" />} /> <Route path="/access" element={<ProtectedRoute menu="access"><RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" /></ProtectedRoute>} />
<Route path="/alerts/*" element={<RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" />} /> <Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" /></ProtectedRoute>} />
<Route path="/operations" element={<RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" />} /> <Route path="/operations" element={<ProtectedRoute menu="operations"><RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" /></ProtectedRoute>} />
<Route path="/users" element={<ProtectedRoute menu="users"><RoutePage page={RoutePages.Users} recreatePage={RoutePageFactories.Users} label="账号管理" /></ProtectedRoute>} />
<Route path="*" element={<Navigate to="/monitor" replace />} /> <Route path="*" element={<Navigate to="/monitor" replace />} />
</Route> </Route>
</Routes> </Routes>
@@ -60,3 +74,8 @@ export function AppV2() {
</QueryClientProvider> </QueryClientProvider>
); );
} }
function SessionIndex() {
const { session } = usePlatformSession();
return <Navigate to={defaultPath(session.menuKeys ?? [])} replace />;
}

View File

@@ -4,8 +4,8 @@ import { afterEach, expect, test, vi } from 'vitest';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session'; import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
import { AuthGate, usePlatformSession } from './AuthGate'; import { AuthGate, usePlatformSession } from './AuthGate';
const mocks = vi.hoisted(() => ({ session: vi.fn() })); const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn() }));
vi.mock('../../api/client', () => ({ api: { session: mocks.session } })); vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout } }));
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) { function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
const { session, logout } = usePlatformSession(); const { session, logout } = usePlatformSession();
@@ -21,10 +21,14 @@ function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
mocks.session.mockReset(); mocks.session.mockReset();
mocks.login.mockReset();
mocks.logout.mockReset();
mocks.logout.mockResolvedValue({ loggedOut: true });
window.sessionStorage.clear(); window.sessionStorage.clear();
}); });
test('treats login and logout as complete query and mutation cache boundaries', async () => { 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 } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' }); client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' });
client.getMutationCache().build(client, { mutationFn: async () => ({ ok: true }) }); 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('未登录'); if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
return { name: 'next-user', role: 'viewer', authMode: 'enforce' }; 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(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={aborted} /></AuthGate></QueryClientProvider>); render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={aborted} /></AuthGate></QueryClientProvider>);
const token = await screen.findByPlaceholderText('Bearer token'); fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'next-user' } });
fireEvent.change(token, { target: { value: 'next-user-token' } }); fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } });
fireEvent.click(screen.getByRole('button', { name: '进入平台' })); fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('next-user')).toBeInTheDocument(); expect(await screen.findByText('next-user')).toBeInTheDocument();
expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined(); 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)); await waitFor(() => expect(client.getMutationCache().getAll()).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '退出测试会话' })); fireEvent.click(screen.getByRole('button', { name: '退出测试会话' }));
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument(); expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument();
await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1)); await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1));
expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined(); expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined();
expect(client.getMutationCache().getAll()).toHaveLength(0); 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 () => { 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 } } }); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAccessToken('next-user-token'); setAccessToken('next-user-token');
mocks.session.mockImplementation(async () => { 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' }); client.setQueryData(['active-user-detail'], { plate: '粤A00002' });
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT)); 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(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined();
expect(getAccessToken()).toBe(''); expect(getAccessToken()).toBe('');
}); });

View File

@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'; 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 { api } from '../../api/client';
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session'; import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
@@ -19,8 +19,13 @@ export function usePlatformSession() {
export function AuthGate({ children }: { children: ReactNode }) { export function AuthGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tokenVersion, setTokenVersion] = useState(0); const [tokenVersion, setTokenVersion] = useState(0);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [draftToken, setDraftToken] = useState(''); const [draftToken, setDraftToken] = useState('');
const [legacyMode, setLegacyMode] = useState(false);
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken())); const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState('');
const session = useQuery({ const session = useQuery({
queryKey: ['platform-session', tokenVersion], queryKey: ['platform-session', tokenVersion],
queryFn: ({ signal }) => api.session(signal), queryFn: ({ signal }) => api.session(signal),
@@ -31,31 +36,73 @@ export function AuthGate({ children }: { children: ReactNode }) {
void queryClient.cancelQueries(); void queryClient.cancelQueries();
queryClient.clear(); queryClient.clear();
}, [queryClient]); }, [queryClient]);
const logout = useCallback(() => { const finishLogout = useCallback(() => {
clearAccessToken(); clearAccessToken();
clearClientSession(); clearClientSession();
setPassword('');
setDraftToken(''); setDraftToken('');
setAttempted(false); setAttempted(false);
setLoginError('');
setTokenVersion((value) => value + 1); setTokenVersion((value) => value + 1);
}, [clearClientSession]); }, [clearClientSession]);
const logout = useCallback(() => {
void api.logout().catch(() => undefined).finally(finishLogout);
}, [finishLogout]);
useEffect(() => { useEffect(() => {
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout); window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout); return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
}, [logout]); }, [finishLogout]);
const login = (event: FormEvent) => { const login = async (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
setAccessToken(draftToken);
clearClientSession();
setAttempted(true); setAttempted(true);
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); setTokenVersion((value) => value + 1);
} catch (error) {
clearAccessToken();
setLoginError(error instanceof Error ? error.message : '登录失败,请稍后重试');
} finally {
setLoginPending(false);
}
}; };
if (session.isPending) { if (session.isPending) {
return <div className="v2-auth-screen"><div className="v2-auth-card"><img className="v2-auth-logo" src="/brand-logo.svg" alt="牛智能" /><i className="v2-auth-spinner" /><strong>访</strong></div></div>; return <div className="v2-auth-screen"><div className="v2-auth-card v2-auth-loading"><img className="v2-auth-logo" src="/brand-logo.svg" alt="牛智能" /><i className="v2-auth-spinner" /><strong></strong></div></div>;
} }
if (!session.data) { if (!session.data) {
return <div className="v2-auth-screen"><form className="v2-auth-card" onSubmit={login}><img className="v2-auth-logo" src="/brand-logo.svg" alt="灵牛智能" /><h1></h1><p>访</p><label><span>访</span><input autoFocus required type="password" autoComplete="current-password" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label>{attempted && session.error ? <em>{session.error.message}</em> : null}<button type="submit" disabled={!draftToken.trim()}></button></form></div>; const error = loginError || (attempted && session.error ? session.error.message : '');
return <div className="v2-auth-screen">
<section className="v2-auth-intro" aria-hidden="true">
<img src="/brand-logo.svg" alt="" />
<h2><br /></h2>
<p></p>
<div><span></span><span></span><span></span></div>
</section>
<form className="v2-auth-card" onSubmit={login}>
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
<h1></h1>
<p>{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</p>
{legacyMode ? <label><span>访</span><input autoFocus required type="password" autoComplete="off" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label> : <>
<label><span></span><input autoFocus required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入用户名" /></label>
<label><span></span><input required type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></label>
</>}
{error ? <em role="alert">{error}</em> : null}
<button type="submit" disabled={loginPending || (legacyMode ? !draftToken.trim() : !username.trim() || !password)}>{loginPending ? '正在登录…' : '登录'}</button>
<button className="v2-auth-mode-switch" type="button" onClick={() => { setLegacyMode((value) => !value); setLoginError(''); }}>
{legacyMode ? '返回账号密码登录' : '使用运维令牌登录'}
</button>
<small></small>
</form>
</div>;
} }
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
} }

View File

@@ -1,13 +1,7 @@
const TOKEN_KEY = 'vehicle-platform.access-token'; const TOKEN_KEY = 'vehicle-platform.access-token';
export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized'; export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized';
export type PlatformRole = 'viewer' | 'operator' | 'admin'; export type PlatformSession = SessionInfo;
export interface PlatformSession {
name: string;
role: PlatformRole;
authMode: 'disabled' | 'enforce';
}
export function getAccessToken() { export function getAccessToken() {
return window.sessionStorage.getItem(TOKEN_KEY) ?? ''; return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
@@ -34,6 +28,13 @@ export function canOperate(session: PlatformSession) {
return session.role === 'operator' || session.role === 'admin'; 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) { export function canAdminister(session: PlatformSession) {
return session.role === 'admin'; return session.role === 'admin';
} }
import type { SessionInfo } from '../../api/types';

View File

@@ -8,10 +8,11 @@ const routing = vi.hoisted(() => ({
scheduleIdleRoutePreloads: vi.fn(() => vi.fn()), scheduleIdleRoutePreloads: vi.fn(() => vi.fn()),
shouldPreloadRouteOnIntent: vi.fn(() => true) 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('../routing/routeModules', () => routing);
vi.mock('../auth/AuthGate', () => ({ 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'; import { AppShell } from './AppShell';
@@ -19,6 +20,24 @@ import { AppShell } from './AppShell';
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); 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(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor']}>
<Routes><Route element={<AppShell />}><Route path="*" element={<span></span>} /></Route></Routes>
</MemoryRouter>);
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 () => { test('reschedules likely route preloads when the active module changes', async () => {

View File

@@ -12,19 +12,22 @@ import {
IconUser, IconUser,
IconExit IconExit
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useEffect, useState } from 'react'; import { FormEvent, useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom'; import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { api } from '../../api/client';
import { usePlatformSession } from '../auth/AuthGate'; import { usePlatformSession } from '../auth/AuthGate';
import { hasMenu } from '../auth/session';
import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules'; import { preloadRoute, scheduleIdleRoutePreloads, shouldPreloadRouteOnIntent } from '../routing/routeModules';
const navigation = [ const navigation = [
{ to: '/monitor', label: '全局监控', icon: IconHome }, { to: '/monitor', menu: 'monitor', label: '全局监控', icon: IconHome },
{ to: '/vehicles', label: '车辆查询', icon: IconSearch }, { to: '/vehicles', menu: 'vehicles', label: '车辆查询', icon: IconSearch },
{ to: '/tracks', label: '轨迹回放', icon: IconMapPin }, { to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconMapPin },
{ to: '/history', label: '历史数据', icon: IconBarChartHStroked }, { to: '/history', menu: 'history', label: '历史数据', icon: IconBarChartHStroked },
{ to: '/statistics', label: '里程查询', icon: IconBarChartHStroked }, { to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartHStroked },
{ to: '/alerts', label: '告警中心', icon: IconAlarm }, { to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm },
{ to: '/access', label: '接入管理', icon: IconBox } { to: '/access', menu: 'access', label: '接入管理', icon: IconBox },
{ to: '/users', menu: 'users', label: '账号管理', icon: IconUser }
]; ];
const pageNames: Record<string, string> = { const pageNames: Record<string, string> = {
@@ -35,7 +38,8 @@ const pageNames: Record<string, string> = {
statistics: '里程查询', statistics: '里程查询',
alerts: '告警中心', alerts: '告警中心',
access: '接入管理', access: '接入管理',
operations: '运维质量' operations: '运维质量',
users: '账号管理'
}; };
const pageHelp: Record<string, { summary: string; tips: string[] }> = { const pageHelp: Record<string, { summary: string; tips: string[] }> = {
@@ -46,7 +50,8 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] }, statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] },
alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] }, alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] },
access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] }, access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] },
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] } operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] },
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
}; };
function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) { function ContextHelp({ section, onClose }: { section: string; onClose: () => void }) {
@@ -72,8 +77,9 @@ export function AppShell() {
const activeRoutePath = `/${section}`; const activeRoutePath = `/${section}`;
const { session, logout } = usePlatformSession(); const { session, logout } = usePlatformSession();
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [passwordOpen, setPasswordOpen] = useState(false);
const [mobileLayout, setMobileLayout] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 680px)').matches); 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(() => scheduleIdleRoutePreloads({ activePathname: activeRoutePath }), [activeRoutePath]);
useEffect(() => { useEffect(() => {
const media = window.matchMedia('(max-width: 680px)'); const media = window.matchMedia('(max-width: 680px)');
@@ -91,24 +97,61 @@ export function AppShell() {
<h1>{pageNames[section] ?? '车辆数据中台'}</h1> <h1>{pageNames[section] ?? '车辆数据中台'}</h1>
<div className="v2-topbar-actions"> <div className="v2-topbar-actions">
<button type="button" aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><IconHelpCircle /></button> <button type="button" aria-label="帮助" aria-expanded={helpOpen} aria-controls="v2-context-help" onClick={() => setHelpOpen(true)}><IconHelpCircle /></button>
<span className="v2-current-user"><IconUser /><b>{session.name}</b><small>{roleLabel}</small></span> <button type="button" className="v2-current-user" title="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /><b>{session.name}</b><small>{roleLabel}</small></button>
<button type="button" className="v2-password-mobile" aria-label="修改密码" onClick={() => setPasswordOpen(true)}><IconUser /></button>
<button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button> <button type="button" aria-label="退出登录" title="退出登录" onClick={logout}><IconExit /></button>
</div> </div>
</header> </header>
<main className="v2-content"><Outlet /></main> <main className="v2-content"><Outlet /></main>
</div> </div>
{helpOpen ? <div id="v2-context-help"><ContextHelp section={section} onClose={() => setHelpOpen(false)} /></div> : null} {helpOpen ? <div id="v2-context-help"><ContextHelp section={section} onClose={() => setHelpOpen(false)} /></div> : null}
{passwordOpen ? <PasswordDialog onClose={() => setPasswordOpen(false)} onChanged={logout} /> : null}
</div> </div>
); );
} }
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 <div className="v2-password-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget && !pending) onClose(); }}><form className="v2-password-dialog" role="dialog" aria-modal="true" aria-labelledby="v2-password-title" onSubmit={submit}>
<header><div><h2 id="v2-password-title"></h2><p></p></div><button type="button" aria-label="关闭修改密码" onClick={onClose}>×</button></header>
<label><span></span><input autoFocus required type="password" autoComplete="current-password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} /></label>
<label><span></span><input required minLength={10} type="password" autoComplete="new-password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><input required minLength={10} type="password" autoComplete="new-password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} /></label>
{error ? <em role="alert">{error}</em> : null}
<footer><button type="button" onClick={onClose} disabled={pending}></button><button type="submit" disabled={pending || !currentPassword || !newPassword || !confirmPassword}>{pending ? '正在修改…' : '确认修改'}</button></footer>
</form></div>;
}
const mobilePrimaryNavigation = [navigation[0], navigation[1], navigation[2], navigation[4]]; 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() { function MobileNavigation() {
const location = useLocation(); const location = useLocation();
const { session } = usePlatformSession();
const [moreOpen, setMoreOpen] = useState(false); 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); }; const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
useEffect(() => setMoreOpen(false), [location.pathname]); useEffect(() => setMoreOpen(false), [location.pathname]);
useEffect(() => { useEffect(() => {
@@ -120,17 +163,19 @@ function MobileNavigation() {
const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>; const link = ({ to, label, icon: Icon }: (typeof navigation)[number]) => <NavLink key={to} to={to} aria-label={label} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-mobile-nav-item ${isActive ? 'is-active' : ''}`}><Icon size="large" /><span>{label}</span></NavLink>;
return <> return <>
{moreOpen ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong></strong><span></span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{mobileMoreNavigation.map(link)}</nav></section></div> : null} {moreOpen && more.length ? <div className="v2-mobile-more-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setMoreOpen(false); }}><section className="v2-mobile-more-sheet" role="dialog" aria-modal="true" aria-label="更多功能"><header><div><strong></strong><span></span></div><button type="button" aria-label="关闭更多功能" onClick={() => setMoreOpen(false)}>×</button></header><nav>{more.map(link)}</nav></section></div> : null}
<nav className="v2-mobile-navigation" aria-label="主导航"> <nav className="v2-mobile-navigation" aria-label="主导航">
{mobilePrimaryNavigation.map(link)} {primary.map(link)}
<button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span></span></button> {more.length ? <button type="button" className={`v2-mobile-nav-item${moreActive ? ' is-active' : ''}`} aria-label="更多功能" aria-expanded={moreOpen} onClick={() => setMoreOpen((value) => !value)}><IconMore size="large" /><span></span></button> : null}
</nav> </nav>
</>; </>;
} }
function Sidebar() { function Sidebar() {
const { session } = usePlatformSession();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); }; const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
const visibleNavigation = navigation.filter((item) => hasMenu(session, item.menu));
return ( return (
<aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}> <aside className={`v2-sidebar${collapsed ? ' is-collapsed' : ''}`}>
@@ -139,17 +184,17 @@ function Sidebar() {
<img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" /> <img className="v2-brand-symbol" src="/brand-mark.svg" alt="" aria-hidden="true" />
</div> </div>
<nav className="v2-navigation" aria-label="主导航"> <nav className="v2-navigation" aria-label="主导航">
{navigation.map(({ to, label, icon: Icon }) => ( {visibleNavigation.map(({ to, label, icon: Icon }) => (
<NavLink key={to} to={to} aria-label={label} title={collapsed ? label : undefined} onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}> <NavLink key={to} to={to} aria-label={label} title={collapsed ? label : undefined} onPointerEnter={() => warmRoute(to)} onFocus={() => warmRoute(to)} onPointerDown={() => warmRoute(to)} className={({ isActive }) => `v2-nav-item ${isActive ? 'is-active' : ''}`}>
<Icon size="large" /> <Icon size="large" />
<span className="v2-nav-label">{label}</span> <span className="v2-nav-label">{label}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
<NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}> {hasMenu(session, 'operations') ? <NavLink to="/operations" aria-label="运维质量" title={collapsed ? '运维质量' : undefined} onPointerEnter={() => warmRoute('/operations')} onFocus={() => warmRoute('/operations')} onPointerDown={() => warmRoute('/operations')} className={({ isActive }) => `v2-nav-item v2-nav-operations ${isActive ? 'is-active' : ''}`}>
<IconSetting size="large" /> <IconSetting size="large" />
<span className="v2-nav-label"></span> <span className="v2-nav-label"></span>
</NavLink> </NavLink> : null}
<button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}> <button className="v2-collapse" type="button" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? '展开侧栏' : '收起侧栏'}>
<IconChevronLeft /> <IconChevronLeft />
<span></span> <span></span>

View File

@@ -0,0 +1,137 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import type { AdminUser, CustomerUserInput } from '../../api/types';
const customerMenus = [
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
{ key: 'vehicles', label: '车辆查询', description: '查看车辆档案与最新遥测' },
{ key: 'tracks', label: '轨迹回放', description: '查询授权车辆的历史轨迹' },
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
];
type Draft = {
username: string;
displayName: string;
password: string;
status: 'enabled' | 'disabled';
customerRef: string;
tenantRef: string;
menuKeys: string[];
vehicleVins: string[];
};
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleVins: [] };
function draftFromUser(user?: AdminUser): Draft {
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleVins: [] };
return { username: user.username, displayName: user.displayName, password: '', status: user.status, customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], vehicleVins: [...user.vehicleVins] };
}
function formatTime(value?: string) {
if (!value) return '尚未登录';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
export default function UsersPage() {
const queryClient = useQueryClient();
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
const [selectedID, setSelectedID] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const selected = useMemo(() => customers.find((user) => user.id === selectedID), [customers, selectedID]);
const [draft, setDraft] = useState<Draft>(() => draftFromUser());
const [vehicleKeyword, setVehicleKeyword] = useState('');
const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim());
const [bulkVINs, setBulkVINs] = useState('');
const [feedback, setFeedback] = useState('');
useEffect(() => {
if (!creating && selected) setDraft(draftFromUser(selected));
}, [creating, selected]);
const candidates = useQuery({
queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword],
queryFn: ({ signal }) => api.vehicles(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal),
enabled: deferredVehicleKeyword.length >= 1,
staleTime: 30_000
});
const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]);
const save = useMutation({
mutationFn: async () => {
const input: CustomerUserInput = { displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, vehicleVins: draft.vehicleVins };
if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password });
if (!selected) throw new Error('请先选择客户账号');
return api.updateCustomerUser(selected.id, input);
},
onSuccess: async (result) => {
setFeedback(creating ? '客户账号已创建' : '账号与权限已更新');
setCreating(false);
setSelectedID(result.id);
setDraft((value) => ({ ...value, password: '' }));
await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
},
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
});
const startCreate = () => {
setCreating(true);
setSelectedID(null);
setDraft(draftFromUser());
setVehicleKeyword('');
setBulkVINs('');
setFeedback('');
};
const selectCustomer = (user: AdminUser) => {
setCreating(false);
setSelectedID(user.id);
setDraft(draftFromUser(user));
setVehicleKeyword('');
setBulkVINs('');
setFeedback('');
};
const toggleVIN = (vin: string) => setDraft((value) => ({ ...value, vehicleVins: value.vehicleVins.includes(vin) ? value.vehicleVins.filter((item) => item !== vin) : [...value.vehicleVins, vin].sort() }));
const addBulkVINs = () => {
const next = bulkVINs.split(/[\s,;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean);
setDraft((value) => ({ ...value, vehicleVins: [...new Set([...value.vehicleVins, ...next])].sort() }));
setBulkVINs('');
};
const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); };
return <div className="v2-user-admin">
<header className="v2-user-admin-heading">
<div><h2></h2><p> 30 </p></div>
<button type="button" onClick={startCreate}></button>
</header>
<div className="v2-user-admin-grid">
<aside className="v2-user-list">
<div className="v2-user-list-summary"><strong>{customers.length}</strong><span></span></div>
{users.isPending ? <p className="v2-user-empty"></p> : customers.length === 0 ? <p className="v2-user-empty"></p> : customers.map((user) => <button key={user.id} type="button" className={selectedID === user.id && !creating ? 'is-active' : ''} onClick={() => selectCustomer(user)}>
<span className="v2-user-avatar">{user.displayName.slice(0, 1)}</span>
<span><b>{user.displayName}</b><small>@{user.username} · {user.vehicleVins.length} </small></span>
<i className={user.status === 'enabled' ? 'is-enabled' : ''}>{user.status === 'enabled' ? '启用' : '停用'}</i>
</button>)}
</aside>
<main className="v2-user-editor">
{!creating && !selected ? <div className="v2-user-editor-empty"><strong></strong><p></p><button type="button" onClick={startCreate}></button></div> : <form onSubmit={submit}>
<div className="v2-user-editor-title"><div><h3>{creating ? '创建客户账号' : selected?.displayName}</h3><p>{creating ? '设置登录身份和最小必要权限' : `最近登录:${formatTime(selected?.lastLoginAt)}`}</p></div><label className="v2-user-status"><input type="checkbox" checked={draft.status === 'enabled'} onChange={(event) => setDraft((value) => ({ ...value, status: event.target.checked ? 'enabled' : 'disabled' }))} /><span>{draft.status === 'enabled' ? '账号启用' : '账号停用'}</span></label></div>
<section><h4></h4><div className="v2-user-fields">
<label><span></span><input required disabled={!creating} value={draft.username} onChange={(event) => setDraft((value) => ({ ...value, username: event.target.value }))} placeholder="例如 customer-huadong" /></label>
<label><span></span><input required value={draft.displayName} onChange={(event) => setDraft((value) => ({ ...value, displayName: event.target.value }))} placeholder="显示在平台右上角" /></label>
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><input required={creating} type="password" autoComplete="new-password" value={draft.password} onChange={(event) => setDraft((value) => ({ ...value, password: event.target.value }))} placeholder="至少 10 位,包含三类字符" /></label>
<label><span></span><input value={draft.customerRef} onChange={(event) => setDraft((value) => ({ ...value, customerRef: event.target.value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
</div></section>
<section><h4> <small></small></h4><div className="v2-menu-permissions">{customerMenus.map((menu) => <label key={menu.key} className={draft.menuKeys.includes(menu.key) ? 'is-selected' : ''}><input type="checkbox" checked={draft.menuKeys.includes(menu.key)} onChange={() => setDraft((value) => ({ ...value, menuKeys: value.menuKeys.includes(menu.key) ? value.menuKeys.filter((item) => item !== menu.key) : [...value.menuKeys, menu.key] }))} /><span><b>{menu.label}</b><small>{menu.description}</small></span></label>)}</div></section>
<section><div className="v2-vehicle-permission-heading"><h4> <small> {draft.vehicleVins.length} </small></h4>{draft.vehicleVins.length ? <button type="button" onClick={() => setDraft((value) => ({ ...value, vehicleVins: [] }))}></button> : null}</div>
<div className="v2-vehicle-permission-tools"><label><span> VIN </span><input value={vehicleKeyword} onChange={(event) => setVehicleKeyword(event.target.value)} placeholder="输入后显示候选车辆" /></label><label><span> VIN</span><span className="v2-bulk-vin"><input value={bulkVINs} onChange={(event) => setBulkVINs(event.target.value)} placeholder="空格、逗号或换行分隔" /><button type="button" disabled={!bulkVINs.trim()} onClick={addBulkVINs}></button></span></label></div>
{deferredVehicleKeyword ? <div className="v2-vehicle-candidates">{candidates.isPending ? <p></p> : (candidates.data?.items ?? []).length === 0 ? <p></p> : candidates.data?.items.map((vehicle) => <button type="button" className={assigned.has(vehicle.vin) ? 'is-selected' : ''} key={`${vehicle.vin}-${vehicle.protocol}`} onClick={() => toggleVIN(vehicle.vin)}><span><b>{vehicle.plate || '未登记车牌'}</b><small>{vehicle.vin}</small></span><i>{assigned.has(vehicle.vin) ? '已分配' : '选择'}</i></button>)}</div> : null}
{draft.vehicleVins.length ? <div className="v2-assigned-vins">{draft.vehicleVins.map((vin) => <button type="button" key={vin} onClick={() => toggleVIN(vin)}>{vin}<span>×</span></button>)}</div> : <p className="v2-user-empty"></p>}
</section>
{feedback ? <p className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} role="status">{feedback}</p> : null}
<footer><button type="submit" disabled={save.isPending}>{save.isPending ? '正在保存…' : creating ? '创建账号' : '保存权限'}</button></footer>
</form>}
</main>
</div>
</div>;
}

View File

@@ -1,6 +1,6 @@
import { lazy, type ComponentType } from 'react'; import { lazy, type ComponentType } from 'react';
export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations'; export type RouteSection = 'monitor' | 'vehicles' | 'tracks' | 'history' | 'statistics' | 'access' | 'alerts' | 'operations' | 'users';
type RouteModule = { default: ComponentType }; type RouteModule = { default: ComponentType };
type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string }; type RoutePreloadConnection = { saveData?: boolean; effectiveType?: string };
type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number }; type RoutePreloadRuntime = { visibilityState?: DocumentVisibilityState; deviceMemory?: number };
@@ -18,7 +18,8 @@ const importers: Record<RouteSection, () => Promise<RouteModule>> = {
statistics: () => import('../pages/StatisticsPage'), statistics: () => import('../pages/StatisticsPage'),
access: () => import('../pages/AccessPage'), access: () => import('../pages/AccessPage'),
alerts: () => import('../pages/AlertsPage'), alerts: () => import('../pages/AlertsPage'),
operations: () => import('../pages/OperationsPage') operations: () => import('../pages/OperationsPage'),
users: () => import('../pages/UsersPage')
}; };
const pendingModules = new Map<RouteSection, Promise<RouteModule>>(); const pendingModules = new Map<RouteSection, Promise<RouteModule>>();
@@ -30,7 +31,8 @@ const likelyNextRoutes: Record<RouteSection, RouteSection[]> = {
statistics: ['history', 'vehicles'], statistics: ['history', 'vehicles'],
access: ['monitor', 'operations'], access: ['monitor', 'operations'],
alerts: ['monitor', 'access'], alerts: ['monitor', 'access'],
operations: ['access', 'monitor'] operations: ['access', 'monitor'],
users: ['monitor', 'vehicles']
}; };
function routeLoadTimeout<T>(promise: Promise<T>, section: string, timeoutMs: number) { function routeLoadTimeout<T>(promise: Promise<T>, section: string, timeoutMs: number) {
@@ -195,7 +197,8 @@ export const RoutePages = {
Statistics: createRouteComponent('statistics'), Statistics: createRouteComponent('statistics'),
Access: createRouteComponent('access'), Access: createRouteComponent('access'),
Alerts: createRouteComponent('alerts'), Alerts: createRouteComponent('alerts'),
Operations: createRouteComponent('operations') Operations: createRouteComponent('operations'),
Users: createRouteComponent('users')
} as const; } as const;
export const RoutePageFactories = { export const RoutePageFactories = {
@@ -206,5 +209,6 @@ export const RoutePageFactories = {
Statistics: () => createRouteComponent('statistics'), Statistics: () => createRouteComponent('statistics'),
Access: () => createRouteComponent('access'), Access: () => createRouteComponent('access'),
Alerts: () => createRouteComponent('alerts'), Alerts: () => createRouteComponent('alerts'),
Operations: () => createRouteComponent('operations') Operations: () => createRouteComponent('operations'),
Users: () => createRouteComponent('users')
} as const; } as const;

View File

@@ -18,13 +18,23 @@ body { margin: 0; background: var(--v2-bg); color: var(--v2-text); font-family:
button, input, select { font: inherit; } button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; } button, a { -webkit-tap-highlight-color: transparent; }
.v2-auth-screen { display: grid; min-height: 100vh; place-items: center; background: radial-gradient(circle at 50% 10%, #edf4ff 0, #f4f7fb 42%, #eef2f7 100%); padding: 20px; } .v2-auth-screen { display: grid; min-height: 100vh; grid-template-columns: minmax(360px, 1.15fr) minmax(420px, .85fr); align-items: center; background: #f3f6fa; padding: clamp(28px, 5vw, 80px); }
.v2-auth-card { display: flex; width: min(390px, 100%); flex-direction: column; align-items: stretch; border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 34px; box-shadow: 0 22px 70px rgba(21, 32, 51, .12); } .v2-auth-intro { max-width: 640px; padding: 36px 8vw 36px 4vw; }
.v2-auth-intro > img { width: 176px; }
.v2-auth-intro h2 { margin: 54px 0 20px; color: #14223a; font-size: clamp(34px, 4vw, 58px); font-weight: 720; line-height: 1.16; letter-spacing: -.055em; }
.v2-auth-intro p { max-width: 520px; margin: 0; color: #617087; font-size: 16px; line-height: 1.9; }
.v2-auth-intro > div { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 42px; color: #3f5068; font-size: 13px; font-weight: 650; }
.v2-auth-intro > div span::before { display: inline-block; width: 7px; height: 7px; margin-right: 8px; border-radius: 50%; background: var(--v2-blue); content: ""; }
.v2-auth-card { display: flex; width: min(430px, 100%); flex-direction: column; align-items: stretch; border: 1px solid #e0e7f0; border-radius: 18px; background: #fff; padding: 42px; box-shadow: 0 28px 80px rgba(21, 32, 51, .11); }
.v2-auth-loading { grid-column: 1 / -1; justify-self: center; }
.v2-auth-card > strong { margin-top: 12px; text-align: center; } .v2-auth-card > strong { margin-top: 12px; text-align: center; }
.v2-auth-logo { display: block; width: 150px; height: auto; margin: 0 auto 18px; } .v2-auth-logo { display: block; width: 150px; height: auto; margin: 0 auto 18px; }
.v2-auth-card h1 { margin: 0; text-align: center; font-size: 22px; }.v2-auth-card p { margin: 10px 0 24px; color: var(--v2-muted); text-align: center; font-size: 12px; line-height: 1.7; } .v2-auth-card h1 { margin: 2px 0 0; text-align: center; font-size: 24px; letter-spacing: -.035em; }.v2-auth-card p { margin: 11px 0 26px; color: var(--v2-muted); text-align: center; font-size: 13px; line-height: 1.7; }
.v2-auth-card label { display: flex; flex-direction: column; gap: 7px; color: #58667a; font-size: 12px; font-weight: 600; }.v2-auth-card input { height: 40px; border: 1px solid #d7e0ec; border-radius: 8px; padding: 0 11px; outline: 0; }.v2-auth-card input:focus { border-color: #8bb6fb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); } .v2-auth-card label { display: flex; flex-direction: column; gap: 8px; margin-top: 15px; color: #44536a; font-size: 13px; font-weight: 650; }.v2-auth-card input { height: 46px; border: 1px solid #d5deea; border-radius: 9px; padding: 0 13px; outline: 0; font-size: 14px; }.v2-auth-card input:focus { border-color: #7aaafb; box-shadow: 0 0 0 3px rgba(18,104,243,.08); }
.v2-auth-card em { margin-top: 9px; color: var(--v2-red); font-size: 11px; font-style: normal; }.v2-auth-card button { height: 40px; margin-top: 18px; border: 0; border-radius: 8px; background: var(--v2-blue); color: #fff; cursor: pointer; font-weight: 700; }.v2-auth-card button:disabled { opacity: .45; cursor: not-allowed; } .v2-auth-card em { margin-top: 12px; color: var(--v2-red); font-size: 12px; font-style: normal; }.v2-auth-card button { height: 46px; margin-top: 22px; border: 0; border-radius: 9px; background: var(--v2-blue); color: #fff; cursor: pointer; font-size: 14px; font-weight: 700; }.v2-auth-card button:disabled { opacity: .45; cursor: not-allowed; }
.v2-auth-card .v2-auth-mode-switch { height: 36px; margin-top: 8px; background: transparent; color: #68778c; font-size: 12px; font-weight: 600; }
.v2-auth-card .v2-auth-mode-switch:hover { color: var(--v2-blue); }
.v2-auth-card > small { margin-top: 14px; color: #9aa5b5; text-align: center; font-size: 10px; }
.v2-auth-spinner { width: 24px; height: 24px; margin: auto; border: 3px solid #d8e5fa; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; } .v2-auth-spinner { width: 24px; height: 24px; margin: auto; border: 3px solid #d8e5fa; border-top-color: var(--v2-blue); border-radius: 50%; animation: v2-spin .8s linear infinite; }
.v2-shell { height: 100vh; height: 100dvh; overflow: hidden; background: var(--v2-bg); } .v2-shell { height: 100vh; height: 100dvh; overflow: hidden; background: var(--v2-bg); }
@@ -58,6 +68,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-help-panel > footer { display: flex; align-items: center; gap: 6px; margin-top: 14px; color: #8996a8; font-size: 10px; } .v2-help-panel > footer { display: flex; align-items: center; gap: 6px; margin-top: 14px; color: #8996a8; font-size: 10px; }
.v2-help-panel kbd { border: 1px solid #dce4ef; border-radius: 4px; background: #fff; padding: 2px 5px; color: #59677c; font-family: inherit; } .v2-help-panel kbd { border: 1px solid #dce4ef; border-radius: 4px; background: #fff; padding: 2px 5px; color: #59677c; font-family: inherit; }
.v2-current-user { display: flex; align-items: center; gap: 6px; margin: 0 5px; color: #59677c; font-size: 11px; }.v2-current-user b { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-current-user small, .v2-role-badge { border-radius: 10px; background: var(--v2-blue-soft); padding: 2px 6px; color: var(--v2-blue); font-size: 9px; } .v2-current-user { display: flex; align-items: center; gap: 6px; margin: 0 5px; color: #59677c; font-size: 11px; }.v2-current-user b { max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-current-user small, .v2-role-badge { border-radius: 10px; background: var(--v2-blue-soft); padding: 2px 6px; color: var(--v2-blue); font-size: 9px; }
.v2-topbar-actions .v2-current-user { display: flex; width: auto; padding: 0 7px; }
.v2-password-mobile { display: none !important; }
.v2-password-backdrop { position: fixed; z-index: 1400; inset: 0; display: grid; place-items: center; background: rgba(15,23,42,.28); padding: 16px; }
.v2-password-dialog { width: min(410px,100%); border: 1px solid #e0e7ef; border-radius: 14px; background: #fff; padding: 22px; box-shadow: 0 28px 80px rgba(15,23,42,.2); }
.v2-password-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }.v2-password-dialog h2 { margin: 0; font-size: 19px; }.v2-password-dialog header p { margin: 6px 0 0; color: #8491a3; font-size: 10px; }.v2-password-dialog header button { width: 30px; height: 30px; border: 0; border-radius: 7px; background: #f2f5f8; color: #66758a; cursor: pointer; }
.v2-password-dialog > label { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; color: #5a6980; font-size: 11px; font-weight: 650; }.v2-password-dialog input { height: 40px; border: 1px solid #d8e1ec; border-radius: 8px; padding: 0 10px; outline: 0; font-size: 13px; }.v2-password-dialog input:focus { border-color: #84b0f7; box-shadow: 0 0 0 3px rgba(18,104,243,.07); }.v2-password-dialog > em { display: block; margin-top: 10px; color: var(--v2-red); font-size: 10px; font-style: normal; }
.v2-password-dialog footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }.v2-password-dialog footer button { height: 36px; border: 1px solid #d7e0eb; border-radius: 7px; background: #fff; padding: 0 14px; color: #5d6d83; cursor: pointer; font-size: 11px; font-weight: 650; }.v2-password-dialog footer button[type="submit"] { border-color: var(--v2-blue); background: var(--v2-blue); color: #fff; }.v2-password-dialog footer button:disabled { opacity: .5; }
.v2-role-notice { margin: 6px 0; border-radius: 6px; background: #f6f8fb; padding: 8px; color: var(--v2-muted); font-size: 8px; line-height: 1.5; } .v2-role-notice { margin: 6px 0; border-radius: 6px; background: #f6f8fb; padding: 8px; color: var(--v2-muted); font-size: 8px; line-height: 1.5; }
.v2-content { min-width: 0; min-height: 0; flex: 1; overflow: auto; } .v2-content { min-width: 0; min-height: 0; flex: 1; overflow: auto; }
.v2-sidebar.is-collapsed { width: 68px; } .v2-sidebar.is-collapsed { width: 68px; }
@@ -1359,6 +1376,78 @@ button, a { -webkit-tap-highlight-color: transparent; }
.v2-mileage-evidence { flex-direction: column; line-height: 1.5; } .v2-mileage-evidence { flex-direction: column; line-height: 1.5; }
} }
/* Account administration follows the platform's open table/workspace layout. */
.v2-user-admin { min-height: 100%; padding: 22px 24px 34px; }
.v2-user-admin-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin: 0 auto 18px; max-width: 1480px; }
.v2-user-admin-heading h2 { margin: 0; font-size: 22px; letter-spacing: -.035em; }
.v2-user-admin-heading p { margin: 7px 0 0; color: var(--v2-muted); font-size: 12px; }
.v2-user-admin-heading > button, .v2-user-editor form > footer button, .v2-user-editor-empty button { height: 38px; border: 0; border-radius: 8px; background: var(--v2-blue); padding: 0 16px; color: #fff; cursor: pointer; font-size: 12px; font-weight: 700; }
.v2-user-admin-grid { display: grid; max-width: 1480px; min-height: 690px; margin: 0 auto; grid-template-columns: 300px minmax(0, 1fr); overflow: hidden; border: 1px solid var(--v2-border); border-radius: 13px; background: #fff; box-shadow: var(--v2-shadow); }
.v2-user-list { min-width: 0; border-right: 1px solid var(--v2-border); background: #f9fbfd; padding: 12px; }
.v2-user-list-summary { display: flex; align-items: baseline; gap: 7px; padding: 8px 9px 14px; color: #7a8798; font-size: 11px; }
.v2-user-list-summary strong { color: #26364e; font-size: 20px; }
.v2-user-list > button { display: grid; width: 100%; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 10px; border: 0; border-radius: 9px; background: transparent; padding: 10px; color: #273750; text-align: left; cursor: pointer; }
.v2-user-list > button:hover { background: #f0f4f9; }
.v2-user-list > button.is-active { background: #eaf2ff; box-shadow: inset 3px 0 var(--v2-blue); }
.v2-user-avatar { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 9px; background: #dfe9f8; color: #3a5e91; font-weight: 800; }
.v2-user-list button > span:nth-child(2) { min-width: 0; }
.v2-user-list button b, .v2-user-list button small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-user-list button b { font-size: 12px; }
.v2-user-list button small { margin-top: 4px; color: #8290a3; font-size: 9px; }
.v2-user-list button i { border-radius: 10px; background: #f0f2f5; padding: 3px 6px; color: #8a96a6; font-size: 8px; font-style: normal; }
.v2-user-list button i.is-enabled { background: #e9f8f2; color: #16805b; }
.v2-user-editor { min-width: 0; }
.v2-user-editor-empty { display: grid; height: 100%; place-content: center; justify-items: center; color: #4a5b72; text-align: center; }
.v2-user-editor-empty strong { font-size: 18px; }.v2-user-editor-empty p { margin: 8px 0 18px; color: #8a96a7; font-size: 12px; }
.v2-user-editor form { height: 100%; padding: 24px 28px 82px; }
.v2-user-editor-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; padding-bottom: 20px; border-bottom: 1px solid #edf1f5; }
.v2-user-editor-title h3 { margin: 0; font-size: 20px; }.v2-user-editor-title p { margin: 6px 0 0; color: #8591a2; font-size: 10px; }
.v2-user-status { display: flex; align-items: center; gap: 8px; color: #45556c; font-size: 11px; font-weight: 650; }
.v2-user-status input { width: 16px; height: 16px; accent-color: var(--v2-blue); }
.v2-user-editor section { padding: 20px 0; border-bottom: 1px solid #edf1f5; }
.v2-user-editor h4 { margin: 0 0 14px; color: #2c3d54; font-size: 13px; }
.v2-user-editor h4 small { margin-left: 7px; color: #91a0b2; font-size: 9px; font-weight: 500; }
.v2-user-fields { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
.v2-user-fields label, .v2-vehicle-permission-tools label { display: flex; min-width: 0; flex-direction: column; gap: 6px; color: #637188; font-size: 10px; font-weight: 650; }
.v2-user-fields input, .v2-vehicle-permission-tools input { width: 100%; height: 38px; border: 1px solid #dbe3ed; border-radius: 7px; padding: 0 10px; outline: 0; color: #273750; font-size: 12px; }
.v2-user-fields input:focus, .v2-vehicle-permission-tools input:focus { border-color: #8ab4f7; box-shadow: 0 0 0 3px rgba(18,104,243,.07); }
.v2-user-fields input:disabled { background: #f5f7fa; color: #8390a0; }
.v2-menu-permissions { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 9px; }
.v2-menu-permissions label { display: flex; min-width: 0; align-items: flex-start; gap: 10px; border: 1px solid #e0e7ef; border-radius: 8px; padding: 11px; cursor: pointer; }
.v2-menu-permissions label.is-selected { border-color: #a9c8fb; background: #f3f7ff; }
.v2-menu-permissions input { margin-top: 2px; accent-color: var(--v2-blue); }.v2-menu-permissions span { min-width: 0; }.v2-menu-permissions b, .v2-menu-permissions small { display: block; }.v2-menu-permissions b { color: #33445c; font-size: 11px; }.v2-menu-permissions small { margin-top: 4px; color: #8491a3; font-size: 9px; line-height: 1.5; }
.v2-vehicle-permission-heading { display: flex; align-items: center; justify-content: space-between; }.v2-vehicle-permission-heading h4 { margin-bottom: 14px; }.v2-vehicle-permission-heading > button { border: 0; background: transparent; color: #7d899a; cursor: pointer; font-size: 10px; }
.v2-vehicle-permission-tools { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
.v2-bulk-vin { display: flex; gap: 6px; }.v2-bulk-vin button { flex: 0 0 auto; border: 1px solid #cbd8e9; border-radius: 7px; background: #f7f9fc; padding: 0 11px; color: #46607e; cursor: pointer; font-size: 10px; }
.v2-vehicle-candidates { display: grid; max-height: 194px; margin-top: 10px; grid-template-columns: repeat(2,minmax(0,1fr)); overflow: auto; border: 1px solid #e1e7ef; border-radius: 8px; padding: 5px; }
.v2-vehicle-candidates > p { grid-column: 1/-1; margin: 16px; color: #8996a7; font-size: 10px; }
.v2-vehicle-candidates > button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 6px; background: transparent; padding: 8px; text-align: left; cursor: pointer; }.v2-vehicle-candidates > button:hover { background: #f4f7fa; }.v2-vehicle-candidates > button.is-selected { background: #edf4ff; }
.v2-vehicle-candidates b, .v2-vehicle-candidates small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-vehicle-candidates b { color: #34455c; font-size: 10px; }.v2-vehicle-candidates small { margin-top: 3px; color: #8a96a6; font-size: 8px; }.v2-vehicle-candidates i { color: var(--v2-blue); font-size: 9px; font-style: normal; }
.v2-assigned-vins { display: flex; max-height: 118px; flex-wrap: wrap; gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { height: 26px; border: 1px solid #dbe4ef; border-radius: 6px; background: #f7f9fc; padding: 0 7px; color: #52637a; cursor: pointer; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }.v2-assigned-vins button span { margin-left: 6px; color: #99a4b3; }
.v2-user-empty { margin: 14px 4px; color: #8b97a7; font-size: 10px; line-height: 1.6; }
.v2-user-feedback { margin: 14px 0 0; color: #16805b; font-size: 11px; }.v2-user-feedback.is-error { color: var(--v2-red); }
.v2-user-editor form > footer { display: flex; justify-content: flex-end; margin-top: 18px; }.v2-user-editor form > footer button:disabled { opacity: .5; }
@media (max-width: 900px) {
.v2-auth-screen { grid-template-columns: 1fr; }
.v2-auth-intro { display: none; }
.v2-auth-card { justify-self: center; }
.v2-user-admin { padding: 12px; }
.v2-user-admin-grid { grid-template-columns: 230px minmax(560px,1fr); overflow-x: auto; }
}
@media (max-width: 680px) {
.v2-auth-screen { min-height: 100dvh; padding: 16px; }
.v2-auth-card { padding: 28px 22px; }
.v2-user-admin { padding: 8px 8px 82px; }
.v2-user-admin-heading { align-items: flex-start; margin-bottom: 10px; }.v2-user-admin-heading h2 { font-size: 17px; }.v2-user-admin-heading p { display: none; }.v2-user-admin-heading > button { height: 34px; flex: 0 0 auto; padding: 0 10px; font-size: 10px; }
.v2-user-admin-grid { display: flex; min-height: 0; overflow: visible; border: 0; box-shadow: none; flex-direction: column; gap: 8px; background: transparent; }
.v2-user-list { display: flex; border: 1px solid var(--v2-border); border-radius: 10px; overflow-x: auto; padding: 6px; background: #fff; }.v2-user-list-summary { flex: 0 0 auto; padding: 8px; }.v2-user-list > button { min-width: 210px; }
.v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates { grid-template-columns: 1fr; }
.v2-password-mobile { display: grid !important; }
.v2-topbar-actions .v2-current-user { display: none; }
}
@media (max-width: 1280px) { @media (max-width: 1280px) {
.v2-access-filter-v3 { grid-template-columns: minmax(210px, 1.3fr) repeat(3, minmax(120px, .75fr)) auto auto; } .v2-access-filter-v3 { grid-template-columns: minmax(210px, 1.3fr) repeat(3, minmax(120px, .75fr)) auto auto; }
.v2-access-filter-v3 > button { min-width: 76px; } .v2-access-filter-v3 > button { min-width: 76px; }

View File

@@ -0,0 +1,79 @@
CREATE TABLE IF NOT EXISTS platform_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
display_name VARCHAR(96) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
user_type VARCHAR(24) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'enabled',
customer_ref VARCHAR(96) NOT NULL DEFAULT '',
tenant_ref VARCHAR(96) NOT NULL DEFAULT '',
auth_provider VARCHAR(32) NOT NULL DEFAULT 'local',
external_subject VARCHAR(128) NULL,
failed_login_count INT UNSIGNED NOT NULL DEFAULT 0,
locked_until DATETIME(3) NULL,
password_changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
last_login_at DATETIME(3) NULL,
created_by VARCHAR(96) NOT NULL DEFAULT 'system',
updated_by VARCHAR(96) NOT NULL DEFAULT 'system',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_platform_user_username (username),
UNIQUE KEY uk_platform_user_external_identity (auth_provider, external_subject),
INDEX idx_platform_user_type_status (user_type, status),
CONSTRAINT chk_platform_user_type CHECK (user_type IN ('admin', 'customer')),
CONSTRAINT chk_platform_user_status CHECK (status IN ('enabled', 'disabled'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_user_menu (
user_id BIGINT UNSIGNED NOT NULL,
menu_key VARCHAR(48) NOT NULL,
granted_by VARCHAR(96) NOT NULL,
granted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, menu_key),
INDEX idx_platform_user_menu_key (menu_key, user_id),
CONSTRAINT fk_platform_user_menu_user FOREIGN KEY (user_id) REFERENCES platform_user(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_user_vehicle (
user_id BIGINT UNSIGNED NOT NULL,
vin VARCHAR(64) NOT NULL,
valid_from DATETIME(3) NULL,
valid_to DATETIME(3) NULL,
source_system VARCHAR(32) NOT NULL DEFAULT 'manual',
external_ref VARCHAR(128) NOT NULL DEFAULT '',
granted_by VARCHAR(96) NOT NULL,
granted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (user_id, vin),
INDEX idx_platform_user_vehicle_vin (vin, user_id),
CONSTRAINT fk_platform_user_vehicle_user FOREIGN KEY (user_id) REFERENCES platform_user(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_user_session (
id CHAR(32) NOT NULL PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
token_hash BINARY(32) NOT NULL,
issued_at DATETIME(3) NOT NULL,
expires_at DATETIME(3) NOT NULL,
last_seen_at DATETIME(3) NOT NULL,
revoked_at DATETIME(3) NULL,
remote_addr VARCHAR(96) NOT NULL DEFAULT '',
user_agent VARCHAR(255) NOT NULL DEFAULT '',
UNIQUE KEY uk_platform_session_token (token_hash),
INDEX idx_platform_session_user (user_id, expires_at),
INDEX idx_platform_session_expiry (expires_at, revoked_at),
CONSTRAINT fk_platform_session_user FOREIGN KEY (user_id) REFERENCES platform_user(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS platform_auth_audit (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
actor VARCHAR(96) NOT NULL,
action VARCHAR(64) NOT NULL,
target_type VARCHAR(32) NOT NULL DEFAULT '',
target_id VARCHAR(96) NOT NULL DEFAULT '',
result VARCHAR(24) NOT NULL,
detail_json JSON NULL,
remote_addr VARCHAR(96) NOT NULL DEFAULT '',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX idx_platform_auth_audit_created (created_at, action),
INDEX idx_platform_auth_audit_target (target_type, target_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,75 @@
# Authentication and customer authorization
## Boundary
The platform owns authorization even when authentication is delegated later. An upstream identity system may prove who the user is, but it cannot bypass the local menu and vehicle Scope checks.
Current providers:
- `local`: username/password login and opaque server session.
- `legacy-token`: existing static operational Bearer tokens, retained for emergency access and migration.
- `disabled`: local development only.
The API exposes `IdentityAdapter` as the future signed-token or introspection boundary. Yudao Cloud / RuoYi Sys integration must validate a short-lived signed credential and map `(auth_provider, external_subject)` to `platform_user`. Never trust unsigned identity headers from a reverse proxy.
## Roles and menus
- `admin` receives all platform menus and can manage customer accounts.
- `customer` receives only explicitly granted menu keys from the fixed customer catalog: `monitor`, `vehicles`, `tracks`, `statistics`.
- The server rejects every unspecified customer API route. The web navigation and route guard are usability layers, not the authorization boundary.
Shared vehicle APIs used by more than one customer menu are allowed when the account owns any relevant menu, but every data query still receives the same VIN Scope.
## Vehicle Scope
`platform_user_vehicle` is the authoritative local grant table. Customer principals carry a bounded list of active VIN grants. Service queries inject it as `scopeVins` before SQL construction, and the MySQL builders apply the list to vehicle, realtime, monitor, track resolution and mileage queries.
Explicit VINs outside the grant set return `403 VEHICLE_PERMISSION_DENIED`. A missing vehicle grant is fail-closed and produces an empty collection. Plate-number resolution is performed inside the same VIN Scope.
## Sessions and password policy
- Passwords are stored with bcrypt cost 12.
- Password length is 10-128 characters and must contain at least three of: lowercase, uppercase, number, symbol.
- Five consecutive failures lock the account for 15 minutes.
- Login creates a cryptographically random opaque token; only SHA-256 is stored in MySQL.
- Default session lifetime is 12 hours (`AUTH_SESSION_TTL_HOURS`).
- The browser stores the token in `sessionStorage`, not persistent storage.
- Disabling an account or resetting its password revokes all active sessions immediately.
- Successful/failed logins and account permission changes are written to `platform_auth_audit`.
Session principals are cached in the API for at most 30 seconds to keep realtime polling inexpensive. Account disable and password reset revoke the database session immediately; permission updates invalidate local cache entries and become visible across multiple API instances within the cache window.
## Bootstrap
The first local administrator is created only when no administrator exists:
```text
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=<strong one-time bootstrap secret>
AUTH_SESSION_TTL_HOURS=12
```
The bootstrap password does not overwrite an existing administrator. Keep the environment file root-owned and mode `0600`. After an administrator changes credentials through the account password dialog, remove the bootstrap password from the environment.
## APIs
```text
POST /api/v2/auth/login
POST /api/v2/auth/logout
PUT /api/v2/auth/password
GET /api/v2/session
GET /api/v2/admin/users
POST /api/v2/admin/users
PUT /api/v2/admin/users/{id}
```
Customer creation/update accepts display name, optional external customer/tenant references, account status, menu keys and VINs. Username is immutable after creation. Password reset and account disable invalidate previous sessions.
## External identity migration
1. Add a concrete `IdentityAdapter` for the upstream issuer using JWKS signature validation or server-side token introspection.
2. Match the validated issuer subject to `platform_user.auth_provider` and `platform_user.external_subject`.
3. Keep menu and VIN grants in the platform initially, or sync them into the same local projection with source/version metadata.
4. Run local and external providers in parallel during migration; do not remove the operational token until external login and rollback are proven.
5. Preserve `subjectId`, `customerRef`, `tenantRef`, `menuKeys`, `vehicleCount` and `authProvider` in the session contract so the web application does not depend on a Yudao/RuoYi-specific payload.

View File

@@ -85,6 +85,9 @@ EXPORT_DIR=/opt/lingniu-vehicle-platform/data/exports
AUTH_MODE=enforce AUTH_MODE=enforce
# JSON array with viewer/operator/admin principals. Keep this file mode 0600. # JSON array with viewer/operator/admin principals. Keep this file mode 0600.
AUTH_TOKENS_JSON=[{"token":"<at-least-16-random-characters>","name":"ecs-admin","role":"admin"}] AUTH_TOKENS_JSON=[{"token":"<at-least-16-random-characters>","name":"ecs-admin","role":"admin"}]
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=<strong-first-admin-password>
AUTH_SESSION_TTL_HOURS=12
ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai ONEOS_MYSQL_DSN=vehicle_scope_reader:***@tcp(rm-bp179zbv481rnw3e2.mysql.rds.aliyuncs.com:3306)/ln_asset_management?parseTime=true&loc=Asia%2FShanghai
ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60 ONEOS_SCOPE_SYNC_TIMEOUT_SEC=60
ONEOS_SCOPE_MAX_REJECTED=100 ONEOS_SCOPE_MAX_REJECTED=100
@@ -165,7 +168,8 @@ test -n "$MYSQL_DSN"
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/009_alert_stream_checkpoint.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/009_alert_stream_checkpoint.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/010_alert_stream_metric_mapping.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \
/opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql
``` ```
The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed.