diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store.go b/vehicle-data-platform/apps/api/internal/app/auth_store.go index c3a0285d..1aca1b94 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -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 位") diff --git a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go index c20e17e5..378ddc02 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 5aab4885..5ca1ab4e 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -37,6 +37,7 @@ export interface AdminUser { menuKeys: string[]; vehicleVins: string[]; vehicles: AdminVehicleGrant[]; + grantHistory: AdminVehicleGrantHistory[]; lastLoginAt?: string; createdAt: string; updatedAt: string; @@ -45,6 +46,23 @@ export interface AdminUser { export interface AdminVehicleGrant { vin: string; plate: string; + validFrom: string; + validTo?: string; + sourceSystem: string; + grantedBy: string; +} + +export interface AdminVehicleGrantHistory extends AdminVehicleGrant { + id: number; + revokedBy: string; + createdAt: string; + updatedAt: string; +} + +export interface CustomerVehicleGrantInput { + vin: string; + validFrom: string; + validTo: string; } export interface CustomerUserInput { @@ -55,7 +73,8 @@ export interface CustomerUserInput { customerRef: string; tenantRef: string; menuKeys: string[]; - vehicleVins: string[]; + vehicleVins?: string[]; + vehicleGrants: CustomerVehicleGrantInput[]; } export interface MonitorSummary { diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx index 3214aa64..9ca6a51d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx @@ -29,7 +29,8 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate authProvider: 'local', menuKeys: ['monitor'], vehicleVins: ['VIN001'], - vehicles: [{ vin: 'VIN001', plate: '粤A11111' }], + vehicles: [{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }], + grantHistory: [{ id: 1, vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员', revokedBy: '', createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z' }], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z' }]); @@ -40,7 +41,7 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate render(); fireEvent.click(await screen.findByRole('button', { name: /华东客户/ })); - expect(await screen.findByRole('button', { name: '移除 粤A11111' })).toHaveTextContent('粤A11111VIN001'); + expect((await screen.findByRole('button', { name: '移除 粤A11111' })).closest('article')).toHaveTextContent('粤A11111VIN001'); fireEvent.change(screen.getByRole('textbox', { name: '按车牌或 VIN 搜索' }), { target: { value: '粤A22222' } }); await waitFor(() => expect(mocks.vehicleCoverage).toHaveBeenCalled()); @@ -49,5 +50,25 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate expect((mocks.vehicleCoverage.mock.calls[0][0] as URLSearchParams).get('keyword')).toBe('粤A22222'); fireEvent.click(candidates[0]); - expect(await screen.findByRole('button', { name: '移除 粤A22222' })).toHaveTextContent('粤A22222VIN002'); + expect((await screen.findByRole('button', { name: '移除 粤A22222' })).closest('article')).toHaveTextContent('粤A22222VIN002'); +}); + +test('submits per-vehicle authorization interval instead of only a VIN list', async () => { + mocks.adminUsers.mockResolvedValue([{ + id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled', + customerRef: '', tenantRef: '', authProvider: 'local', menuKeys: ['statistics'], vehicleVins: ['VIN001'], + vehicles: [{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-07-16T14:08:18+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }], + grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z' + }]); + mocks.updateCustomerUser.mockResolvedValue({ id: 7 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + fireEvent.click(await screen.findByRole('button', { name: /华东客户/ })); + fireEvent.change(await screen.findByLabelText('粤A11111 启用时间'), { target: { value: '2026-06-05T00:00' } }); + fireEvent.click(screen.getByRole('button', { name: '保存权限' })); + await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalled()); + expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({ + vehicleVins: ['VIN001'], + vehicleGrants: [{ vin: 'VIN001', validFrom: '2026-06-05T00:00', validTo: '' }] + }); }); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx index f0fd6d8d..8556d12c 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react'; import { api } from '../../api/client'; -import type { AdminUser, CustomerUserInput } from '../../api/types'; +import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types'; const customerMenus = [ { key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' }, @@ -18,14 +18,29 @@ type Draft = { customerRef: string; tenantRef: string; menuKeys: string[]; - vehicleVins: string[]; + vehicleGrants: CustomerVehicleGrantInput[]; }; -const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleVins: [] }; +const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false +}); + +function dateTimeInput(value?: string) { + if (!value) return ''; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return ''; + return shanghaiDateTimeFormatter.format(parsed).replace(' ', 'T'); +} + +const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleGrants: [] }; function draftFromUser(user?: AdminUser): Draft { - if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleVins: [] }; - return { username: user.username, displayName: user.displayName, password: '', status: user.status, customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], vehicleVins: [...user.vehicleVins] }; + if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleGrants: [] }; + return { + username: user.username, displayName: user.displayName, password: '', status: user.status, + customerRef: user.customerRef, tenantRef: user.tenantRef, menuKeys: [...user.menuKeys], + vehicleGrants: user.vehicles.map((vehicle) => ({ vin: vehicle.vin, validFrom: dateTimeInput(vehicle.validFrom), validTo: dateTimeInput(vehicle.validTo) })) + }; } function formatTime(value?: string) { @@ -71,7 +86,7 @@ export default function UsersPage() { enabled: deferredVehicleKeyword.length >= 1, staleTime: 30_000 }); - const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]); + const assigned = useMemo(() => new Set(draft.vehicleGrants.map((grant) => grant.vin)), [draft.vehicleGrants]); const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]); useEffect(() => { if (candidateVehicles.length === 0) return; @@ -84,7 +99,11 @@ export default function UsersPage() { const save = useMutation({ mutationFn: async () => { - const input: CustomerUserInput = { displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, vehicleVins: draft.vehicleVins }; + const input: CustomerUserInput = { + displayName: draft.displayName.trim(), password: draft.password || undefined, status: draft.status, + customerRef: draft.customerRef.trim(), tenantRef: draft.tenantRef.trim(), menuKeys: draft.menuKeys, + vehicleVins: draft.vehicleGrants.map((grant) => grant.vin), vehicleGrants: draft.vehicleGrants + }; if (creating) return api.createCustomerUser({ ...input, username: draft.username.trim(), password: draft.password }); if (!selected) throw new Error('请先选择客户账号'); return api.updateCustomerUser(selected.id, input); @@ -116,10 +135,22 @@ export default function UsersPage() { setBulkVINs(''); setFeedback(''); }; - const toggleVIN = (vin: string) => setDraft((value) => ({ ...value, vehicleVins: value.vehicleVins.includes(vin) ? value.vehicleVins.filter((item) => item !== vin) : [...value.vehicleVins, vin].sort() })); + const toggleVIN = (vin: string) => setDraft((value) => ({ + ...value, + vehicleGrants: value.vehicleGrants.some((grant) => grant.vin === vin) + ? value.vehicleGrants.filter((grant) => grant.vin !== vin) + : [...value.vehicleGrants, { vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' }].sort((left, right) => left.vin.localeCompare(right.vin)) + })); + const updateGrant = (vin: string, patch: Partial) => setDraft((value) => ({ + ...value, vehicleGrants: value.vehicleGrants.map((grant) => grant.vin === vin ? { ...grant, ...patch } : grant) + })); const addBulkVINs = () => { const next = bulkVINs.split(/[\s,,;;]+/).map((value) => value.trim().toUpperCase()).filter(Boolean); - setDraft((value) => ({ ...value, vehicleVins: [...new Set([...value.vehicleVins, ...next])].sort() })); + setDraft((value) => { + const existing = new Set(value.vehicleGrants.map((grant) => grant.vin)); + const added = next.filter((vin) => !existing.has(vin)).map((vin) => ({ vin, validFrom: dateTimeInput(new Date().toISOString()), validTo: '' })); + return { ...value, vehicleGrants: [...value.vehicleGrants, ...added].sort((left, right) => left.vin.localeCompare(right.vin)) }; + }); setBulkVINs(''); }; const submit = (event: FormEvent) => { event.preventDefault(); setFeedback(''); save.mutate(); }; @@ -134,7 +165,7 @@ export default function UsersPage() {
{customers.length}个客户账号
{users.isPending ?

正在加载账号…

: customers.length === 0 ?

还没有客户账号

: customers.map((user) => )} @@ -148,10 +179,14 @@ export default function UsersPage() {

菜单权限 只能分配客户开放菜单

{customerMenus.map((menu) => )}
-

车辆权限 已选择 {draft.vehicleVins.length} 辆

{draft.vehicleVins.length ? : null}
+

车辆权限与有效期 已选择 {draft.vehicleGrants.length} 辆

{draft.vehicleGrants.length ? : null}
{deferredVehicleKeyword ?
{candidates.isPending ?

正在搜索车辆…

: candidateVehicles.length === 0 ?

没有匹配车辆

: candidateVehicles.map((vehicle) => )}
: null} - {draft.vehicleVins.length ?
{draft.vehicleVins.map((vin) => { const plate = vehicleLabels[vin]; return ; })}
:

尚未分配车辆,客户将无法看到任何车辆数据。

} + {draft.vehicleGrants.length ?
{draft.vehicleGrants.map((grant) => { const plate = vehicleLabels[grant.vin]; return
+
{plate || '未登记车牌'}{grant.vin}
+
+
; })}
:

尚未分配车辆,客户将无法看到任何车辆数据。

} + {!creating && selected?.grantHistory?.length ?
历次开放记录 · {selected.grantHistory.length} 条
{selected.grantHistory.map((item) =>
{item.plate || '未登记车牌'}{item.vin}

{formatTime(item.validFrom)} → {item.validTo ? formatTime(item.validTo) : '持续有效'}

开通:{item.grantedBy || '—'}{item.revokedBy ? ` · 停用:${item.revokedBy}` : ''} · {item.sourceSystem}
)}
: null}
{feedback ?

{feedback}

: null} diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 4e08a8d5..c3d0e1fe 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -1550,7 +1550,25 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-vehicle-candidates > p { grid-column: 1/-1; margin: 16px; color: #8996a7; font-size: 10px; } .v2-vehicle-candidates > button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: 6px; background: transparent; padding: 8px; text-align: left; cursor: pointer; }.v2-vehicle-candidates > button:hover { background: #f4f7fa; }.v2-vehicle-candidates > button.is-selected { background: #edf4ff; } .v2-vehicle-candidates b, .v2-vehicle-candidates small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-vehicle-candidates b { color: #34455c; font-size: 10px; }.v2-vehicle-candidates small { margin-top: 3px; color: #8a96a6; font-size: 8px; }.v2-vehicle-candidates i { color: var(--v2-blue); font-size: 9px; font-style: normal; } -.v2-assigned-vins { display: grid; max-height: 150px; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { display: flex; min-width: 0; min-height: 42px; align-items: center; justify-content: space-between; gap: 8px; border: 1px solid #dbe4ef; border-radius: 7px; background: #f7f9fc; padding: 6px 8px; color: #52637a; text-align: left; cursor: pointer; }.v2-assigned-vins button > span { min-width: 0; }.v2-assigned-vins b,.v2-assigned-vins small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-assigned-vins b { color: #34455c; font-size: 10px; }.v2-assigned-vins small { margin-top: 3px; color: #8a96a6; font: 8px ui-monospace,SFMono-Regular,Menlo,monospace; }.v2-assigned-vins i { flex: 0 0 auto; color: #99a4b3; font-size: 13px; font-style: normal; } +.v2-assigned-vins { display: grid; max-height: 330px; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; overflow: auto; margin-top: 10px; } +.v2-assigned-vins > article { min-width: 0; border: 1px solid #dbe4ef; border-radius: 9px; background: #f8fafc; padding: 10px; } +.v2-assigned-vins article > header { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 8px; } +.v2-assigned-vins article > header > span { min-width: 0; } +.v2-assigned-vins article > header button { width: 24px; height: 24px; flex: 0 0 auto; border: 0; border-radius: 6px; background: transparent; color: #99a4b3; cursor: pointer; font-size: 15px; } +.v2-assigned-vins article > header button:hover { background: #eef2f7; color: var(--v2-red); } +.v2-assigned-vins b,.v2-assigned-vins small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-assigned-vins b { color: #34455c; font-size: 11px; }.v2-assigned-vins small { margin-top: 3px; color: #8a96a6; font: 9px ui-monospace,SFMono-Regular,Menlo,monospace; } +.v2-assigned-vins article > div { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-top: 9px; } +.v2-assigned-vins label { min-width: 0; color: #758296; font-size: 9px; } +.v2-assigned-vins label span { display: block; margin-bottom: 4px; } +.v2-assigned-vins input { width: 100%; height: 32px; border: 1px solid #dbe3ed; border-radius: 6px; background: #fff; padding: 0 7px; color: #34455c; font-size: 10px; } +.v2-grant-history { margin-top: 12px; border: 1px solid #e1e7ef; border-radius: 9px; background: #fff; } +.v2-grant-history summary { padding: 10px 12px; color: #4c5f78; cursor: pointer; font-size: 10px; font-weight: 700; } +.v2-grant-history > div { display: grid; max-height: 250px; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 7px; overflow: auto; border-top: 1px solid #edf1f5; padding: 8px; } +.v2-grant-history article { min-width: 0; border-radius: 7px; background: #f7f9fc; padding: 9px; } +.v2-grant-history article header { display: flex; min-width: 0; justify-content: space-between; gap: 8px; color: #34455c; font-size: 10px; } +.v2-grant-history article header span { overflow: hidden; color: #8290a3; font: 8px ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow: ellipsis; white-space: nowrap; } +.v2-grant-history article p { margin: 6px 0 4px; color: #4d6078; font-size: 9px; } +.v2-grant-history article small { color: #8996a7; font-size: 8px; line-height: 1.5; } .v2-user-empty { margin: 14px 4px; color: #8b97a7; font-size: 10px; line-height: 1.6; } .v2-user-feedback { margin: 14px 0 0; color: #16805b; font-size: 11px; }.v2-user-feedback.is-error { color: var(--v2-red); } .v2-user-editor form > footer { display: flex; justify-content: flex-end; margin-top: 18px; }.v2-user-editor form > footer button:disabled { opacity: .5; } @@ -1570,7 +1588,8 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-user-admin-heading { align-items: flex-start; margin-bottom: 10px; }.v2-user-admin-heading h2 { font-size: 17px; }.v2-user-admin-heading p { display: none; }.v2-user-admin-heading > button { height: 34px; flex: 0 0 auto; padding: 0 10px; font-size: 10px; } .v2-user-admin-grid { display: flex; min-height: 0; overflow: visible; border: 0; box-shadow: none; flex-direction: column; gap: 8px; background: transparent; } .v2-user-list { display: flex; border: 1px solid var(--v2-border); border-radius: 10px; overflow-x: auto; padding: 6px; background: #fff; }.v2-user-list-summary { flex: 0 0 auto; padding: 8px; }.v2-user-list > button { min-width: 210px; } - .v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates, .v2-assigned-vins { grid-template-columns: 1fr; } + .v2-user-editor { border: 1px solid var(--v2-border); border-radius: 10px; overflow: hidden; background: #fff; }.v2-user-editor form { padding: 16px 14px 22px; }.v2-user-fields, .v2-menu-permissions, .v2-vehicle-permission-tools, .v2-vehicle-candidates, .v2-assigned-vins, .v2-grant-history > div { grid-template-columns: 1fr; } + .v2-assigned-vins article > div { grid-template-columns: 1fr; } .v2-password-mobile { display: grid !important; } .v2-topbar-actions .v2-current-user { display: none; } }