@@ -0,0 +1,747 @@
package app
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"lingniu/vehicle-data-platform/apps/api/internal/config"
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
"lingniu/vehicle-data-platform/apps/api/internal/platform"
)
const (
localSessionCacheTTL = 30 * time . Second
loginLockDuration = 15 * time . Minute
maxLoginFailures = 5
)
var usernamePattern = regexp . MustCompile ( ` ^[A-Za-z0-9][A-Za-z0-9._-] { 2,63}$ ` )
var customerMenuSet = map [ string ] bool {
"monitor" : true , "vehicles" : true , "tracks" : true , "statistics" : true ,
}
var adminMenus = [ ] string { "monitor" , "vehicles" , "tracks" , "history" , "statistics" , "alerts" , "access" , "operations" , "users" }
type authUser struct {
ID uint64 ` json:"id" `
Username string ` json:"username" `
DisplayName string ` json:"displayName" `
UserType string ` json:"userType" `
Status string ` json:"status" `
CustomerRef string ` json:"customerRef" `
TenantRef string ` json:"tenantRef" `
AuthProvider string ` json:"authProvider" `
ExternalSubject string ` json:"externalSubject,omitempty" `
MenuKeys [ ] string ` json:"menuKeys" `
VehicleVINs [ ] string ` json:"vehicleVins" `
LastLoginAt * time . Time ` json:"lastLoginAt,omitempty" `
CreatedAt time . Time ` json:"createdAt" `
UpdatedAt time . Time ` json:"updatedAt" `
}
type localCredential struct {
authUser
PasswordHash string
FailedLoginCount int
LockedUntil sql . NullTime
}
type userMutation struct {
Username string ` json:"username" `
DisplayName string ` json:"displayName" `
Password string ` json:"password" `
Status string ` json:"status" `
CustomerRef string ` json:"customerRef" `
TenantRef string ` json:"tenantRef" `
MenuKeys [ ] string ` json:"menuKeys" `
VehicleVINs [ ] string ` json:"vehicleVins" `
}
type loginRequest struct {
Username string ` json:"username" `
Password string ` json:"password" `
}
type passwordChangeRequest struct {
CurrentPassword string ` json:"currentPassword" `
NewPassword string ` json:"newPassword" `
}
type loginResponse struct {
AccessToken string ` json:"accessToken" `
ExpiresAt time . Time ` json:"expiresAt" `
Session platform . Principal ` json:"session" `
}
type cachedSession struct {
principal platform . Principal
expiresAt time . Time
cachedAt time . Time
}
type authStore struct {
db * sql . DB
sessionTTL time . Duration
cacheMu sync . RWMutex
cache map [ string ] cachedSession
}
func newAuthStore ( db * sql . DB , cfg config . Config ) ( * authStore , error ) {
if db == nil {
return nil , nil
}
ttl := cfg . SessionTTL
if ttl <= 0 {
ttl = 12 * time . Hour
}
store := & authStore { db : db , sessionTTL : ttl , cache : map [ string ] cachedSession { } }
if err := store . ensureBootstrapAdmin ( context . Background ( ) , cfg . BootstrapAdminUsername , cfg . BootstrapAdminPassword ) ; err != nil {
return nil , err
}
return store , nil
}
func ( s * authStore ) ensureBootstrapAdmin ( ctx context . Context , username , password string ) error {
username = strings . TrimSpace ( username )
if username == "" && password == "" {
return nil
}
if ! usernamePattern . MatchString ( username ) {
return fmt . Errorf ( "BOOTSTRAP_ADMIN_USERNAME is invalid" )
}
if err := validatePassword ( password ) ; err != nil {
return fmt . Errorf ( "BOOTSTRAP_ADMIN_PASSWORD: %w" , err )
}
var exists int
if err := s . db . QueryRowContext ( ctx , ` SELECT COUNT(*) FROM platform_user WHERE user_type='admin' ` ) . Scan ( & exists ) ; err != nil {
return fmt . Errorf ( "check bootstrap admin: %w" , err )
}
if exists > 0 {
return nil
}
hash , err := bcrypt . GenerateFromPassword ( [ ] byte ( password ) , 12 )
if err != nil {
return fmt . Errorf ( "hash bootstrap password: %w" , err )
}
_ , err = s . db . ExecContext ( ctx , ` INSERT INTO platform_user(username,display_name,password_hash,user_type,status,auth_provider,created_by,updated_by) VALUES(?,?,?,'admin','enabled','local','bootstrap','bootstrap') ` , username , "平台管理员" , string ( hash ) )
if err != nil {
return fmt . Errorf ( "create bootstrap admin: %w" , err )
}
return nil
}
func ( s * authStore ) login ( w http . ResponseWriter , r * http . Request ) {
var input loginRequest
if ! decodeAuthJSON ( w , r , & input ) {
return
}
input . Username = strings . TrimSpace ( input . Username )
if ! usernamePattern . MatchString ( input . Username ) || len ( input . Password ) > 128 {
s . audit ( r . Context ( ) , input . Username , "login" , "user" , input . Username , "denied" , map [ string ] any { "reason" : "invalid_credentials" } , remoteAddress ( r ) )
httpx . WriteError ( w , http . StatusUnauthorized , "LOGIN_FAILED" , "用户名或密码错误" , "" , requestTraceID ( r ) )
return
}
credential , err := s . localCredential ( r . Context ( ) , input . Username )
if err != nil {
if ! errors . Is ( err , sql . ErrNoRows ) {
httpx . WriteError ( w , http . StatusInternalServerError , "AUTH_STORE_FAILED" , "登录服务暂时不可用" , "" , requestTraceID ( r ) )
return
}
s . audit ( r . Context ( ) , input . Username , "login" , "user" , input . Username , "denied" , map [ string ] any { "reason" : "invalid_credentials" } , remoteAddress ( r ) )
httpx . WriteError ( w , http . StatusUnauthorized , "LOGIN_FAILED" , "用户名或密码错误" , "" , requestTraceID ( r ) )
return
}
now := time . Now ( )
if credential . Status != "enabled" {
s . audit ( r . Context ( ) , input . Username , "login" , "user" , strconv . FormatUint ( credential . ID , 10 ) , "denied" , map [ string ] any { "reason" : "disabled" } , remoteAddress ( r ) )
httpx . WriteError ( w , http . StatusForbidden , "ACCOUNT_DISABLED" , "账号已停用,请联系管理员" , "" , requestTraceID ( r ) )
return
}
if credential . LockedUntil . Valid && credential . LockedUntil . Time . After ( now ) {
httpx . WriteError ( w , http . StatusTooManyRequests , "ACCOUNT_LOCKED" , "登录失败次数过多,请稍后再试" , credential . LockedUntil . Time . Format ( time . RFC3339 ) , requestTraceID ( r ) )
return
}
if bcrypt . CompareHashAndPassword ( [ ] byte ( credential . PasswordHash ) , [ ] byte ( input . Password ) ) != nil {
failures := credential . FailedLoginCount + 1
var locked any
if failures >= maxLoginFailures {
locked = now . Add ( loginLockDuration )
}
_ , _ = s . db . ExecContext ( r . Context ( ) , ` UPDATE platform_user SET failed_login_count=?,locked_until=? WHERE id=? ` , failures , locked , credential . ID )
s . audit ( r . Context ( ) , input . Username , "login" , "user" , strconv . FormatUint ( credential . ID , 10 ) , "denied" , map [ string ] any { "reason" : "invalid_credentials" , "failures" : failures } , remoteAddress ( r ) )
httpx . WriteError ( w , http . StatusUnauthorized , "LOGIN_FAILED" , "用户名或密码错误" , "" , requestTraceID ( r ) )
return
}
principal , err := s . principalForUser ( r . Context ( ) , credential . authUser )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "AUTH_STORE_FAILED" , "登录服务暂时不可用" , "" , requestTraceID ( r ) )
return
}
accessToken , tokenHash , err := randomSessionToken ( )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "SESSION_CREATE_FAILED" , "无法创建登录会话" , "" , requestTraceID ( r ) )
return
}
sessionID , err := randomHex ( 16 )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "SESSION_CREATE_FAILED" , "无法创建登录会话" , "" , requestTraceID ( r ) )
return
}
expiresAt := now . Add ( s . sessionTTL )
_ , err = s . db . ExecContext ( r . Context ( ) , ` INSERT INTO platform_user_session(id,user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent) VALUES(?,?,?,?,?,?,?,?) ` , sessionID , credential . ID , tokenHash [ : ] , now , expiresAt , now , remoteAddress ( r ) , truncateUTF8 ( r . UserAgent ( ) , 255 ) )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "SESSION_CREATE_FAILED" , "无法创建登录会话" , "" , requestTraceID ( r ) )
return
}
_ , _ = s . db . ExecContext ( r . Context ( ) , ` UPDATE platform_user SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=? ` , now , credential . ID )
principal . SessionID = sessionID
s . cachePut ( hex . EncodeToString ( tokenHash [ : ] ) , principal , expiresAt )
s . audit ( r . Context ( ) , principal . Name , "login" , "user" , strconv . FormatUint ( credential . ID , 10 ) , "success" , nil , remoteAddress ( r ) )
w . Header ( ) . Set ( "Cache-Control" , "no-store" )
httpx . WriteOK ( w , requestTraceID ( r ) , loginResponse { AccessToken : accessToken , ExpiresAt : expiresAt , Session : principal } )
}
func ( s * authStore ) authenticate ( ctx context . Context , token string ) ( platform . Principal , bool ) {
hash := sha256 . Sum256 ( [ ] byte ( token ) )
key := hex . EncodeToString ( hash [ : ] )
if principal , ok := s . cacheGet ( key ) ; ok {
return principal , true
}
var user authUser
var sessionID string
var expiresAt time . Time
var external sql . NullString
err := s . db . QueryRowContext ( ctx , ` SELECT u.id,u.username,u.display_name,u.user_type,u.status,u.customer_ref,u.tenant_ref,u.auth_provider,u.external_subject,u.last_login_at,u.created_at,u.updated_at,s.id,s.expires_at FROM platform_user_session s JOIN platform_user u ON u.id=s.user_id WHERE s.token_hash=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) AND u.status='enabled' ` , hash [ : ] ) . Scan (
& user . ID , & user . Username , & user . DisplayName , & user . UserType , & user . Status , & user . CustomerRef , & user . TenantRef , & user . AuthProvider , & external , & user . LastLoginAt , & user . CreatedAt , & user . UpdatedAt , & sessionID , & expiresAt ,
)
if err != nil {
return platform . Principal { } , false
}
user . ExternalSubject = external . String
principal , err := s . principalForUser ( ctx , user )
if err != nil {
return platform . Principal { } , false
}
principal . SessionID = sessionID
s . cachePut ( key , principal , expiresAt )
return principal , true
}
func ( s * authStore ) logout ( ctx context . Context , token string ) {
hash := sha256 . Sum256 ( [ ] byte ( token ) )
_ , _ = s . db . ExecContext ( ctx , ` UPDATE platform_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL ` , hash [ : ] )
s . cacheMu . Lock ( )
delete ( s . cache , hex . EncodeToString ( hash [ : ] ) )
s . cacheMu . Unlock ( )
}
func ( s * authStore ) changePassword ( w http . ResponseWriter , r * http . Request , principal platform . Principal ) {
if principal . AuthProvider != "local" || principal . SubjectID == "" {
httpx . WriteError ( w , http . StatusBadRequest , "PASSWORD_CHANGE_UNAVAILABLE" , "当前登录方式不支持修改密码" , "" , requestTraceID ( r ) )
return
}
var input passwordChangeRequest
if ! decodeAuthJSON ( w , r , & input ) {
return
}
if err := validatePassword ( input . NewPassword ) ; err != nil {
httpx . WriteError ( w , http . StatusBadRequest , "PASSWORD_INVALID" , err . Error ( ) , "" , requestTraceID ( r ) )
return
}
id , _ := strconv . ParseUint ( principal . SubjectID , 10 , 64 )
var currentHash string
if err := s . db . QueryRowContext ( r . Context ( ) , ` SELECT password_hash FROM platform_user WHERE id=? AND status='enabled' ` , id ) . Scan ( & currentHash ) ; err != nil || bcrypt . CompareHashAndPassword ( [ ] byte ( currentHash ) , [ ] byte ( input . CurrentPassword ) ) != nil {
s . audit ( r . Context ( ) , principal . Name , "password.change" , "user" , principal . SubjectID , "denied" , map [ string ] any { "reason" : "current_password_invalid" } , remoteAddress ( r ) )
httpx . WriteError ( w , http . StatusUnauthorized , "CURRENT_PASSWORD_INVALID" , "当前密码不正确" , "" , requestTraceID ( r ) )
return
}
if bcrypt . CompareHashAndPassword ( [ ] byte ( currentHash ) , [ ] byte ( input . NewPassword ) ) == nil {
httpx . WriteError ( w , http . StatusBadRequest , "PASSWORD_UNCHANGED" , "新密码不能与当前密码相同" , "" , requestTraceID ( r ) )
return
}
hash , _ := bcrypt . GenerateFromPassword ( [ ] byte ( input . NewPassword ) , 12 )
tx , err := s . db . BeginTx ( r . Context ( ) , nil )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "PASSWORD_CHANGE_FAILED" , "无法修改密码" , "" , requestTraceID ( r ) )
return
}
defer tx . Rollback ( )
if _ , err := tx . ExecContext ( r . Context ( ) , ` UPDATE platform_user SET password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL,updated_by=? WHERE id=? ` , string ( hash ) , principal . Name , id ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "PASSWORD_CHANGE_FAILED" , "无法修改密码" , "" , requestTraceID ( r ) )
return
}
if _ , err := tx . ExecContext ( r . Context ( ) , ` UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL ` , id ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "PASSWORD_CHANGE_FAILED" , "无法撤销旧会话" , "" , requestTraceID ( r ) )
return
}
if err := tx . Commit ( ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "PASSWORD_CHANGE_FAILED" , "无法修改密码" , "" , requestTraceID ( r ) )
return
}
s . invalidateUser ( id )
s . audit ( r . Context ( ) , principal . Name , "password.change" , "user" , principal . SubjectID , "success" , nil , remoteAddress ( r ) )
httpx . WriteOK ( w , requestTraceID ( r ) , map [ string ] bool { "changed" : true } )
}
func ( s * authStore ) localCredential ( ctx context . Context , username string ) ( localCredential , error ) {
var credential localCredential
var external sql . NullString
err := s . db . QueryRowContext ( ctx , ` SELECT id,username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,failed_login_count,locked_until,last_login_at,created_at,updated_at FROM platform_user WHERE username=? AND auth_provider='local' ` , username ) . Scan (
& credential . ID , & credential . Username , & credential . DisplayName , & credential . PasswordHash , & credential . UserType , & credential . Status , & credential . CustomerRef , & credential . TenantRef , & credential . AuthProvider , & external , & credential . FailedLoginCount , & credential . LockedUntil , & credential . LastLoginAt , & credential . CreatedAt , & credential . UpdatedAt ,
)
credential . ExternalSubject = external . String
return credential , err
}
func ( s * authStore ) principalForUser ( ctx context . Context , user authUser ) ( platform . Principal , error ) {
menus := append ( [ ] string ( nil ) , adminMenus ... )
vehicles := [ ] string { }
if user . UserType == "customer" {
menus = [ ] string { }
rows , err := s . db . QueryContext ( ctx , ` SELECT menu_key FROM platform_user_menu WHERE user_id=? ORDER BY menu_key ` , user . ID )
if err != nil {
return platform . Principal { } , err
}
for rows . Next ( ) {
var value string
if err := rows . Scan ( & value ) ; err != nil {
rows . Close ( )
return platform . Principal { } , err
}
if customerMenuSet [ value ] {
menus = append ( menus , value )
}
}
if err := rows . Close ( ) ; err != nil {
return platform . Principal { } , err
}
rows , err = s . db . QueryContext ( ctx , ` SELECT vin FROM platform_user_vehicle WHERE user_id=? AND (valid_from IS NULL OR valid_from<=NOW(3)) AND (valid_to IS NULL OR valid_to>NOW(3)) ORDER BY vin ` , user . ID )
if err != nil {
return platform . Principal { } , err
}
for rows . Next ( ) {
var vin string
if err := rows . Scan ( & vin ) ; err != nil {
rows . Close ( )
return platform . Principal { } , err
}
vehicles = append ( vehicles , vin )
}
if err := rows . Close ( ) ; err != nil {
return platform . Principal { } , err
}
}
return platform . Principal {
SubjectID : strconv . FormatUint ( user . ID , 10 ) , Name : user . DisplayName , Username : user . Username ,
Role : user . UserType , UserType : user . UserType , CustomerRef : user . CustomerRef , TenantRef : user . TenantRef ,
AuthProvider : user . AuthProvider , MenuKeys : menus , VehicleVINs : vehicles ,
} , nil
}
func ( s * authStore ) handleAdmin ( w http . ResponseWriter , r * http . Request , principal platform . Principal ) bool {
if r . URL . Path != "/api/v2/admin/users" && ! strings . HasPrefix ( r . URL . Path , "/api/v2/admin/users/" ) {
return false
}
if principal . UserType != "admin" && principal . Role != "admin" {
httpx . WriteError ( w , http . StatusForbidden , "PERMISSION_DENIED" , "仅管理员可以管理账号与权限" , "" , requestTraceID ( r ) )
return true
}
switch {
case r . Method == http . MethodGet && r . URL . Path == "/api/v2/admin/users" :
s . listUsers ( w , r )
case r . Method == http . MethodPost && r . URL . Path == "/api/v2/admin/users" :
s . createCustomer ( w , r , principal )
case r . Method == http . MethodPut && strings . HasPrefix ( r . URL . Path , "/api/v2/admin/users/" ) :
s . updateCustomer ( w , r , principal )
default :
httpx . WriteError ( w , http . StatusMethodNotAllowed , "METHOD_NOT_ALLOWED" , "不支持的账号管理操作" , "" , requestTraceID ( r ) )
}
return true
}
func ( s * authStore ) listUsers ( w http . ResponseWriter , r * http . Request ) {
rows , err := s . db . QueryContext ( r . Context ( ) , ` SELECT id,username,display_name,user_type,status,customer_ref,tenant_ref,auth_provider,external_subject,last_login_at,created_at,updated_at FROM platform_user ORDER BY user_type ASC,created_at DESC ` )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_LIST_FAILED" , "无法读取账号列表" , "" , requestTraceID ( r ) )
return
}
defer rows . Close ( )
users := [ ] authUser { }
for rows . Next ( ) {
var user authUser
var external sql . NullString
if err := rows . Scan ( & user . ID , & user . Username , & user . DisplayName , & user . UserType , & user . Status , & user . CustomerRef , & user . TenantRef , & user . AuthProvider , & external , & user . LastLoginAt , & user . CreatedAt , & user . UpdatedAt ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_LIST_FAILED" , "无法读取账号列表" , "" , requestTraceID ( r ) )
return
}
user . ExternalSubject = external . String
principal , err := s . principalForUser ( r . Context ( ) , user )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_LIST_FAILED" , "无法读取账号权限" , "" , requestTraceID ( r ) )
return
}
user . MenuKeys = principal . MenuKeys
user . VehicleVINs = principal . VehicleVINs
users = append ( users , user )
}
httpx . WriteOK ( w , requestTraceID ( r ) , users )
}
func ( s * authStore ) createCustomer ( w http . ResponseWriter , r * http . Request , actor platform . Principal ) {
var input userMutation
if ! decodeAuthJSON ( w , r , & input ) {
return
}
input . Status = firstNonEmpty ( strings . TrimSpace ( input . Status ) , "enabled" )
menus , vins , err := s . validateMutation ( r . Context ( ) , input , true )
if err != nil {
httpx . WriteError ( w , http . StatusBadRequest , "USER_INPUT_INVALID" , err . Error ( ) , "" , requestTraceID ( r ) )
return
}
hash , _ := bcrypt . GenerateFromPassword ( [ ] byte ( input . Password ) , 12 )
tx , err := s . db . BeginTx ( r . Context ( ) , nil )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_CREATE_FAILED" , "无法创建客户账号" , "" , requestTraceID ( r ) )
return
}
defer tx . Rollback ( )
result , err := tx . ExecContext ( r . Context ( ) , ` INSERT INTO platform_user(username,display_name,password_hash,user_type,status,customer_ref,tenant_ref,auth_provider,created_by,updated_by) VALUES(?,?,?,'customer',?,?,?,'local',?,?) ` , strings . TrimSpace ( input . Username ) , strings . TrimSpace ( input . DisplayName ) , string ( hash ) , input . Status , strings . TrimSpace ( input . CustomerRef ) , strings . TrimSpace ( input . TenantRef ) , actor . Name , actor . Name )
if err != nil {
writeUserMutationError ( w , r , err , "创建" )
return
}
id , _ := result . LastInsertId ( )
if err := replaceGrants ( r . Context ( ) , tx , uint64 ( id ) , menus , vins , actor . Name ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_CREATE_FAILED" , "无法保存客户权限" , "" , requestTraceID ( r ) )
return
}
if err := tx . Commit ( ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_CREATE_FAILED" , "无法创建客户账号" , "" , requestTraceID ( r ) )
return
}
s . audit ( r . Context ( ) , actor . Name , "user.create" , "user" , strconv . FormatInt ( id , 10 ) , "success" , map [ string ] any { "menus" : menus , "vehicleCount" : len ( vins ) } , remoteAddress ( r ) )
httpx . WriteOK ( w , requestTraceID ( r ) , map [ string ] any { "id" : id } )
}
func ( s * authStore ) updateCustomer ( w http . ResponseWriter , r * http . Request , actor platform . Principal ) {
idText := strings . TrimPrefix ( r . URL . Path , "/api/v2/admin/users/" )
id , err := strconv . ParseUint ( idText , 10 , 64 )
if err != nil || id == 0 {
httpx . WriteError ( w , http . StatusBadRequest , "USER_ID_INVALID" , "账号编号无效" , "" , requestTraceID ( r ) )
return
}
var input userMutation
if ! decodeAuthJSON ( w , r , & input ) {
return
}
input . Status = firstNonEmpty ( strings . TrimSpace ( input . Status ) , "enabled" )
menus , vins , err := s . validateMutation ( r . Context ( ) , input , false )
if err != nil {
httpx . WriteError ( w , http . StatusBadRequest , "USER_INPUT_INVALID" , err . Error ( ) , "" , requestTraceID ( r ) )
return
}
tx , err := s . db . BeginTx ( r . Context ( ) , nil )
if err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_UPDATE_FAILED" , "无法更新客户账号" , "" , requestTraceID ( r ) )
return
}
defer tx . Rollback ( )
var userType string
if err := tx . QueryRowContext ( r . Context ( ) , ` SELECT user_type FROM platform_user WHERE id=? FOR UPDATE ` , id ) . Scan ( & userType ) ; err != nil || userType != "customer" {
httpx . WriteError ( w , http . StatusBadRequest , "CUSTOMER_USER_REQUIRED" , "只能通过此功能维护客户账号" , "" , requestTraceID ( r ) )
return
}
args := [ ] any { strings . TrimSpace ( input . DisplayName ) , input . Status , strings . TrimSpace ( input . CustomerRef ) , strings . TrimSpace ( input . TenantRef ) , actor . Name }
query := ` UPDATE platform_user SET display_name=?,status=?,customer_ref=?,tenant_ref=?,updated_by=? `
if input . Password != "" {
hash , _ := bcrypt . GenerateFromPassword ( [ ] byte ( input . Password ) , 12 )
query += ` ,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL `
args = append ( args , string ( hash ) )
}
query += ` WHERE id=? `
args = append ( args , id )
if _ , err := tx . ExecContext ( r . Context ( ) , query , args ... ) ; err != nil {
writeUserMutationError ( w , r , err , "更新" )
return
}
if err := replaceGrants ( r . Context ( ) , tx , id , menus , vins , actor . Name ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_UPDATE_FAILED" , "无法保存客户权限" , "" , requestTraceID ( r ) )
return
}
if input . Status == "disabled" || input . Password != "" {
if _ , err := tx . ExecContext ( r . Context ( ) , ` UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL ` , id ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_UPDATE_FAILED" , "无法撤销旧会话" , "" , requestTraceID ( r ) )
return
}
}
if err := tx . Commit ( ) ; err != nil {
httpx . WriteError ( w , http . StatusInternalServerError , "USER_UPDATE_FAILED" , "无法更新客户账号" , "" , requestTraceID ( r ) )
return
}
s . invalidateUser ( id )
s . audit ( r . Context ( ) , actor . Name , "user.update" , "user" , idText , "success" , map [ string ] any { "status" : input . Status , "menus" : menus , "vehicleCount" : len ( vins ) , "passwordReset" : input . Password != "" } , remoteAddress ( r ) )
httpx . WriteOK ( w , requestTraceID ( r ) , map [ string ] any { "id" : id } )
}
func ( s * authStore ) validateMutation ( ctx context . Context , input userMutation , requirePassword bool ) ( [ ] string , [ ] string , error ) {
if requirePassword && ! usernamePattern . MatchString ( strings . TrimSpace ( input . Username ) ) {
return nil , nil , fmt . Errorf ( "用户名需为 3-64 位字母、数字、点、下划线或短横线" )
}
if strings . TrimSpace ( input . DisplayName ) == "" || len ( [ ] rune ( strings . TrimSpace ( input . DisplayName ) ) ) > 48 {
return nil , nil , fmt . Errorf ( "客户名称不能为空且不能超过 48 个字符" )
}
if input . Status != "enabled" && input . Status != "disabled" {
return nil , nil , fmt . Errorf ( "账号状态无效" )
}
if requirePassword || input . Password != "" {
if err := validatePassword ( input . Password ) ; err != nil {
return nil , nil , err
}
}
menus := normalizeMenus ( input . MenuKeys )
if len ( menus ) == 0 {
return nil , nil , fmt . Errorf ( "至少分配一个客户菜单" )
}
vins := normalizeVINs ( input . VehicleVINs )
if len ( vins ) == 0 {
return nil , nil , fmt . Errorf ( "至少分配一辆可查看车辆" )
}
if len ( vins ) > 5000 {
return nil , nil , fmt . Errorf ( "单个客户最多分配 5000 辆车" )
}
found , err := existingVINs ( ctx , s . db , vins )
if err != nil {
return nil , nil , fmt . Errorf ( "校验车辆失败" )
}
if len ( found ) != len ( vins ) {
missing := [ ] string { }
for _ , vin := range vins {
if ! found [ vin ] {
missing = append ( missing , vin )
}
if len ( missing ) == 5 {
break
}
}
return nil , nil , fmt . Errorf ( "存在未接入的 VIN: %s" , strings . Join ( missing , "、" ) )
}
return menus , vins , nil
}
func replaceGrants ( ctx context . Context , tx * sql . Tx , userID uint64 , menus , vins [ ] string , actor string ) error {
if _ , err := tx . ExecContext ( ctx , ` DELETE FROM platform_user_menu WHERE user_id=? ` , userID ) ; err != nil {
return err
}
for _ , menu := range menus {
if _ , err := tx . ExecContext ( ctx , ` INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?) ` , userID , menu , actor ) ; err != nil {
return err
}
}
if _ , err := tx . ExecContext ( ctx , ` DELETE FROM platform_user_vehicle WHERE user_id=? ` , userID ) ; err != nil {
return err
}
for _ , vin := range vins {
if _ , err := tx . ExecContext ( ctx , ` INSERT INTO platform_user_vehicle(user_id,vin,source_system,granted_by) VALUES(?,?,'manual',?) ` , userID , vin , actor ) ; err != nil {
return err
}
}
return nil
}
func existingVINs ( ctx context . Context , db * sql . DB , vins [ ] string ) ( map [ string ] bool , error ) {
result := map [ string ] bool { }
for start := 0 ; start < len ( vins ) ; start += 500 {
end := start + 500
if end > len ( vins ) {
end = len ( vins )
}
part := vins [ start : end ]
placeholders := strings . TrimSuffix ( strings . Repeat ( "?," , len ( part ) ) , "," )
args := make ( [ ] any , len ( part ) )
for index := range part {
args [ index ] = part [ index ]
}
rows , err := db . QueryContext ( ctx , ` SELECT vin FROM (SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin<>'' UNION SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin<>'') v WHERE vin IN ( ` + placeholders + ` ) ` , args ... )
if err != nil {
return nil , err
}
for rows . Next ( ) {
var vin string
if err := rows . Scan ( & vin ) ; err != nil {
rows . Close ( )
return nil , err
}
result [ strings . ToUpper ( strings . TrimSpace ( vin ) ) ] = true
}
if err := rows . Close ( ) ; err != nil {
return nil , err
}
}
return result , nil
}
func normalizeMenus ( values [ ] string ) [ ] string {
seen := map [ string ] bool { }
menus := [ ] string { }
for _ , value := range values {
value = strings . ToLower ( strings . TrimSpace ( value ) )
if customerMenuSet [ value ] && ! seen [ value ] {
seen [ value ] = true
menus = append ( menus , value )
}
}
sort . Strings ( menus )
return menus
}
func normalizeVINs ( values [ ] string ) [ ] string {
seen := map [ string ] bool { }
vins := [ ] string { }
for _ , value := range values {
for _ , part := range strings . FieldsFunc ( value , func ( r rune ) bool {
return r == ',' || r == ', ' || r == ';' || r == '; ' || r == '\n' || r == '\t' || r == ' '
} ) {
vin := strings . ToUpper ( strings . TrimSpace ( part ) )
if vin != "" && len ( vin ) <= 64 && ! seen [ vin ] {
seen [ vin ] = true
vins = append ( vins , vin )
}
}
}
sort . Strings ( vins )
return vins
}
func validatePassword ( password string ) error {
if len ( password ) < 10 || len ( password ) > 128 {
return fmt . Errorf ( "密码长度需为 10-128 位" )
}
classes := 0
if regexp . MustCompile ( ` [a-z] ` ) . MatchString ( password ) {
classes ++
}
if regexp . MustCompile ( ` [A-Z] ` ) . MatchString ( password ) {
classes ++
}
if regexp . MustCompile ( ` [0-9] ` ) . MatchString ( password ) {
classes ++
}
if regexp . MustCompile ( ` [^A-Za-z0-9] ` ) . MatchString ( password ) {
classes ++
}
if classes < 3 {
return fmt . Errorf ( "密码需包含大小写字母、数字、特殊字符中的至少三类" )
}
return nil
}
func randomSessionToken ( ) ( string , [ sha256 . Size ] byte , error ) {
var raw [ 32 ] byte
if _ , err := rand . Read ( raw [ : ] ) ; err != nil {
return "" , [ sha256 . Size ] byte { } , err
}
token := base64 . RawURLEncoding . EncodeToString ( raw [ : ] )
return token , sha256 . Sum256 ( [ ] byte ( token ) ) , nil
}
func randomHex ( size int ) ( string , error ) {
raw := make ( [ ] byte , size )
if _ , err := rand . Read ( raw ) ; err != nil {
return "" , err
}
return hex . EncodeToString ( raw ) , nil
}
func ( s * authStore ) cacheGet ( key string ) ( platform . Principal , bool ) {
s . cacheMu . RLock ( )
entry , ok := s . cache [ key ]
s . cacheMu . RUnlock ( )
now := time . Now ( )
if ! ok || now . After ( entry . expiresAt ) || now . Sub ( entry . cachedAt ) > localSessionCacheTTL {
return platform . Principal { } , false
}
return entry . principal . Clone ( ) , true
}
func ( s * authStore ) cachePut ( key string , principal platform . Principal , expiresAt time . Time ) {
s . cacheMu . Lock ( )
s . cache [ key ] = cachedSession { principal : principal . Clone ( ) , expiresAt : expiresAt , cachedAt : time . Now ( ) }
s . cacheMu . Unlock ( )
}
func ( s * authStore ) invalidateUser ( userID uint64 ) {
subject := strconv . FormatUint ( userID , 10 )
s . cacheMu . Lock ( )
for key , entry := range s . cache {
if entry . principal . SubjectID == subject {
delete ( s . cache , key )
}
}
s . cacheMu . Unlock ( )
}
func ( s * authStore ) audit ( ctx context . Context , actor , action , targetType , targetID , result string , detail any , remote string ) {
var detailJSON any
if detail != nil {
if encoded , err := json . Marshal ( detail ) ; err == nil {
detailJSON = encoded
}
}
_ , _ = s . db . ExecContext ( ctx , ` INSERT INTO platform_auth_audit(actor,action,target_type,target_id,result,detail_json,remote_addr) VALUES(?,?,?,?,?,?,?) ` , truncateUTF8 ( actor , 96 ) , action , targetType , targetID , result , detailJSON , truncateUTF8 ( remote , 96 ) )
}
func decodeAuthJSON ( w http . ResponseWriter , r * http . Request , target any ) bool {
defer r . Body . Close ( )
decoder := json . NewDecoder ( http . MaxBytesReader ( w , r . Body , 2 << 20 ) )
decoder . DisallowUnknownFields ( )
if err := decoder . Decode ( target ) ; err != nil {
httpx . WriteError ( w , http . StatusBadRequest , "BAD_JSON" , "请求 JSON 解析失败" , "" , requestTraceID ( r ) )
return false
}
return true
}
func remoteAddress ( r * http . Request ) string {
if forwarded := strings . TrimSpace ( strings . Split ( r . Header . Get ( "X-Forwarded-For" ) , "," ) [ 0 ] ) ; forwarded != "" {
return forwarded
}
host , _ , err := net . SplitHostPort ( r . RemoteAddr )
if err == nil {
return host
}
return r . RemoteAddr
}
func truncateUTF8 ( value string , max int ) string {
runes := [ ] rune ( strings . TrimSpace ( value ) )
if len ( runes ) > max {
runes = runes [ : max ]
}
return string ( runes )
}
func writeUserMutationError ( w http . ResponseWriter , r * http . Request , err error , action string ) {
message := "无法" + action + "客户账号"
if strings . Contains ( strings . ToLower ( err . Error ( ) ) , "duplicate" ) {
message = "用户名已存在"
}
httpx . WriteError ( w , http . StatusConflict , "USER_MUTATION_FAILED" , message , "" , requestTraceID ( r ) )
}