Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/app/auth_store.go
2026-07-16 14:16:22 +08:00

819 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
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"`
}
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{}
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
user.VehicleVINs = principal.VehicleVINs
allVINs = append(allVINs, principal.VehicleVINs...)
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 {
users[index].Vehicles = make([]authVehicleGrant, 0, len(users[index].VehicleVINs))
for _, vin := range users[index].VehicleVINs {
vehicle := vehicleLabels[vin]
if vehicle.VIN == "" {
vehicle.VIN = vin
}
users[index].Vehicles = append(users[index].Vehicles, vehicle)
}
}
httpx.WriteOK(w, requestTraceID(r), users)
}
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, 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))
}