From 2575778293c93d3d8939f55127eb97d54d0ad50c Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 2 Apr 2026 15:35:29 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8E=E7=AB=AF=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=AE=A4=E8=AF=81=E5=92=8C=E6=9D=83=E9=99=90=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 auth 模块:jumpToken 代理交换、用户信息获取、JWT 签发 - 三级权限:full(所有权限/数智中心/BI-Leader)、department(BI-Leader-Dep)、personal - 添加 managerId 到车辆数据模型,支持个人级别按 userId 精确过滤 - auth 中间件保护所有 /api/* 端点(跳过 /api/health 和 /api/auth/*) - 所有路由集成 filterByPermission 权限过滤 Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 155 +++++++++++++++++++++- package.json | 2 + src/server/auth/login.ts | 102 ++++++++++++++ src/server/auth/middleware.ts | 37 ++++++ src/server/auth/permissions.ts | 26 ++++ src/server/auth/types.ts | 27 ++++ src/server/index.ts | 9 ++ src/server/routes/mileage/cache.ts | 1 + src/server/routes/mileage/monitoring.ts | 9 ++ src/server/routes/mileage/targets.ts | 5 +- src/server/routes/mileage/types.ts | 2 + src/server/routes/mileage/vehicle-info.ts | 1 + src/server/routes/vehicles.ts | 31 +++-- src/server/types.ts | 2 + 14 files changed, 395 insertions(+), 14 deletions(-) create mode 100644 src/server/auth/login.ts create mode 100644 src/server/auth/middleware.ts create mode 100644 src/server/auth/permissions.ts create mode 100644 src/server/auth/types.ts diff --git a/package-lock.json b/package-lock.json index 7548e40..9d2fe6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "ln-bi", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.0.0", + "version": "1.1.0", "dependencies": { "@hono/node-server": "^1.13.0", + "@types/jsonwebtoken": "^9.0.10", "dotenv": "^16.4.0", "hono": "^4.7.0", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", "mysql2": "^3.11.0", @@ -1583,6 +1585,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", @@ -1721,6 +1739,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -2031,6 +2055,15 @@ "url": "https://dotenvx.com" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.325", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", @@ -2346,6 +2379,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2607,6 +2695,48 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2702,7 +2832,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { @@ -3000,6 +3129,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/package.json b/package.json index 98bf6cc..5e4e171 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ }, "dependencies": { "@hono/node-server": "^1.13.0", + "@types/jsonwebtoken": "^9.0.10", "dotenv": "^16.4.0", "hono": "^4.7.0", + "jsonwebtoken": "^9.0.3", "lucide-react": "^0.546.0", "motion": "^12.23.24", "mysql2": "^3.11.0", diff --git a/src/server/auth/login.ts b/src/server/auth/login.ts new file mode 100644 index 0000000..2858995 --- /dev/null +++ b/src/server/auth/login.ts @@ -0,0 +1,102 @@ +import { Hono } from 'hono'; +import jwt from 'jsonwebtoken'; +import pool from '../db.js'; +import type { AuthUser, JwtPayload, PermissionLevel } from './types.js'; +import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js'; + +const app = new Hono(); + +const EXTERNAL_API_BASE = process.env.EXTERNAL_API_BASE || 'https://beta.lnh2e.com'; +const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; + +/** GET /api/auth/exchange?jumpToken=xxx — 代理 jumpToken 换取 sessionToken */ +app.get('/exchange', async (c) => { + const jumpToken = c.req.query('jumpToken'); + if (!jumpToken) return c.json({ error: 'Missing jumpToken' }, 400); + + try { + const res = await fetch( + `${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/issueTokenByJump?jumpToken=${encodeURIComponent(jumpToken)}` + ); + const data = await res.json() as { code: number; data: string | null; message: string }; + + if (data.code !== 0 || !data.data) { + return c.json({ error: 'Token exchange failed', message: data.message }, 401); + } + + return c.json({ token: data.data }); + } catch (e: unknown) { + console.error('jumpToken exchange error:', e); + return c.json({ error: 'Token exchange failed' }, 500); + } +}); + +/** POST /api/auth/login — 用外部 sessionToken 获取用户信息,签发 JWT */ +app.post('/login', async (c) => { + const body = await c.req.json<{ token: string }>().catch(() => null); + if (!body?.token) return c.json({ error: 'Missing token' }, 400); + + try { + // 调用外部 API 获取用户信息 + const res = await fetch( + `${EXTERNAL_API_BASE}/api/lingniu-manager-v1/v1/auth/getLoginUserInfo`, + { headers: { g7litegtoken: body.token } } + ); + const data = await res.json() as { + code: number; + data: { + id: string; + userName: string; + loginName: string; + depCode: string; + orgId: string; + roles: { roleName: string; id: string }[]; + } | null; + }; + + if (data.code !== 0 || !data.data) { + return c.json({ error: 'Failed to get user info' }, 401); + } + + const userInfo = data.data; + const roleNames = userInfo.roles.map(r => r.roleName); + + // 确定权限级别 + let permissionLevel: PermissionLevel = 'personal'; + if (roleNames.some(r => FULL_ACCESS_ROLES.includes(r))) { + permissionLevel = 'full'; + } else if (roleNames.some(r => DEPT_ACCESS_ROLES.includes(r))) { + permissionLevel = 'department'; + } + + // 查询 depCode 对应的部门名称 + let depName = ''; + if (userInfo.depCode) { + const [rows] = await pool.execute( + 'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1', + [userInfo.depCode] + ) as [{ dep_name: string }[], unknown]; + depName = rows[0]?.dep_name || ''; + } + + const payload: JwtPayload = { + userId: userInfo.id, + userName: userInfo.userName, + loginName: userInfo.loginName, + depCode: userInfo.depCode, + depName, + permissionLevel, + }; + + const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '8h' }); + + const authUser: AuthUser = { ...payload }; + + return c.json({ token, user: authUser }); + } catch (e: unknown) { + console.error('login error:', e); + return c.json({ error: 'Login failed' }, 500); + } +}); + +export default app; diff --git a/src/server/auth/middleware.ts b/src/server/auth/middleware.ts new file mode 100644 index 0000000..b0db96b --- /dev/null +++ b/src/server/auth/middleware.ts @@ -0,0 +1,37 @@ +import type { Context, Next } from 'hono'; +import jwt from 'jsonwebtoken'; +import type { JwtPayload, AuthUser } from './types.js'; + +const JWT_SECRET = process.env.JWT_SECRET || 'ln-bi-default-secret'; + +export async function authMiddleware(c: Context, next: Next) { + const path = c.req.path; + + // 跳过不需要认证的路径 + if (path === '/api/health' || path.startsWith('/api/auth/')) { + return next(); + } + + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const token = authHeader.slice(7); + + try { + const payload = jwt.verify(token, JWT_SECRET) as JwtPayload; + const user: AuthUser = { + userId: payload.userId, + userName: payload.userName, + loginName: payload.loginName, + depCode: payload.depCode, + depName: payload.depName, + permissionLevel: payload.permissionLevel, + }; + c.set('user', user); + return next(); + } catch { + return c.json({ error: 'Invalid or expired token' }, 401); + } +} diff --git a/src/server/auth/permissions.ts b/src/server/auth/permissions.ts new file mode 100644 index 0000000..dc14642 --- /dev/null +++ b/src/server/auth/permissions.ts @@ -0,0 +1,26 @@ +import type { AuthUser } from './types.js'; + +/** + * 通用权限过滤函数 + * 适配 CachedVehicle(department, manager, managerId)和 Vehicle(departmentName, customerManager, managerId) + */ +export function filterByPermission( + items: T[], + user: AuthUser, +): T[] { + if (user.permissionLevel === 'full') return items; + + if (user.permissionLevel === 'department') { + return items.filter(v => { + const obj = v as any; + const dept = obj.departmentName || obj.department || null; + return dept === user.depName; + }); + } + + // personal: 仅看自己负责的车辆 (bd = userId) + return items.filter(v => { + const obj = v as any; + return obj.managerId === user.userId; + }); +} diff --git a/src/server/auth/types.ts b/src/server/auth/types.ts new file mode 100644 index 0000000..bbfb08b --- /dev/null +++ b/src/server/auth/types.ts @@ -0,0 +1,27 @@ +export type PermissionLevel = 'full' | 'department' | 'personal'; + +export interface AuthUser { + userId: string; + userName: string; + loginName: string; + depCode: string; + depName: string; + permissionLevel: PermissionLevel; +} + +export interface JwtPayload { + userId: string; + userName: string; + loginName: string; + depCode: string; + depName: string; + permissionLevel: PermissionLevel; + iat?: number; + exp?: number; +} + +/** 全量权限角色名 */ +export const FULL_ACCESS_ROLES = ['所有权限', '数智中心', 'BI-Leader']; + +/** 部门级权限角色名 */ +export const DEPT_ACCESS_ROLES = ['BI-Leader-Dep']; diff --git a/src/server/index.ts b/src/server/index.ts index d062f4b..c718588 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -5,12 +5,21 @@ import { cors } from 'hono/cors'; import dotenv from 'dotenv'; import vehiclesRouter from './routes/vehicles.js'; import mileageRouter from './routes/mileage/index.js'; +import authRouter from './auth/login.js'; +import { authMiddleware } from './auth/middleware.js'; dotenv.config(); const app = new Hono(); app.use('/api/*', cors()); + +// Auth 路由(不需要中间件) +app.route('/api/auth', authRouter); + +// Auth 中间件(保护后续所有 /api/* 路由) +app.use('/api/*', authMiddleware); + app.route('/api/vehicles', vehiclesRouter); app.route('/api/mileage', mileageRouter); diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index e22ec20..63945ef 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -77,6 +77,7 @@ function mergeVehicles( customer: info?.customer || null, department: info?.department || null, manager: info?.manager || null, + managerId: info?.manager_id || null, rentStatus: info?.rent_status || null, entity: info?.entity || null, project: info?.project || null, diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 0d994ca..f173b08 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -1,5 +1,7 @@ import { Hono } from 'hono'; import { getCache, queryDateMileage, buildDateFilters } from './cache.js'; +import { filterByPermission } from '../../auth/permissions.js'; +import type { AuthUser } from '../../auth/types.js'; import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; const app = new Hono(); @@ -86,6 +88,13 @@ app.get('/', async (c) => { filters = cache.filters; } + // 权限过滤 + const user = (c as any).get('user') as AuthUser | undefined; + if (user) { + allVehicles = filterByPermission(allVehicles, user); + filters = buildDateFilters(allVehicles); // 重算筛选选项以匹配权限范围 + } + const filtered = applyFilters(allVehicles, filterParams); const stats = { diff --git a/src/server/routes/mileage/targets.ts b/src/server/routes/mileage/targets.ts index 293a50f..7301e32 100644 --- a/src/server/routes/mileage/targets.ts +++ b/src/server/routes/mileage/targets.ts @@ -3,6 +3,7 @@ import pool from '../../db.js'; import mileagePool from '../../mileage-db.js'; import { getCache } from './cache.js'; import { fetchVehicleInfoByPlates } from './vehicle-info.js'; +import { filterByPermission } from '../../auth/permissions.js'; const app = new Hono(); @@ -170,7 +171,9 @@ app.get('/:id/vehicles', async (c) => { }; }); - return c.json(result); + const user = (c as any).get('user') as import('../../auth/types.js').AuthUser | undefined; + const filtered = user ? filterByPermission(result, user) : result; + return c.json(filtered); } catch (e: unknown) { console.error('target vehicles error:', e); return c.json([], 500); diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index f9c7154..6f88cd5 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -10,6 +10,7 @@ export interface CachedVehicle { customer: string | null; department: string | null; manager: string | null; + managerId: string | null; rentStatus: string | null; entity: string | null; project: string | null; @@ -68,6 +69,7 @@ export interface VehicleInfoRow { customer: string | null; department: string | null; manager: string | null; + manager_id: string | null; rent_status: string | null; entity: string | null; project: string | null; diff --git a/src/server/routes/mileage/vehicle-info.ts b/src/server/routes/mileage/vehicle-info.ts index 30e8dfe..104ef9d 100644 --- a/src/server/routes/mileage/vehicle-info.ts +++ b/src/server/routes/mileage/vehicle-info.ts @@ -7,6 +7,7 @@ export const VEHICLE_INFO_SQL = `SELECT cus.customer_name AS customer, dep.dep_name AS department, u.user_name AS manager, + CAST(c.bd AS CHAR) AS manager_id, dic_status.dic_name AS rent_status, org_truck.org_name AS entity, c.project_name AS project diff --git a/src/server/routes/vehicles.ts b/src/server/routes/vehicles.ts index a823db7..6c2a6d5 100644 --- a/src/server/routes/vehicles.ts +++ b/src/server/routes/vehicles.ts @@ -10,6 +10,9 @@ import type { BatchGroup, InventoryTypeSummary, } from '../types.js'; +import { filterByPermission } from '../auth/permissions.js'; +import type { AuthUser } from '../auth/types.js'; +import type { Context } from 'hono'; const app = new Hono(); @@ -39,7 +42,8 @@ const MAIN_SQL = `SELECT dep.dep_name AS 合同归属部门, org_truck.org_name AS 主体, c.project_name AS 项目名称, - u.user_name AS 客户经理 + u.user_name AS 客户经理, + CAST(c.bd AS CHAR) AS 经理ID FROM tab_truck truck LEFT JOIN tab_truck_remote_sync_realtime_info info ON info.id = truck.id @@ -285,6 +289,7 @@ function transformRow(row: VehicleRow): Vehicle { subjectOrg: row.主体, projectName: row.项目名称, customerManager: row.客户经理, + managerId: row.经理ID || null, brandLabel: row.车辆品牌Label, }; } @@ -305,6 +310,12 @@ async function getVehicles(): Promise { return cachedVehicles; } +async function getVehiclesForUser(c: Context): Promise { + const all = await getVehicles(); + const user = c.get('user') as AuthUser | undefined; + return user ? filterByPermission(all, user) : all; +} + function getRegionCounts(vehicles: Vehicle[], regions: readonly string[]): Record { return regions.reduce((acc, reg) => { acc[reg] = vehicles.filter((v) => v.location === reg).length; @@ -566,7 +577,7 @@ app.get('/by-type', async (c) => { // GET /api/vehicles/by-batch app.get('/by-batch', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const batches = Array.from(new Set(vehicles.map((v) => v.contractNo || '未知'))) .filter(Boolean) .sort() @@ -595,7 +606,7 @@ app.get('/by-batch', async (c) => { // GET /api/vehicles/inventory-analysis app.get('/inventory-analysis', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const typeFilters = [ { name: '4.5T普货', filter: (v: Vehicle) => v.type === '4.5T' && !v.model.includes('冷链') }, @@ -649,7 +660,7 @@ app.get('/inventory-analysis', async (c) => { // GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance app.get('/dept-stats', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const withManager = vehicles.filter((v) => v.status === 'Operating'); // Query realtime day_mileage from tab_truck_remote_sync_realtime_info @@ -723,7 +734,7 @@ app.get('/dept-stats', async (c) => { // GET /api/vehicles/region-stats — macro-region with city drill-down app.get('/region-stats', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const { customer, city: filterCity, region: filterRegion } = c.req.query(); let operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending'); if (customer) operating = operating.filter((v) => v.customerName === customer); @@ -799,7 +810,7 @@ app.get('/region-stats', async (c) => { // GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles app.get('/customer-stats', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const operating = vehicles.filter((v) => v.status === 'Operating'); const custMap = new Map(); @@ -839,7 +850,7 @@ const VEHICLE_TYPE_FILTERS: Record boolean> = { // GET /api/vehicles/list — flat list with optional filters app.get('/list', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const { batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query(); let filtered = vehicles; @@ -935,7 +946,7 @@ app.get('/list', async (c) => { // GET /api/vehicles/inventory-stats — grouped inventory stats for the inventory statistics section app.get('/inventory-stats', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const inventory = vehicles.filter((v) => v.status === 'Inventory' || v.status === 'Abnormal'); const TYPE_NAME_MAP: Record = { @@ -997,7 +1008,7 @@ app.get('/weekly-detail', async (c) => { app.get('/refresh', async (c) => { lastFetchTime = 0; weeklyStatsLastFetch = 0; - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); return c.json({ message: 'Cache refreshed', count: vehicles.length }); }); @@ -1032,7 +1043,7 @@ app.get('/debug', async (c) => { // GET /api/vehicles/region-chart — aggregated chart data with top N + "其他" app.get('/region-chart', async (c) => { - const vehicles = await getVehicles(); + const vehicles = await getVehiclesForUser(c); const operating = vehicles.filter((v) => v.status === 'Operating'); const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'province' const source = c.req.query('source') || 'realtime'; // 'realtime' | 'vehicle' diff --git a/src/server/types.ts b/src/server/types.ts index 50805ca..ae2d33e 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -25,6 +25,7 @@ export interface VehicleRow { 主体: string | null; 项目名称: string | null; 客户经理: string | null; + 经理ID: string | null; } export interface Vehicle { @@ -48,6 +49,7 @@ export interface Vehicle { subjectOrg: string | null; projectName: string | null; customerManager: string | null; + managerId: string | null; brandLabel: string | null; }