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"` Vehicles []authVehicleGrant `json:"vehicles"` GrantHistory []authVehicleGrantHistory `json:"grantHistory"` LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type authVehicleGrant struct { VIN string `json:"vin"` Plate string `json:"plate"` ValidFrom time.Time `json:"validFrom"` ValidTo *time.Time `json:"validTo,omitempty"` SourceSystem string `json:"sourceSystem"` GrantedBy string `json:"grantedBy"` } type authVehicleGrantHistory struct { ID uint64 `json:"id"` VIN string `json:"vin"` Plate string `json:"plate"` ValidFrom time.Time `json:"validFrom"` ValidTo *time.Time `json:"validTo,omitempty"` SourceSystem string `json:"sourceSystem"` GrantedBy string `json:"grantedBy"` RevokedBy string `json:"revokedBy"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type userVehicleGrantInput struct { VIN string `json:"vin"` ValidFrom string `json:"validFrom"` ValidTo string `json:"validTo"` } type vehicleGrantMutation struct { VIN string ValidFrom *time.Time ValidTo *time.Time } type vehicleGrantAuditSnapshot struct { ValidFrom time.Time `json:"validFrom"` ValidTo *time.Time `json:"validTo,omitempty"` } type vehicleGrantAuditChange struct { VIN string `json:"vin"` Before *vehicleGrantAuditSnapshot `json:"before,omitempty"` After *vehicleGrantAuditSnapshot `json:"after,omitempty"` } 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"` VehicleGrants []userVehicleGrantInput `json:"vehicleGrants"` } type loginRequest struct { Username string `json:"username"` Password string `json:"password"` } type passwordChangeRequest struct { CurrentPassword string `json:"currentPassword"` NewPassword string `json:"newPassword"` } type loginResponse struct { AccessToken string `json:"accessToken"` ExpiresAt time.Time `json:"expiresAt"` Session platform.Principal `json:"session"` } type cachedSession struct { principal platform.Principal expiresAt time.Time cachedAt time.Time } type authStore struct { db *sql.DB sessionTTL time.Duration cacheMu sync.RWMutex cache map[string]cachedSession } func newAuthStore(db *sql.DB, cfg config.Config) (*authStore, error) { if db == nil { return nil, nil } ttl := cfg.SessionTTL if ttl <= 0 { ttl = 12 * time.Hour } store := &authStore{db: db, sessionTTL: ttl, cache: map[string]cachedSession{}} if err := store.ensureBootstrapAdmin(context.Background(), cfg.BootstrapAdminUsername, cfg.BootstrapAdminPassword); err != nil { return nil, err } return store, nil } func (s *authStore) ensureBootstrapAdmin(ctx context.Context, username, password string) error { username = strings.TrimSpace(username) if username == "" && password == "" { return nil } if !usernamePattern.MatchString(username) { return fmt.Errorf("BOOTSTRAP_ADMIN_USERNAME is invalid") } if err := validatePassword(password); err != nil { return fmt.Errorf("BOOTSTRAP_ADMIN_PASSWORD: %w", err) } var exists int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM platform_user WHERE user_type='admin'`).Scan(&exists); err != nil { return fmt.Errorf("check bootstrap admin: %w", err) } if exists > 0 { return nil } hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return fmt.Errorf("hash bootstrap password: %w", err) } _, err = s.db.ExecContext(ctx, `INSERT INTO platform_user(username,display_name,password_hash,user_type,status,auth_provider,created_by,updated_by) VALUES(?,?,?,'admin','enabled','local','bootstrap','bootstrap')`, username, "平台管理员", string(hash)) if err != nil { return fmt.Errorf("create bootstrap admin: %w", err) } return nil } func (s *authStore) login(w http.ResponseWriter, r *http.Request) { var input loginRequest if !decodeAuthJSON(w, r, &input) { return } input.Username = strings.TrimSpace(input.Username) if !usernamePattern.MatchString(input.Username) || len(input.Password) > 128 { s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r)) httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) return } credential, err := s.localCredential(r.Context(), input.Username) if err != nil { if !errors.Is(err, sql.ErrNoRows) { httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r)) return } s.audit(r.Context(), input.Username, "login", "user", input.Username, "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddress(r)) httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) return } now := time.Now() if credential.Status != "enabled" { s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "disabled"}, remoteAddress(r)) httpx.WriteError(w, http.StatusForbidden, "ACCOUNT_DISABLED", "账号已停用,请联系管理员", "", requestTraceID(r)) return } if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) { httpx.WriteError(w, http.StatusTooManyRequests, "ACCOUNT_LOCKED", "登录失败次数过多,请稍后再试", credential.LockedUntil.Time.Format(time.RFC3339), requestTraceID(r)) return } if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil { failures := credential.FailedLoginCount + 1 var locked any if failures >= maxLoginFailures { locked = now.Add(loginLockDuration) } _, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=?,locked_until=? WHERE id=?`, failures, locked, credential.ID) s.audit(r.Context(), input.Username, "login", "user", strconv.FormatUint(credential.ID, 10), "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddress(r)) httpx.WriteError(w, http.StatusUnauthorized, "LOGIN_FAILED", "用户名或密码错误", "", requestTraceID(r)) return } principal, err := s.principalForUser(r.Context(), credential.authUser) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "AUTH_STORE_FAILED", "登录服务暂时不可用", "", requestTraceID(r)) return } accessToken, tokenHash, err := randomSessionToken() if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r)) return } sessionID, err := randomHex(16) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r)) return } expiresAt := now.Add(s.sessionTTL) _, err = s.db.ExecContext(r.Context(), `INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?)`, sessionID, credential.ID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255)) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r)) return } _, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID) principal.SessionID = sessionID s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt) s.audit(r.Context(), principal.Name, "login", "user", strconv.FormatUint(credential.ID, 10), "success", nil, remoteAddress(r)) w.Header().Set("Cache-Control", "no-store") httpx.WriteOK(w, requestTraceID(r), loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}) } func (s *authStore) authenticate(ctx context.Context, token string) (platform.Principal, bool) { hash := sha256.Sum256([]byte(token)) key := hex.EncodeToString(hash[:]) if principal, ok := s.cacheGet(key); ok { return principal, true } var user authUser var sessionID string var expiresAt time.Time var external sql.NullString err := s.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.display_name,u.user_type,u.status,u.customer_ref,u.tenant_ref,u.auth_provider,u.external_subject,u.last_login_at,u.created_at,u.updated_at,s.id,s.expires_at FROM platform_user_session s JOIN platform_user u ON u.id=s.user_id WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status='enabled'`, hash[:]).Scan( &user.ID, &user.Username, &user.DisplayName, &user.UserType, &user.Status, &user.CustomerRef, &user.TenantRef, &user.AuthProvider, &external, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &sessionID, &expiresAt, ) if err != nil { return platform.Principal{}, false } user.ExternalSubject = external.String principal, err := s.principalForUser(ctx, user) if err != nil { return platform.Principal{}, false } principal.SessionID = sessionID s.cachePut(key, principal, expiresAt) return principal, true } func (s *authStore) logout(ctx context.Context, token string) { hash := sha256.Sum256([]byte(token)) _, _ = s.db.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:]) s.cacheMu.Lock() delete(s.cache, hex.EncodeToString(hash[:])) s.cacheMu.Unlock() } func (s *authStore) changePassword(w http.ResponseWriter, r *http.Request, principal platform.Principal) { if principal.AuthProvider != "local" || principal.SubjectID == "" { httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_CHANGE_UNAVAILABLE", "当前登录方式不支持修改密码", "", requestTraceID(r)) return } var input passwordChangeRequest if !decodeAuthJSON(w, r, &input) { return } if err := validatePassword(input.NewPassword); err != nil { httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_INVALID", err.Error(), "", requestTraceID(r)) return } id, _ := strconv.ParseUint(principal.SubjectID, 10, 64) var currentHash string if err := s.db.QueryRowContext(r.Context(), `SELECT password_hash FROM platform_user WHERE id=? AND status='enabled'`, id).Scan(¤tHash); err != nil || bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.CurrentPassword)) != nil { s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "denied", map[string]any{"reason": "current_password_invalid"}, remoteAddress(r)) httpx.WriteError(w, http.StatusUnauthorized, "CURRENT_PASSWORD_INVALID", "当前密码不正确", "", requestTraceID(r)) return } if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(input.NewPassword)) == nil { httpx.WriteError(w, http.StatusBadRequest, "PASSWORD_UNCHANGED", "新密码不能与当前密码相同", "", requestTraceID(r)) return } hash, _ := bcrypt.GenerateFromPassword([]byte(input.NewPassword), 12) tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r)) return } defer tx.Rollback() if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user SET password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL,updated_by=? WHERE id=?`, string(hash), principal.Name, id); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r)) return } if _, err := tx.ExecContext(r.Context(), `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法撤销旧会话", "", requestTraceID(r)) return } if err := tx.Commit(); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "PASSWORD_CHANGE_FAILED", "无法修改密码", "", requestTraceID(r)) return } s.invalidateUser(id) s.audit(r.Context(), principal.Name, "password.change", "user", principal.SubjectID, "success", nil, remoteAddress(r)) httpx.WriteOK(w, requestTraceID(r), map[string]bool{"changed": true}) } func (s *authStore) localCredential(ctx context.Context, username string) (localCredential, error) { var credential localCredential var external sql.NullString err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,failed_login_count,locked_until,last_login_at,created_at,updated_at FROM platform_user WHERE username=? AND auth_provider='local'`, username).Scan( &credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash, &credential.UserType, &credential.Status, &credential.CustomerRef, &credential.TenantRef, &credential.AuthProvider, &external, &credential.FailedLoginCount, &credential.LockedUntil, &credential.LastLoginAt, &credential.CreatedAt, &credential.UpdatedAt, ) credential.ExternalSubject = external.String return credential, err } func (s *authStore) principalForUser(ctx context.Context, user authUser) (platform.Principal, error) { menus := append([]string(nil), adminMenus...) vehicles := []string{} vehicleGrants := []platform.VehicleGrant{} 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,COALESCE(valid_from,granted_at),valid_to FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin`, user.ID) if err != nil { return platform.Principal{}, err } for rows.Next() { var vin string var validFrom time.Time var validTo sql.NullTime if err := rows.Scan(&vin, &validFrom, &validTo); err != nil { rows.Close() return platform.Principal{}, err } vehicles = append(vehicles, vin) grant := platform.VehicleGrant{VIN: vin, ValidFrom: validFrom} if validTo.Valid { grant.ValidTo = &validTo.Time } vehicleGrants = append(vehicleGrants, grant) } 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, VehicleGrants: vehicleGrants, }, 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{} allVINs := []string{} 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 if user.UserType == "customer" { user.Vehicles, user.GrantHistory, err = loadUserVehicleAuthorization(r.Context(), s.db, user.ID) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取车辆授权履历", "", requestTraceID(r)) return } user.VehicleVINs = make([]string, 0, len(user.Vehicles)) for _, vehicle := range user.Vehicles { user.VehicleVINs = append(user.VehicleVINs, vehicle.VIN) allVINs = append(allVINs, vehicle.VIN) } for _, history := range user.GrantHistory { allVINs = append(allVINs, history.VIN) } } users = append(users, user) } if err := rows.Err(); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r)) return } vehicleLabels, err := loadAuthVehicleGrants(r.Context(), s.db, normalizeVINs(allVINs)) if err != nil { httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取车辆权限信息", "", requestTraceID(r)) return } for index := range users { for vehicleIndex := range users[index].Vehicles { users[index].Vehicles[vehicleIndex].Plate = vehicleLabels[users[index].Vehicles[vehicleIndex].VIN].Plate } for historyIndex := range users[index].GrantHistory { users[index].GrantHistory[historyIndex].Plate = vehicleLabels[users[index].GrantHistory[historyIndex].VIN].Plate } } httpx.WriteOK(w, requestTraceID(r), users) } func loadUserVehicleAuthorization(ctx context.Context, db *sql.DB, userID uint64) ([]authVehicleGrant, []authVehicleGrantHistory, error) { rows, err := db.QueryContext(ctx, `SELECT vin,COALESCE(valid_from,granted_at),valid_to,source_system,granted_by FROM platform_user_vehicle WHERE user_id=? ORDER BY vin`, userID) if err != nil { return nil, nil, err } grants := []authVehicleGrant{} for rows.Next() { var grant authVehicleGrant var validTo sql.NullTime if err := rows.Scan(&grant.VIN, &grant.ValidFrom, &validTo, &grant.SourceSystem, &grant.GrantedBy); err != nil { rows.Close() return nil, nil, err } grant.VIN = strings.ToUpper(strings.TrimSpace(grant.VIN)) if validTo.Valid { value := validTo.Time grant.ValidTo = &value } grants = append(grants, grant) } if err := rows.Close(); err != nil { return nil, nil, err } historyRows, err := db.QueryContext(ctx, `SELECT id,vin,valid_from,valid_to,source_system,granted_by,revoked_by,created_at,updated_at FROM platform_user_vehicle_grant_history WHERE user_id=? ORDER BY valid_from DESC,id DESC`, userID) if err != nil { return nil, nil, err } history := []authVehicleGrantHistory{} for historyRows.Next() { var item authVehicleGrantHistory var validTo sql.NullTime if err := historyRows.Scan(&item.ID, &item.VIN, &item.ValidFrom, &validTo, &item.SourceSystem, &item.GrantedBy, &item.RevokedBy, &item.CreatedAt, &item.UpdatedAt); err != nil { historyRows.Close() return nil, nil, err } item.VIN = strings.ToUpper(strings.TrimSpace(item.VIN)) if validTo.Valid { value := validTo.Time item.ValidTo = &value } history = append(history, item) } if err := historyRows.Close(); err != nil { return nil, nil, err } return grants, history, nil } func loadAuthVehicleGrants(ctx context.Context, db *sql.DB, vins []string) (map[string]authVehicleGrant, error) { result := make(map[string]authVehicleGrant, len(vins)) for start := 0; start < len(vins); start += 250 { end := start + 250 if end > len(vins) { end = len(vins) } part := vins[start:end] placeholders := strings.TrimSuffix(strings.Repeat("?,", len(part)), ",") args := make([]any, 0, len(part)*2) for range 2 { for _, vin := range part { args = append(args, vin) } } query := `SELECT vin, COALESCE(` + `MAX(CASE WHEN source_priority=0 THEN NULLIF(plate,'') END),` + `MAX(CASE WHEN source_priority=1 THEN NULLIF(plate,'') END),'') ` + `FROM (` + `SELECT UPPER(TRIM(vin)) AS vin,plate,0 AS source_priority FROM vehicle_identity_binding WHERE vin IN (` + placeholders + `) ` + `UNION ALL ` + `SELECT UPPER(TRIM(vin)) AS vin,plate,1 AS source_priority FROM vehicle_realtime_snapshot WHERE vin IN (` + placeholders + `)` + `) vehicle_grants GROUP BY vin` rows, err := db.QueryContext(ctx, query, args...) if err != nil { return nil, err } for rows.Next() { var vehicle authVehicleGrant if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil { rows.Close() return nil, err } vehicle.VIN = strings.ToUpper(strings.TrimSpace(vehicle.VIN)) vehicle.Plate = strings.TrimSpace(vehicle.Plate) result[vehicle.VIN] = vehicle } if err := rows.Close(); err != nil { return nil, err } } return result, nil } 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, grants, 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() grantChanges := []vehicleGrantAuditChange{} if err := replaceGrants(r.Context(), tx, uint64(id), menus, grants, actor.Name, &grantChanges); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r)) return } if err := tx.Commit(); err != nil { httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法创建客户账号", "", requestTraceID(r)) return } s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r)) httpx.WriteOK(w, requestTraceID(r), map[string]any{"id": id}) } func (s *authStore) 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, grants, 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 } grantChanges := []vehicleGrantAuditChange{} if err := replaceGrants(r.Context(), tx, id, menus, grants, actor.Name, &grantChanges); 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, "vehicleGrantChanges": grantChanges, "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, []vehicleGrantMutation, 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("至少分配一个客户菜单") } grants, err := normalizeVehicleGrantMutations(input) if err != nil { return nil, nil, err } if len(grants) == 0 { return nil, nil, fmt.Errorf("至少分配一辆可查看车辆") } if len(grants) > 5000 { return nil, nil, fmt.Errorf("单个客户最多分配 5000 辆车") } vins := grantVINs(grants) 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, grants, nil } type currentVehicleGrant struct { ValidFrom time.Time ValidTo *time.Time HistoryID uint64 HistoryValidFrom *time.Time HistoryValidTo *time.Time } func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus []string, grants []vehicleGrantMutation, actor string, changes *[]vehicleGrantAuditChange) 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 } } current := map[string]currentVehicleGrant{} rows, err := tx.QueryContext(ctx, `SELECT p.vin,COALESCE(p.valid_from,p.granted_at),p.valid_to, COALESCE(h.id,0),h.valid_from,h.valid_to FROM platform_user_vehicle p LEFT JOIN platform_user_vehicle_grant_history h ON h.id=(SELECT MAX(latest.id) FROM platform_user_vehicle_grant_history latest WHERE latest.user_id=p.user_id AND latest.vin=p.vin) WHERE p.user_id=? FOR UPDATE`, userID) if err != nil { return err } for rows.Next() { var vin string var validFrom time.Time var validTo sql.NullTime var historyID uint64 var historyValidFrom, historyValidTo sql.NullTime if err := rows.Scan(&vin, &validFrom, &validTo, &historyID, &historyValidFrom, &historyValidTo); err != nil { rows.Close() return err } item := currentVehicleGrant{ValidFrom: validFrom, HistoryID: historyID} if validTo.Valid { value := validTo.Time item.ValidTo = &value } if historyValidFrom.Valid { value := historyValidFrom.Time item.HistoryValidFrom = &value } if historyValidTo.Valid { value := historyValidTo.Time item.HistoryValidTo = &value } current[strings.ToUpper(strings.TrimSpace(vin))] = item } if err := rows.Close(); err != nil { return err } selected := make(map[string]vehicleGrantMutation, len(grants)) for _, grant := range grants { selected[grant.VIN] = grant } removedHistoryIDs := []uint64{} for vin, item := range current { if _, kept := selected[vin]; kept { continue } if item.HistoryID == 0 { return fmt.Errorf("active vehicle grant %s is missing history", vin) } if item.HistoryID > 0 { removedHistoryIDs = append(removedHistoryIDs, item.HistoryID) appendVehicleGrantChange(changes, vin, &item, nil) } } for _, historyID := range removedHistoryIDs { if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=CASE WHEN valid_to IS NOT NULL AND valid_to<=NOW(3) THEN valid_to WHEN valid_from>NOW(3) THEN valid_from ELSE NOW(3) END, revoked_by=?,updated_at=NOW(3) WHERE id=?`, actor, historyID); err != nil { return err } } if len(grants) == 0 { if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=?`, userID); err != nil { return err } } else { vins := grantVINs(grants) placeholders := strings.TrimSuffix(strings.Repeat("?,", len(vins)), ",") args := make([]any, 0, len(vins)+1) args = append(args, userID) for _, vin := range vins { args = append(args, vin) } if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (`+placeholders+`)`, args...); err != nil { return err } } now := time.Now() for _, grant := range grants { existing, exists := current[grant.VIN] validFrom := now validTo := grant.ValidTo if grant.ValidFrom != nil { validFrom = *grant.ValidFrom } else if exists { validFrom = existing.ValidFrom validTo = existing.ValidTo } if exists { projectionMatches := existing.ValidFrom.Equal(validFrom) && equalOptionalTimes(existing.ValidTo, validTo) historyMatches := existing.HistoryValidFrom != nil && existing.HistoryValidFrom.Equal(validFrom) && equalOptionalTimes(existing.HistoryValidTo, validTo) if projectionMatches && historyMatches { continue } if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle SET valid_from=?,valid_to=?,source_system='manual',granted_by=? WHERE user_id=? AND vin=?`, validFrom, nullableTime(validTo), actor, userID, grant.VIN); err != nil { return err } if existing.HistoryID == 0 { return fmt.Errorf("active vehicle grant %s is missing history", grant.VIN) } if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_from=?,valid_to=?,source_system='manual',granted_by=?,revoked_by='',updated_at=NOW(3) WHERE id=?`, validFrom, nullableTime(validTo), actor, existing.HistoryID); err != nil { return err } after := currentVehicleGrant{ValidFrom: validFrom, ValidTo: validTo, HistoryID: existing.HistoryID} appendVehicleGrantChange(changes, grant.VIN, &existing, &after) continue } if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle(user_id,vin,valid_from,valid_to,source_system,granted_by) VALUES(?,?,?,?,'manual',?)`, userID, grant.VIN, validFrom, nullableTime(validTo), actor); err != nil { return err } if _, err := tx.ExecContext(ctx, `INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,valid_to,source_system,external_ref,granted_by) VALUES(?,?,?,?, 'manual','',?)`, userID, grant.VIN, validFrom, nullableTime(validTo), actor); err != nil { return err } after := currentVehicleGrant{ValidFrom: validFrom, ValidTo: validTo} appendVehicleGrantChange(changes, grant.VIN, nil, &after) } 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 normalizeVehicleGrantMutations(input userMutation) ([]vehicleGrantMutation, error) { if len(input.VehicleGrants) == 0 { vins := normalizeVINs(input.VehicleVINs) grants := make([]vehicleGrantMutation, 0, len(vins)) for _, vin := range vins { grants = append(grants, vehicleGrantMutation{VIN: vin}) } return grants, nil } seen := map[string]bool{} grants := make([]vehicleGrantMutation, 0, len(input.VehicleGrants)) for _, raw := range input.VehicleGrants { vin := strings.ToUpper(strings.TrimSpace(raw.VIN)) if vin == "" || len(vin) > 64 { return nil, fmt.Errorf("车辆授权包含无效 VIN") } if seen[vin] { return nil, fmt.Errorf("车辆 %s 的授权区间重复", vin) } seen[vin] = true validFrom, err := parseVehicleGrantTime(raw.ValidFrom, true) if err != nil { return nil, fmt.Errorf("车辆 %s 的启用时间无效:%v", vin, err) } validTo, err := parseVehicleGrantTime(raw.ValidTo, false) if err != nil { return nil, fmt.Errorf("车辆 %s 的停用时间无效:%v", vin, err) } if validTo != nil && !validTo.After(*validFrom) { return nil, fmt.Errorf("车辆 %s 的停用时间必须晚于启用时间", vin) } grants = append(grants, vehicleGrantMutation{VIN: vin, ValidFrom: validFrom, ValidTo: validTo}) } sort.Slice(grants, func(i, j int) bool { return grants[i].VIN < grants[j].VIN }) return grants, nil } func parseVehicleGrantTime(value string, required bool) (*time.Time, error) { value = strings.TrimSpace(value) if value == "" { if required { return nil, fmt.Errorf("不能为空") } return nil, nil } if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { return &parsed, nil } shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) for _, layout := range []string{"2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05", "2006-01-02"} { if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil { return &parsed, nil } } return nil, fmt.Errorf("应为有效日期时间") } func grantVINs(grants []vehicleGrantMutation) []string { vins := make([]string, 0, len(grants)) for _, grant := range grants { vins = append(vins, grant.VIN) } return vins } func nullableTime(value *time.Time) any { if value == nil { return nil } return *value } func equalOptionalTimes(left, right *time.Time) bool { if left == nil || right == nil { return left == nil && right == nil } return left.Equal(*right) } func appendVehicleGrantChange(changes *[]vehicleGrantAuditChange, vin string, before, after *currentVehicleGrant) { if changes == nil { return } change := vehicleGrantAuditChange{VIN: vin} if before != nil { change.Before = &vehicleGrantAuditSnapshot{ValidFrom: before.ValidFrom, ValidTo: before.ValidTo} } if after != nil { change.After = &vehicleGrantAuditSnapshot{ValidFrom: after.ValidFrom, ValidTo: after.ValidTo} } *changes = append(*changes, change) } 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)) }