From 48e2ce9fd43bc339398dd8e07399088923f7c3c5 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 14:16:22 +0800 Subject: [PATCH] fix: deduplicate account vehicle permissions --- .../apps/api/internal/app/auth_store.go | 99 ++++++++++++++++--- .../apps/web/src/api/types.ts | 6 ++ .../apps/web/src/v2/pages/UsersPage.test.tsx | 53 ++++++++++ .../apps/web/src/v2/pages/UsersPage.tsx | 33 ++++++- .../apps/web/src/v2/styles/v2.css | 4 +- 5 files changed, 175 insertions(+), 20 deletions(-) create mode 100644 vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx 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 f7b44f98..baef7c01 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_store.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_store.go @@ -41,20 +41,26 @@ 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"` - 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"` + LastLoginAt *time.Time `json:"lastLoginAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type authVehicleGrant struct { + VIN string `json:"vin"` + Plate string `json:"plate"` } type localCredential struct { @@ -385,6 +391,7 @@ func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) { } defer rows.Close() users := []authUser{} + allVINs := []string{} for rows.Next() { var user authUser var external sql.NullString @@ -400,11 +407,75 @@ func (s *authStore) listUsers(w http.ResponseWriter, r *http.Request) { } user.MenuKeys = principal.MenuKeys user.VehicleVINs = principal.VehicleVINs + allVINs = append(allVINs, principal.VehicleVINs...) users = append(users, user) } + if err := rows.Err(); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取账号列表", "", requestTraceID(r)) + return + } + vehicleLabels, err := loadAuthVehicleGrants(r.Context(), s.db, normalizeVINs(allVINs)) + if err != nil { + httpx.WriteError(w, http.StatusInternalServerError, "USER_LIST_FAILED", "无法读取车辆权限信息", "", requestTraceID(r)) + return + } + for index := range users { + users[index].Vehicles = make([]authVehicleGrant, 0, len(users[index].VehicleVINs)) + for _, vin := range users[index].VehicleVINs { + vehicle := vehicleLabels[vin] + if vehicle.VIN == "" { + vehicle.VIN = vin + } + users[index].Vehicles = append(users[index].Vehicles, vehicle) + } + } httpx.WriteOK(w, requestTraceID(r), users) } +func loadAuthVehicleGrants(ctx context.Context, db *sql.DB, vins []string) (map[string]authVehicleGrant, error) { + result := make(map[string]authVehicleGrant, len(vins)) + for start := 0; start < len(vins); start += 250 { + end := start + 250 + if end > len(vins) { + end = len(vins) + } + part := vins[start:end] + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(part)), ",") + args := make([]any, 0, len(part)*2) + for range 2 { + for _, vin := range part { + args = append(args, vin) + } + } + query := `SELECT vin, COALESCE(` + + `MAX(CASE WHEN source_priority=0 THEN NULLIF(plate,'') END),` + + `MAX(CASE WHEN source_priority=1 THEN NULLIF(plate,'') END),'') ` + + `FROM (` + + `SELECT UPPER(TRIM(vin)) AS vin,plate,0 AS source_priority FROM vehicle_identity_binding WHERE vin IN (` + placeholders + `) ` + + `UNION ALL ` + + `SELECT UPPER(TRIM(vin)) AS vin,plate,1 AS source_priority FROM vehicle_realtime_snapshot WHERE vin IN (` + placeholders + `)` + + `) vehicle_grants GROUP BY vin` + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + for rows.Next() { + var vehicle authVehicleGrant + if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil { + rows.Close() + return nil, err + } + vehicle.VIN = strings.ToUpper(strings.TrimSpace(vehicle.VIN)) + vehicle.Plate = strings.TrimSpace(vehicle.Plate) + result[vehicle.VIN] = vehicle + } + if err := rows.Close(); err != nil { + return nil, err + } + } + return result, nil +} + func (s *authStore) createCustomer(w http.ResponseWriter, r *http.Request, actor platform.Principal) { var input userMutation if !decodeAuthJSON(w, r, &input) { diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index e125c6d4..745e1e6a 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -36,11 +36,17 @@ export interface AdminUser { externalSubject?: string; menuKeys: string[]; vehicleVins: string[]; + vehicles: AdminVehicleGrant[]; lastLoginAt?: string; createdAt: string; updatedAt: string; } +export interface AdminVehicleGrant { + vin: string; + plate: string; +} + export interface CustomerUserInput { username?: string; displayName: string; 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 new file mode 100644 index 00000000..3214aa64 --- /dev/null +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.test.tsx @@ -0,0 +1,53 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; +import UsersPage from './UsersPage'; + +const mocks = vi.hoisted(() => ({ + adminUsers: vi.fn(), + vehicleCoverage: vi.fn(), + createCustomerUser: vi.fn(), + updateCustomerUser: vi.fn() +})); + +vi.mock('../../api/client', () => ({ api: mocks })); + +afterEach(() => { + cleanup(); + Object.values(mocks).forEach((mock) => mock.mockReset()); +}); + +test('deduplicates vehicle candidates by VIN and renders granted vehicles plate first', async () => { + mocks.adminUsers.mockResolvedValue([{ + id: 7, + username: 'customer-east', + displayName: '华东客户', + userType: 'customer', + status: 'enabled', + customerRef: '', + tenantRef: '', + authProvider: 'local', + menuKeys: ['monitor'], + vehicleVins: ['VIN001'], + vehicles: [{ vin: 'VIN001', plate: '粤A11111' }], + createdAt: '2026-07-16T00:00:00Z', + updatedAt: '2026-07-16T00:00:00Z' + }]); + const duplicatedVehicle = { vin: 'VIN002', plate: '粤A22222', protocols: ['GB32960', 'JT808'] }; + mocks.vehicleCoverage.mockResolvedValue({ items: [duplicatedVehicle, duplicatedVehicle], total: 1, limit: 20, offset: 0 }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render(); + + fireEvent.click(await screen.findByRole('button', { name: /华东客户/ })); + expect(await screen.findByRole('button', { name: '移除 粤A11111' })).toHaveTextContent('粤A11111VIN001'); + + fireEvent.change(screen.getByRole('textbox', { name: '按车牌或 VIN 搜索' }), { target: { value: '粤A22222' } }); + await waitFor(() => expect(mocks.vehicleCoverage).toHaveBeenCalled()); + const candidates = await screen.findAllByRole('button', { name: /粤A22222.*VIN002.*选择/ }); + expect(candidates).toHaveLength(1); + 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'); +}); 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 73c3f407..f0fd6d8d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/UsersPage.tsx @@ -33,6 +33,17 @@ function formatTime(value?: string) { return new Date(value).toLocaleString('zh-CN', { hour12: false }); } +function uniqueVehicles(vehicles: T[]) { + const byVIN = new Map(); + for (const vehicle of vehicles) { + const vin = vehicle.vin.trim().toUpperCase(); + const current = byVIN.get(vin); + if (!vin || (current?.plate && !vehicle.plate)) continue; + byVIN.set(vin, vehicle); + } + return [...byVIN.values()]; +} + export default function UsersPage() { const queryClient = useQueryClient(); const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 }); @@ -44,19 +55,32 @@ export default function UsersPage() { const [vehicleKeyword, setVehicleKeyword] = useState(''); const deferredVehicleKeyword = useDeferredValue(vehicleKeyword.trim()); const [bulkVINs, setBulkVINs] = useState(''); + const [vehicleLabels, setVehicleLabels] = useState>({}); const [feedback, setFeedback] = useState(''); useEffect(() => { - if (!creating && selected) setDraft(draftFromUser(selected)); + if (!creating && selected) { + setDraft(draftFromUser(selected)); + setVehicleLabels(Object.fromEntries(selected.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate]))); + } }, [creating, selected]); const candidates = useQuery({ queryKey: ['permission-vehicle-candidates', deferredVehicleKeyword], - queryFn: ({ signal }) => api.vehicles(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal), + queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredVehicleKeyword, limit: '20', offset: '0' }), signal), enabled: deferredVehicleKeyword.length >= 1, staleTime: 30_000 }); const assigned = useMemo(() => new Set(draft.vehicleVins), [draft.vehicleVins]); + const candidateVehicles = useMemo(() => uniqueVehicles(candidates.data?.items ?? []), [candidates.data?.items]); + useEffect(() => { + if (candidateVehicles.length === 0) return; + setVehicleLabels((current) => { + const next = { ...current }; + for (const vehicle of candidateVehicles) next[vehicle.vin] = vehicle.plate; + return next; + }); + }, [candidateVehicles]); const save = useMutation({ mutationFn: async () => { @@ -81,6 +105,7 @@ export default function UsersPage() { setDraft(draftFromUser()); setVehicleKeyword(''); setBulkVINs(''); + setVehicleLabels({}); setFeedback(''); }; const selectCustomer = (user: AdminUser) => { @@ -125,8 +150,8 @@ export default function UsersPage() {

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

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

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

{draft.vehicleVins.length ? : null}
- {deferredVehicleKeyword ?
{candidates.isPending ?

正在搜索车辆…

: (candidates.data?.items ?? []).length === 0 ?

没有匹配车辆

: candidates.data?.items.map((vehicle) => )}
: null} - {draft.vehicleVins.length ?
{draft.vehicleVins.map((vin) => )}
:

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

} + {deferredVehicleKeyword ?
{candidates.isPending ?

正在搜索车辆…

: candidateVehicles.length === 0 ?

没有匹配车辆

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

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

}
{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 28e5bf06..2b7f540b 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -1423,7 +1423,7 @@ 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: flex; max-height: 118px; flex-wrap: wrap; gap: 6px; overflow: auto; margin-top: 10px; }.v2-assigned-vins button { height: 26px; border: 1px solid #dbe4ef; border-radius: 6px; background: #f7f9fc; padding: 0 7px; color: #52637a; cursor: pointer; font: 9px ui-monospace, SFMono-Regular, Menlo, monospace; }.v2-assigned-vins button span { margin-left: 6px; color: #99a4b3; } +.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-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; } @@ -1443,7 +1443,7 @@ 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 { 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 { grid-template-columns: 1fr; } .v2-password-mobile { display: grid !important; } .v2-topbar-actions .v2-current-user { display: none; } }