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

1528 lines
58 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"`
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"`
}
const maxUserBatchItems = 50
type userBatchRequest struct {
Mode string `json:"mode"`
Items []userBatchItem `json:"items"`
}
type userBatchItem struct {
Row int `json:"row"`
Input userMutation `json:"input"`
}
type userBatchResultItem struct {
Row int `json:"row"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
ID int64 `json:"id,omitempty"`
}
type userBatchSummary struct {
Received int `json:"received"`
Ready int `json:"ready"`
Created int `json:"created"`
Failed int `json:"failed"`
}
type userBatchResult struct {
Mode string `json:"mode"`
Summary userBatchSummary `json:"summary"`
Items []userBatchResultItem `json:"items"`
}
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 oneOSTicketExchangeRequest struct {
Ticket string `json:"ticket"`
}
type oneOSTicketExchangeResponse struct {
AccessToken string `json:"accessToken"`
ExpiresAt time.Time `json:"expiresAt"`
Session platform.Principal `json:"session"`
ReturnTo string `json:"returnTo"`
}
type cachedSession struct {
principal platform.Principal
expiresAt time.Time
cachedAt time.Time
}
type authStore struct {
db *sql.DB
sessionTTL time.Duration
oneOSSessionTTL 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
}
oneOSTTL := cfg.OneOSSessionTTL
if oneOSTTL <= 0 {
oneOSTTL = 30 * time.Minute
}
store := &authStore{db: db, sessionTTL: ttl, oneOSSessionTTL: oneOSTTL, 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
}
response, err := s.issueSession(r, credential.ID, principal, s.sessionTTL)
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)
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), response)
}
func (s *authStore) exchangeOneOSTicket(w http.ResponseWriter, r *http.Request, adapter *oneOSIdentityAdapter) {
var input oneOSTicketExchangeRequest
if !decodeAuthJSON(w, r, &input) {
return
}
identity, err := adapter.ExchangeTicket(r.Context(), input.Ticket)
if err != nil {
s.audit(r.Context(), "oneos", "sso.exchange", "ticket", "", "denied", map[string]any{"reason": err.Error()}, remoteAddress(r))
httpx.WriteError(w, http.StatusUnauthorized, "ONEOS_TICKET_REJECTED", "OneOS 登录票据无效、已过期或已使用", "", requestTraceID(r))
return
}
user, err := s.syncOneOSIdentity(r.Context(), identity, adapter.defaultMenus)
if err != nil {
s.audit(r.Context(), identity.Username, "sso.exchange", "user", identity.Subject, "failed", map[string]any{"reason": err.Error()}, remoteAddress(r))
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法同步 OneOS 用户权限", "", requestTraceID(r))
return
}
principal, err := s.principalForUser(r.Context(), user)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "ONEOS_IDENTITY_SYNC_FAILED", "无法加载 OneOS 用户权限", "", requestTraceID(r))
return
}
response, err := s.issueSession(r, user.ID, principal, s.oneOSSessionTTL)
if err != nil {
httpx.WriteError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "无法创建登录会话", "", requestTraceID(r))
return
}
_, _ = s.db.ExecContext(r.Context(), `UPDATE platform_user SET last_login_at=NOW(3),failed_login_count=0,locked_until=NULL WHERE id=?`, user.ID)
s.audit(r.Context(), principal.Name, "sso.exchange", "user", identity.Subject, "success", map[string]any{
"scopeLevel": identity.ScopeLevel, "departmentIds": identity.DepartmentIDs,
}, remoteAddress(r))
w.Header().Set("Cache-Control", "no-store")
httpx.WriteOK(w, requestTraceID(r), oneOSTicketExchangeResponse{
AccessToken: response.AccessToken,
ExpiresAt: response.ExpiresAt,
Session: response.Session,
ReturnTo: identity.ReturnTo,
})
}
func (s *authStore) syncOneOSIdentity(ctx context.Context, identity oneOSIdentity, menus []string) (authUser, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return authUser{}, err
}
defer tx.Rollback()
var userID uint64
err = tx.QueryRowContext(ctx, `SELECT id FROM platform_user WHERE auth_provider='oneos' AND external_subject=? FOR UPDATE`, identity.Subject).Scan(&userID)
switch {
case errors.Is(err, sql.ErrNoRows):
result, insertErr := tx.ExecContext(ctx, `INSERT INTO platform_user(
username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,created_by,updated_by
) VALUES(?,?,?,'customer','enabled','',?,'oneos',?,'oneos-sso','oneos-sso')`,
oneOSPlatformUsername(identity.Subject), oneOSDisplayName(identity), "", identity.TenantID, identity.Subject,
)
if insertErr != nil {
return authUser{}, insertErr
}
insertedID, insertErr := result.LastInsertId()
if insertErr != nil {
return authUser{}, insertErr
}
userID = uint64(insertedID)
case err != nil:
return authUser{}, err
default:
if _, err = tx.ExecContext(ctx, `UPDATE platform_user SET display_name=?,tenant_ref=?,status='enabled',updated_by='oneos-sso' WHERE id=?`,
oneOSDisplayName(identity), identity.TenantID, userID); err != nil {
return authUser{}, err
}
}
if _, err = tx.ExecContext(ctx, `DELETE FROM platform_user_menu WHERE user_id=?`, userID); err != nil {
return authUser{}, err
}
for _, menu := range menus {
if !customerMenuSet[menu] {
continue
}
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?, 'oneos-sso')`, userID, menu); err != nil {
return authUser{}, err
}
}
departmentIDs := strings.Join(normalizeStringList(identity.DepartmentIDs, 100), ",")
if _, err = tx.ExecContext(ctx, `INSERT INTO platform_user_business_scope(
user_id,scope_level,department_ids,responsible_user_id,enabled,source_system,source_updated_at
) VALUES(?,?,?,?,1,'oneos',?)
ON DUPLICATE KEY UPDATE scope_level=VALUES(scope_level),department_ids=VALUES(department_ids),
responsible_user_id=VALUES(responsible_user_id),enabled=1,source_system='oneos',
source_updated_at=VALUES(source_updated_at)`,
userID, identity.ScopeLevel, departmentIDs, strings.TrimSpace(identity.ResponsibleUserID), identity.IssuedAt); err != nil {
return authUser{}, err
}
if err = tx.Commit(); err != nil {
return authUser{}, err
}
s.invalidateUser(userID)
return authUser{
ID: userID, Username: oneOSPlatformUsername(identity.Subject), DisplayName: oneOSDisplayName(identity),
UserType: "customer", Status: "enabled", TenantRef: identity.TenantID,
AuthProvider: "oneos", ExternalSubject: identity.Subject,
}, nil
}
func (s *authStore) issueSession(r *http.Request, userID uint64, principal platform.Principal, ttl time.Duration) (loginResponse, error) {
accessToken, tokenHash, err := randomSessionToken()
if err != nil {
return loginResponse{}, err
}
sessionID, err := randomHex(16)
if err != nil {
return loginResponse{}, err
}
now := time.Now()
if ttl <= 0 {
ttl = 30 * time.Minute
}
expiresAt := now.Add(ttl)
if _, 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, userID, tokenHash[:], now, expiresAt, now, remoteAddress(r), truncateUTF8(r.UserAgent(), 255)); err != nil {
return loginResponse{}, err
}
principal.SessionID = sessionID
s.cachePut(hex.EncodeToString(tokenHash[:]), principal, expiresAt)
return loginResponse{AccessToken: accessToken, ExpiresAt: expiresAt, Session: principal}, nil
}
func oneOSPlatformUsername(subject string) string {
normalized := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
return r
}
return '-'
}, strings.TrimSpace(subject))
normalized = strings.Trim(normalized, "-")
if normalized == "" {
sum := sha256.Sum256([]byte(subject))
normalized = hex.EncodeToString(sum[:8])
}
value := "oneos-" + normalized
if len(value) > 64 {
sum := sha256.Sum256([]byte(subject))
value = "oneos-" + hex.EncodeToString(sum[:16])
}
return value
}
func oneOSDisplayName(identity oneOSIdentity) string {
if value := strings.TrimSpace(identity.DisplayName); value != "" {
return truncateUTF8(value, 96)
}
return truncateUTF8(identity.Username, 96)
}
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{}
vehicleGrants := []platform.VehicleGrant{}
businessScopeLevel := ""
departmentIDs := []string{}
responsibleUserID := ""
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
}
if strings.EqualFold(strings.TrimSpace(user.AuthProvider), "oneos") {
var rawDepartmentIDs string
err := s.db.QueryRowContext(ctx, `SELECT scope_level,department_ids,responsible_user_id
FROM platform_user_business_scope WHERE user_id=? AND enabled=1`, user.ID).Scan(
&businessScopeLevel, &rawDepartmentIDs, &responsibleUserID,
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return platform.Principal{}, err
}
if err == nil {
departmentIDs = normalizeScopeValues(rawDepartmentIDs)
businessVINs, err := s.loadOneOSBusinessVINs(ctx, businessScopeLevel, departmentIDs, responsibleUserID)
if err != nil {
return platform.Principal{}, err
}
vehicles = businessVINs
vehicleGrants = make([]platform.VehicleGrant, 0, len(businessVINs))
for _, vin := range businessVINs {
vehicleGrants = append(vehicleGrants, platform.VehicleGrant{VIN: vin})
}
}
}
}
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,
BusinessScopeLevel: businessScopeLevel, DepartmentIDs: departmentIDs, ResponsibleUserID: strings.TrimSpace(responsibleUserID),
}, nil
}
func normalizeScopeValues(raw string) []string {
seen := map[string]bool{}
result := []string{}
for _, value := range strings.Split(raw, ",") {
value = strings.TrimSpace(value)
if value == "" || seen[value] || len(result) >= 100 {
continue
}
seen[value] = true
result = append(result, value)
}
sort.Strings(result)
return result
}
func (s *authStore) loadOneOSBusinessVINs(ctx context.Context, level string, departmentIDs []string, responsibleUserID string) ([]string, error) {
where := []string{"st.id=1"}
args := []any{}
switch strings.ToLower(strings.TrimSpace(level)) {
case "department":
if len(departmentIDs) == 0 {
return []string{}, nil
}
placeholders := strings.TrimSuffix(strings.Repeat("?,", len(departmentIDs)), ",")
where = append(where, "scope.department_id IN ("+placeholders+")")
for _, id := range departmentIDs {
args = append(args, id)
}
case "responsible":
responsibleUserID = strings.TrimSpace(responsibleUserID)
if responsibleUserID == "" {
return []string{}, nil
}
where = append(where, "scope.responsible_user_id=?")
args = append(args, responsibleUserID)
default:
return []string{}, nil
}
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT UPPER(TRIM(scope.vin))
FROM business_scope_state st
JOIN business_customer_vehicle_scope scope ON BINARY scope.source_version=BINARY st.active_version
WHERE `+strings.Join(where, " AND ")+` AND scope.vin<>'' ORDER BY UPPER(TRIM(scope.vin))`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []string{}
for rows.Next() {
var vin string
if err := rows.Scan(&vin); err != nil {
return nil, err
}
if vin = strings.TrimSpace(vin); vin != "" {
result = append(result, vin)
}
}
return result, rows.Err()
}
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/batch":
s.batchCustomers(w, r, principal)
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
}
id, grantChanges, err := s.createCustomerRecord(r.Context(), input, menus, grants, actor.Name)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
writeUserMutationError(w, r, err, "创建")
} else {
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) batchCustomers(w http.ResponseWriter, r *http.Request, actor platform.Principal) {
var input userBatchRequest
if !decodeAuthJSON(w, r, &input) {
return
}
input.Mode = strings.ToLower(strings.TrimSpace(input.Mode))
if input.Mode != "preview" && input.Mode != "create" {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_MODE_INVALID", "批量操作模式必须为 preview 或 create", "", requestTraceID(r))
return
}
if len(input.Items) == 0 || len(input.Items) > maxUserBatchItems {
httpx.WriteError(w, http.StatusBadRequest, "USER_BATCH_SIZE_INVALID", fmt.Sprintf("每次需导入 1-%d 个客户账号", maxUserBatchItems), "", requestTraceID(r))
return
}
result := userBatchResult{Mode: input.Mode, Summary: userBatchSummary{Received: len(input.Items)}, Items: make([]userBatchResultItem, 0, len(input.Items))}
seen := map[string]bool{}
for index, item := range input.Items {
row := item.Row
if row <= 1 {
row = index + 2
}
item.Input.Username = strings.TrimSpace(item.Input.Username)
item.Input.DisplayName = strings.TrimSpace(item.Input.DisplayName)
item.Input.Status = firstNonEmpty(strings.TrimSpace(item.Input.Status), "enabled")
entry := userBatchResultItem{Row: row, Username: item.Input.Username, DisplayName: item.Input.DisplayName}
usernameKey := strings.ToLower(item.Input.Username)
if seen[usernameKey] {
entry.Status, entry.Code, entry.Message = "invalid", "DUPLICATE_IN_FILE", "文件内用户名重复"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
seen[usernameKey] = true
menus, grants, err := s.validateMutation(r.Context(), item.Input, true)
if err != nil {
entry.Status, entry.Code, entry.Message = "invalid", "USER_INPUT_INVALID", err.Error()
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
var existingID uint64
err = s.db.QueryRowContext(r.Context(), `SELECT id FROM platform_user WHERE LOWER(username)=LOWER(?) LIMIT 1`, item.Input.Username).Scan(&existingID)
if err == nil {
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
if !errors.Is(err, sql.ErrNoRows) {
entry.Status, entry.Code, entry.Message = "failed", "USER_CHECK_FAILED", "暂时无法校验用户名"
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
if input.Mode == "preview" {
entry.Status, entry.Message = "ready", "校验通过,可以创建"
result.Summary.Ready++
result.Items = append(result.Items, entry)
continue
}
id, grantChanges, err := s.createCustomerRecord(r.Context(), item.Input, menus, grants, actor.Name)
if err != nil {
entry.Status, entry.Code, entry.Message = "failed", "USER_CREATE_FAILED", "创建失败,请重试"
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
entry.Status, entry.Code, entry.Message = "conflict", "USERNAME_EXISTS", "用户名已存在"
}
result.Summary.Failed++
result.Items = append(result.Items, entry)
continue
}
entry.Status, entry.Message, entry.ID = "created", "账号与权限已创建", id
result.Summary.Created++
result.Items = append(result.Items, entry)
s.audit(r.Context(), actor.Name, "user.create", "user", strconv.FormatInt(id, 10), "success", map[string]any{"batch": true, "row": row, "menus": menus, "vehicleGrantChanges": grantChanges}, remoteAddress(r))
}
s.audit(r.Context(), actor.Name, "user.batch."+input.Mode, "user_batch", strconv.Itoa(len(input.Items)), "success", map[string]any{"received": result.Summary.Received, "ready": result.Summary.Ready, "created": result.Summary.Created, "failed": result.Summary.Failed}, remoteAddress(r))
httpx.WriteOK(w, requestTraceID(r), result)
}
func (s *authStore) createCustomerRecord(ctx context.Context, input userMutation, menus []string, grants []vehicleGrantMutation, actor string) (int64, []vehicleGrantAuditChange, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
if err != nil {
return 0, nil, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, nil, err
}
defer tx.Rollback()
created, err := tx.ExecContext(ctx, `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, actor)
if err != nil {
return 0, nil, err
}
id, err := created.LastInsertId()
if err != nil {
return 0, nil, err
}
grantChanges := []vehicleGrantAuditChange{}
if err := replaceGrants(ctx, tx, uint64(id), menus, grants, actor, &grantChanges); err != nil {
return 0, nil, err
}
if err := tx.Commit(); err != nil {
return 0, nil, err
}
return id, grantChanges, nil
}
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, authProvider string
if err := tx.QueryRowContext(r.Context(), `SELECT user_type,auth_provider FROM platform_user WHERE id=? FOR UPDATE`, id).Scan(&userType, &authProvider); err != nil || userType != "customer" {
httpx.WriteError(w, http.StatusBadRequest, "CUSTOMER_USER_REQUIRED", "只能通过此功能维护客户账号", "", requestTraceID(r))
return
}
if authProvider = strings.TrimSpace(authProvider); authProvider != "" && !strings.EqualFold(authProvider, "local") && input.Password != "" {
httpx.WriteError(w, http.StatusBadRequest, "EXTERNAL_IDENTITY_PASSWORD_READ_ONLY", "外部身份的登录凭据必须在身份源中维护", authProvider, 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))
}