feat(auth): manage vehicle grant intervals
This commit is contained in:
@@ -41,26 +41,67 @@ var customerMenuSet = map[string]bool{
|
||||
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"`
|
||||
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"`
|
||||
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 {
|
||||
@@ -71,14 +112,15 @@ type localCredential struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status"`
|
||||
CustomerRef string `json:"customerRef"`
|
||||
TenantRef string `json:"tenantRef"`
|
||||
MenuKeys []string `json:"menuKeys"`
|
||||
VehicleVINs []string `json:"vehicleVins"`
|
||||
VehicleGrants []userVehicleGrantInput `json:"vehicleGrants"`
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -414,8 +456,21 @@ func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
user.MenuKeys = principal.MenuKeys
|
||||
user.VehicleVINs = principal.VehicleVINs
|
||||
allVINs = append(allVINs, principal.VehicleVINs...)
|
||||
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 {
|
||||
@@ -428,18 +483,66 @@ func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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 {
|
||||
@@ -490,7 +593,7 @@ func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, vins, err := s.validateMutation(r.Context(), input, true)
|
||||
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
|
||||
@@ -508,7 +611,8 @@ func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
return
|
||||
}
|
||||
id, _ := result.LastInsertId()
|
||||
if err := replaceGrants(r.Context(), tx, uint64(id), menus, vins, actor.Name); err != nil {
|
||||
grantChanges := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(r.Context(), tx, uint64(id), menus, grants, actor.Name, &grantChanges); err != nil {
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "USER_CREATE_FAILED", "无法保存客户权限", "", requestTraceID(r))
|
||||
return
|
||||
}
|
||||
@@ -516,7 +620,7 @@ func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
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))
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -532,7 +636,7 @@ func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
return
|
||||
}
|
||||
input.Status = firstNonEmpty(strings.TrimSpace(input.Status), "enabled")
|
||||
menus, vins, err := s.validateMutation(r.Context(), input, false)
|
||||
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
|
||||
@@ -561,7 +665,8 @@ func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
writeUserMutationError(w, r, err, "更新")
|
||||
return
|
||||
}
|
||||
if err := replaceGrants(r.Context(), tx, id, menus, vins, actor.Name); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -576,11 +681,11 @@ func (s *authStore) updateCustomer(w http.ResponseWriter, r *http.Request, actor
|
||||
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))
|
||||
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, []string, error) {
|
||||
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 位字母、数字、点、下划线或短横线")
|
||||
}
|
||||
@@ -599,13 +704,17 @@ func (s *authStore) validateMutation(ctx context.Context, input userMutation, re
|
||||
if len(menus) == 0 {
|
||||
return nil, nil, fmt.Errorf("至少分配一个客户菜单")
|
||||
}
|
||||
vins := normalizeVINs(input.VehicleVINs)
|
||||
if len(vins) == 0 {
|
||||
grants, err := normalizeVehicleGrantMutations(input)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(grants) == 0 {
|
||||
return nil, nil, fmt.Errorf("至少分配一辆可查看车辆")
|
||||
}
|
||||
if len(vins) > 5000 {
|
||||
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("校验车辆失败")
|
||||
@@ -622,10 +731,18 @@ func (s *authStore) validateMutation(ctx context.Context, input userMutation, re
|
||||
}
|
||||
return nil, nil, fmt.Errorf("存在未接入的 VIN:%s", strings.Join(missing, "、"))
|
||||
}
|
||||
return menus, vins, nil
|
||||
return menus, grants, nil
|
||||
}
|
||||
|
||||
func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins []string, actor string) error {
|
||||
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
|
||||
}
|
||||
@@ -634,58 +751,127 @@ func replaceGrants(ctx context.Context, tx *sql.Tx, userID uint64, menus, vins [
|
||||
return err
|
||||
}
|
||||
}
|
||||
currentVINs := map[string]bool{}
|
||||
rows, err := tx.QueryContext(ctx, `SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`, userID)
|
||||
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
|
||||
if err := rows.Scan(&vin); err != nil {
|
||||
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
|
||||
}
|
||||
currentVINs[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||||
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
|
||||
}
|
||||
selectedVINs := make(map[string]bool, len(vins))
|
||||
for _, vin := range vins {
|
||||
selectedVINs[vin] = true
|
||||
selected := make(map[string]vehicleGrantMutation, len(grants))
|
||||
for _, grant := range grants {
|
||||
selected[grant.VIN] = grant
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL`, actor, userID); err != nil {
|
||||
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)
|
||||
}
|
||||
historyArgs := append([]any{actor}, args...)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (`+placeholders+`)`, historyArgs...); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (`+placeholders+`)`, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, vin := range vins {
|
||||
if currentVINs[vin] && selectedVINs[vin] {
|
||||
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,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`, userID, vin, actor); err != nil {
|
||||
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,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`, userID, vin); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -754,6 +940,99 @@ func normalizeVINs(values []string) []string {
|
||||
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 位")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
@@ -26,24 +27,33 @@ func TestReplaceGrantsClosesRemovedHistoryAndCreatesNewInterval(t *testing.T) {
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).
|
||||
WithArgs(uint64(7), "monitor", "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)).
|
||||
validFrom := time.Date(2026, 6, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`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`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)).
|
||||
WithArgs("admin-a", uint64(7), "VIN002").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", validFrom, nil, 11, validFrom, nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`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=?`)).
|
||||
WithArgs("admin-a", uint64(11)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).
|
||||
WithArgs(uint64(7), "VIN002").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle(user_id,vin,valid_from,source_system,granted_by) VALUES(?,?,NOW(3),'manual',?)`)).
|
||||
WithArgs(uint64(7), "VIN002", "admin-a").
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle(user_id,vin,valid_from,valid_to,source_system,granted_by) VALUES(?,?,?,?,'manual',?)`)).
|
||||
WithArgs(uint64(7), "VIN002", sqlmock.AnyArg(), nil, "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,source_system,external_ref,granted_by) SELECT user_id,vin,valid_from,source_system,external_ref,granted_by FROM platform_user_vehicle WHERE user_id=? AND vin=?`)).
|
||||
WithArgs(uint64(7), "VIN002").
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_vehicle_grant_history(user_id,vin,valid_from,valid_to,source_system,external_ref,granted_by)
|
||||
VALUES(?,?,?,?, 'manual','',?)`)).
|
||||
WithArgs(uint64(7), "VIN002", sqlmock.AnyArg(), nil, "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN002"}, "admin-a"); err != nil {
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []vehicleGrantMutation{{VIN: "VIN002"}}, "admin-a", nil); err != nil {
|
||||
t.Fatalf("replaceGrants failed: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -72,18 +82,21 @@ func TestReplaceGrantsPreservesUnchangedVehicleStartTime(t *testing.T) {
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).
|
||||
WithArgs(uint64(7), "monitor", "admin-a").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT vin FROM platform_user_vehicle WHERE user_id=? FOR UPDATE`)).
|
||||
validFrom := time.Date(2026, 6, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`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`)).
|
||||
WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history SET valid_to=NOW(3),revoked_by=? WHERE user_id=? AND valid_to IS NULL AND vin NOT IN (?)`)).
|
||||
WithArgs("admin-a", uint64(7), "VIN001").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", validFrom, nil, 12, validFrom, nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).
|
||||
WithArgs(uint64(7), "VIN001").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectCommit()
|
||||
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []string{"VIN001"}, "admin-a"); err != nil {
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"monitor"}, []vehicleGrantMutation{{VIN: "VIN001"}}, "admin-a", nil); err != nil {
|
||||
t.Fatalf("replaceGrants failed: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -93,3 +106,107 @@ func TestReplaceGrantsPreservesUnchangedVehicleStartTime(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceGrantsUpdatesCurrentProjectionAndMatchingHistoryTogether(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromBefore := time.Date(2026, 7, 16, 14, 8, 18, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
fromAfter := time.Date(2026, 6, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).WithArgs(uint64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).WithArgs(uint64(7), "statistics", "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`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`)).WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", fromBefore, nil, 19, fromAfter, nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).WithArgs(uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle SET valid_from=?,valid_to=?,source_system='manual',granted_by=? WHERE user_id=? AND vin=?`)).
|
||||
WithArgs(fromAfter, nil, "admin-a", uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history
|
||||
SET valid_from=?,valid_to=?,source_system='manual',granted_by=?,revoked_by='',updated_at=NOW(3) WHERE id=?`)).
|
||||
WithArgs(fromAfter, nil, "admin-a", uint64(19)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
changes := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"statistics"}, []vehicleGrantMutation{{VIN: "VIN001", ValidFrom: &fromAfter}}, "admin-a", &changes); err != nil {
|
||||
t.Fatalf("replaceGrants failed: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 || changes[0].Before == nil || changes[0].After == nil ||
|
||||
!changes[0].Before.ValidFrom.Equal(fromBefore) || !changes[0].After.ValidFrom.Equal(fromAfter) {
|
||||
t.Fatalf("grant audit must retain before/after interval: %+v", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceGrantsRepairsHistoryDriftEvenWhenProjectionDateIsUnchanged(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectionFrom := time.Date(2026, 7, 16, 14, 8, 18, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
driftedHistoryFrom := time.Date(2026, 6, 5, 19, 6, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_menu WHERE user_id=?`)).WithArgs(uint64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO platform_user_menu(user_id,menu_key,granted_by) VALUES(?,?,?)`)).WithArgs(uint64(7), "statistics", "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`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`)).WithArgs(uint64(7)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "valid_from", "valid_to", "history_id", "history_valid_from", "history_valid_to"}).AddRow("VIN001", projectionFrom, nil, 19, driftedHistoryFrom, nil))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM platform_user_vehicle WHERE user_id=? AND vin NOT IN (?)`)).WithArgs(uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle SET valid_from=?,valid_to=?,source_system='manual',granted_by=? WHERE user_id=? AND vin=?`)).
|
||||
WithArgs(projectionFrom, nil, "admin-a", uint64(7), "VIN001").WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
mock.ExpectExec(regexp.QuoteMeta(`UPDATE platform_user_vehicle_grant_history
|
||||
SET valid_from=?,valid_to=?,source_system='manual',granted_by=?,revoked_by='',updated_at=NOW(3) WHERE id=?`)).
|
||||
WithArgs(projectionFrom, nil, "admin-a", uint64(19)).WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
changes := []vehicleGrantAuditChange{}
|
||||
if err := replaceGrants(context.Background(), tx, 7, []string{"statistics"}, []vehicleGrantMutation{{VIN: "VIN001", ValidFrom: &projectionFrom}}, "admin-a", &changes); err != nil {
|
||||
t.Fatalf("replaceGrants failed: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(changes) != 1 {
|
||||
t.Fatalf("history drift repair must be audited: %+v", changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeVehicleGrantMutationsRequiresOrderedInterval(t *testing.T) {
|
||||
_, err := normalizeVehicleGrantMutations(userMutation{VehicleGrants: []userVehicleGrantInput{{
|
||||
VIN: "VIN001", ValidFrom: "2026-06-05T00:00", ValidTo: "2026-06-04T23:59",
|
||||
}}})
|
||||
if err == nil {
|
||||
t.Fatal("invalid authorization interval must be rejected")
|
||||
}
|
||||
grants, err := normalizeVehicleGrantMutations(userMutation{VehicleGrants: []userVehicleGrantInput{{
|
||||
VIN: "vin001", ValidFrom: "2026-06-05T00:00",
|
||||
}}})
|
||||
if err != nil || len(grants) != 1 || grants[0].VIN != "VIN001" || grants[0].ValidFrom == nil {
|
||||
t.Fatalf("valid authorization interval was not normalized: grants=%+v err=%v", grants, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user