feat: 后端用户认证和权限过滤

- 新增 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) <noreply@anthropic.com>
This commit is contained in:
kkfluous
2026-04-02 15:35:29 +08:00
parent 6dbd36dcd3
commit 2575778293
14 changed files with 395 additions and 14 deletions

102
src/server/auth/login.ts Normal file
View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -0,0 +1,26 @@
import type { AuthUser } from './types.js';
/**
* 通用权限过滤函数
* 适配 CachedVehicledepartment, manager, managerId和 VehicledepartmentName, customerManager, managerId
*/
export function filterByPermission<T>(
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;
});
}

27
src/server/auth/types.ts Normal file
View File

@@ -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'];

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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

View File

@@ -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<Vehicle[]> {
return cachedVehicles;
}
async function getVehiclesForUser(c: Context): Promise<Vehicle[]> {
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<string, number> {
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<string, Vehicle[]>();
@@ -839,7 +850,7 @@ const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => 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<string, string> = {
@@ -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'

View File

@@ -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;
}