From 200172f0afb769de71e8e9fa9e0d915eec56b1cd Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 17 Apr 2026 15:42:21 +0800 Subject: [PATCH] feat(scheduling): role-based access + align list count with qualifiedCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate 智能调度 module on BI-SCHEDULE-OPT role (or full-access roles) via shared canAccessScheduling helper, replacing hardcoded userId allowlist - Thread roles[] through JWT payload → middleware → frontend nav - Add router guard that 403s non-authorized users on /api/scheduling/* - Emit replace_qualified suggestion for every qualified vehicle so list count matches the 已完成考核目标 card; recalc qualifiedCount / hopelessCount post-permission-filter for card↔list consistency Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 10 +++------- src/auth/useAuth.ts | 8 +++++++- src/server/auth/login.ts | 1 + src/server/auth/middleware.ts | 1 + src/server/auth/types.ts | 15 ++++++++++----- src/server/routes/scheduling/algorithm.ts | 18 ++++++++---------- src/server/routes/scheduling/index.ts | 13 +++++++++++++ src/server/routes/scheduling/suggestions.ts | 4 ++-- src/shared/auth/roles.ts | 19 +++++++++++++++++++ 9 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 src/shared/auth/roles.ts diff --git a/src/App.tsx b/src/App.tsx index 15b100d..de6f9a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,11 +7,7 @@ import SchedulingModule from './modules/scheduling/SchedulingModule'; import AuthProvider from './auth/AuthProvider'; import { useAuth } from './auth/useAuth'; import UnauthorizedPage from './auth/UnauthorizedPage'; - -const SCHEDULING_ALLOWED_USERS = new Set([ - '1105261382487539712', - '1116631120763437056', -]); +import { canAccessScheduling } from './shared/auth/roles'; const BASE_MODULES: ModuleConfig[] = [ { id: 'assets', label: '资产管理', icon: Truck, component: AssetsModule }, @@ -26,11 +22,11 @@ function AuthGate() { const { isLoading, isAuthenticated, error, user } = useAuth(); const modules = useMemo(() => { - if (user?.userId && SCHEDULING_ALLOWED_USERS.has(user.userId)) { + if (canAccessScheduling(user?.roles)) { return [...BASE_MODULES, SCHEDULING_MODULE]; } return BASE_MODULES; - }, [user?.userId]); + }, [user?.roles]); if (isLoading) { return ( diff --git a/src/auth/useAuth.ts b/src/auth/useAuth.ts index 0f8f7aa..c869e3a 100644 --- a/src/auth/useAuth.ts +++ b/src/auth/useAuth.ts @@ -3,7 +3,13 @@ import { createContext, useContext } from 'react'; export interface AuthState { isLoading: boolean; isAuthenticated: boolean; - user: { userId: string; userName: string; permissionLevel: string; depName: string } | null; + user: { + userId: string; + userName: string; + permissionLevel: string; + depName: string; + roles?: string[]; + } | null; error: string | null; } diff --git a/src/server/auth/login.ts b/src/server/auth/login.ts index 97606cb..50df3f4 100644 --- a/src/server/auth/login.ts +++ b/src/server/auth/login.ts @@ -66,6 +66,7 @@ app.get('/exchange', async (c) => { depCode: userInfo.depCode, depName, permissionLevel, + roles: roleNames, }; const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' }); diff --git a/src/server/auth/middleware.ts b/src/server/auth/middleware.ts index 1586009..7929be9 100644 --- a/src/server/auth/middleware.ts +++ b/src/server/auth/middleware.ts @@ -35,6 +35,7 @@ export async function authMiddleware(c: Context, next: Next) { depCode: payload.depCode, depName: payload.depName, permissionLevel: payload.permissionLevel, + roles: payload.roles ?? [], }; c.set('user', user); return next(); diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts index bbfb08b..46bbbe1 100644 --- a/src/server/auth/types.ts +++ b/src/server/auth/types.ts @@ -7,6 +7,7 @@ export interface AuthUser { depCode: string; depName: string; permissionLevel: PermissionLevel; + roles: string[]; } export interface JwtPayload { @@ -16,12 +17,16 @@ export interface JwtPayload { depCode: string; depName: string; permissionLevel: PermissionLevel; + roles: string[]; iat?: number; exp?: number; } -/** 全量权限角色名 */ -export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader']; - -/** 部门级权限角色名 */ -export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep']; +// Re-export role constants and helpers from the shared module so existing +// server imports (`from './types.js'`) keep working. +export { + FULL_ACCESS_ROLES, + DEPT_ACCESS_ROLES, + SCHEDULING_ACCESS_ROLES, + canAccessScheduling, +} from '../../shared/auth/roles.js'; diff --git a/src/server/routes/scheduling/algorithm.ts b/src/server/routes/scheduling/algorithm.ts index b4a6c1e..8974a02 100644 --- a/src/server/routes/scheduling/algorithm.ts +++ b/src/server/routes/scheduling/algorithm.ts @@ -157,12 +157,10 @@ export function generateSuggestions( } // --- replace_qualified (medium priority) --- - // Swap out the qualified car, swap in a car that NEEDS mileage. - // The high-mileage customer will drive it hard → helps it reach target. - // Exclude candidates already at target (gap <= 0) — swapping those in is pointless. + // Every qualified vehicle gets a suggestion row so the list count matches + // `qualifiedCount`. Candidates may be empty when no inventory vehicle can + // reach target at this customer — the row still surfaces for manual review. for (const vehicle of qualified) { - if (vehicle.customerAvgDaily <= vehicle.dailyRequiredMileage) continue; - const candidates: CandidateVehicle[] = inventoryVehicles .filter((inv) => { if (!isTypeCompatible(vehicle.vehicleType, inv.vehicleType)) return false; @@ -206,9 +204,6 @@ export function generateSuggestions( }) ; - // Skip if no candidate can reach target — swap would be pointless - if (candidates.length === 0) continue; - const yearRate = vehicle.yearTarget > 0 ? Math.round((vehicle.currentYearMileage / vehicle.yearTarget) * 100) : 0; const canAddKm = vehicle.customerAvgDaily * vehicle.daysLeft; const reason: ReasonBlock = { @@ -231,8 +226,11 @@ export function generateSuggestions( }); } - // Remove suggestions with no candidates - const filteredSuggestions = suggestions.filter((s) => s.candidates.length > 0); + // Drop rescue_hopeless with no candidates — no actionable rescue available. + // Keep every replace_qualified so the list count matches the qualifiedCount card. + const filteredSuggestions = suggestions.filter( + (s) => s.type === 'replace_qualified' || s.candidates.length > 0, + ); // Sort: high priority first filteredSuggestions.sort((a, b) => { diff --git a/src/server/routes/scheduling/index.ts b/src/server/routes/scheduling/index.ts index 1233612..44c0880 100644 --- a/src/server/routes/scheduling/index.ts +++ b/src/server/routes/scheduling/index.ts @@ -1,9 +1,22 @@ import { Hono } from 'hono'; import suggestionsRouter from './suggestions.js'; import notifyRouter from './notify.js'; +import type { AuthUser } from '../../auth/types.js'; +import { canAccessScheduling } from '../../auth/types.js'; const app = new Hono(); +// Module-level access guard. When auth middleware is active, `user` is set and +// we require a role from SCHEDULING_ACCESS_ROLES (or a full-access role). +// When auth is bypassed (dev), `user` is undefined and requests pass through. +app.use('*', async (c, next) => { + const user = (c as any).get('user') as AuthUser | undefined; + if (user && !canAccessScheduling(user.roles)) { + return c.json({ error: 'Forbidden: 智能调度访问需要 BI-SCHEDULE-OPT 角色' }, 403); + } + return next(); +}); + app.route('/suggestions', suggestionsRouter); app.route('/notify', notifyRouter); diff --git a/src/server/routes/scheduling/suggestions.ts b/src/server/routes/scheduling/suggestions.ts index 2f9a21d..073ae94 100644 --- a/src/server/routes/scheduling/suggestions.ts +++ b/src/server/routes/scheduling/suggestions.ts @@ -344,8 +344,8 @@ app.get('/', async (c) => { const filteredHopeless = masked.filter((s: any) => s.type === 'rescue_hopeless').length; const recentInterventionCount = await fetchRecentInterventionCount(); const filteredSummary: SchedulingSummary = { - qualifiedCount: summary.qualifiedCount, - hopelessCount: summary.hopelessCount, + qualifiedCount: filteredQualified, + hopelessCount: filteredHopeless, suggestionCount: masked.length, estimatedGain: masked.filter((s: any) => s.candidates?.some((c: any) => c.canQualifyAfterSwap) diff --git a/src/shared/auth/roles.ts b/src/shared/auth/roles.ts new file mode 100644 index 0000000..34b4b65 --- /dev/null +++ b/src/shared/auth/roles.ts @@ -0,0 +1,19 @@ +// Role constants and role-based access helpers shared between server (JWT +// issuance / API guards) and client (nav visibility / module gating). + +/** 全量权限角色名 */ +export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader']; + +/** 部门级权限角色名 */ +export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep']; + +/** 智能调度模块访问角色 */ +export const SCHEDULING_ACCESS_ROLES = ['BI-SCHEDULE-OPT']; + +/** 用户是否可访问智能调度模块。全量权限用户默认获得访问。 */ +export function canAccessScheduling(roles: readonly string[] | null | undefined): boolean { + if (!roles || roles.length === 0) return false; + return roles.some(r => + SCHEDULING_ACCESS_ROLES.includes(r) || FULL_ACCESS_ROLES.includes(r), + ); +}