Merge Profile0928 asset analysis and permission previews into main
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-09-28 12:34:01 +08:00
74 changed files with 3922 additions and 545 deletions
+3
View File
@@ -5,6 +5,7 @@ import { cors } from 'hono/cors';
import authRouter from './auth/login.js';
import { authMiddleware } from './middleware/auth.js';
import eleRouter from './routes/ele/routes.js';
import { createPreviewRouter, createPreviewMiddleware } from './auth/preview/routes.js';
import energyRouter from './routes/energy/index.js';
import feedbackRouter from './routes/feedback/routes.js';
import hydrogenHeatmapRouter from './routes/hydrogen-heatmap/routes.js';
@@ -27,6 +28,8 @@ export function createApp(): Hono {
// 登录接口公开,其余 API 统一经过认证与数据权限检查。
app.route('/api/auth', authRouter);
app.use('/api/*', authMiddleware);
app.route('/api/permission-preview', createPreviewRouter());
app.use('/api/*', createPreviewMiddleware());
app.route('/api/vehicles', vehiclesRouter);
app.route('/api/asset-assistant', assetAssistantRouter);
+6 -10
View File
@@ -1,8 +1,9 @@
import { Hono } from 'hono';
import jwt from 'jsonwebtoken';
import pool from '../db/mysql.js';
import type { AuthUser, JwtPayload, PermissionLevel } from './types.js';
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES } from './types.js';
import { identitySchema } from './preview/directory.js';
import type { AuthUser, JwtPayload } from './types.js';
import { resolvePermissionLevel } from '../../shared/auth/roles.js';
import { authMode, verifyAuthToken } from './config.js';
import { passwordRouter } from './password.js';
import { jwtSecret, serverConfig } from '../config.js';
@@ -44,20 +45,15 @@ app.get('/exchange', async (c) => {
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';
}
const permissionLevel = resolvePermissionLevel(roleNames);
// 查询 depCode 对应的部门名称
let depName = '';
if (userInfo.depCode) {
try {
const [rows] = await pool.execute(
'SELECT dep_name FROM tab_department WHERE dep_code = ? AND is_deleted = 0 LIMIT 1',
[userInfo.depCode]
`SELECT dep_name FROM ${identitySchema()}.tab_department WHERE dep_code = ? AND org_id = ? AND is_deleted = 0 LIMIT 1`,
[userInfo.depCode, userInfo.orgId]
) as [{ dep_name: string }[], unknown];
depName = rows[0]?.dep_name || '';
} catch (e: any) {
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { verifiedManagerIds } from './manager-identity.js';
import { filterByPermission } from './permissions.js';
import type { AuthUser } from './types.js';
test('only a unique enabled identity mapping extends personal vehicle IDs; ambiguity fails closed', () => {
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: 'new' }]), ['old', 'new']);
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: null }]), ['old']);
assert.deepEqual(verifiedManagerIds('old', []), []);
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'wrong', vehicleId: 'new' }]), []);
assert.deepEqual(verifiedManagerIds('old', [{ legacyId: 'old', vehicleId: 'new' }, { legacyId: 'old', vehicleId: 'other' }]), []);
const user: AuthUser = { userId: 'old', loginName: 'login', userName: '人', depCode: 'D', depName: '部门甲', permissionLevel: 'personal', roles: [], managerIds: ['old', 'new'] };
const items = [{ managerId: 'new', department: '部门乙' }, { managerId: 'other', department: '部门甲' }];
assert.deepEqual(filterByPermission(items, user), [items[0]]);
assert.deepEqual(filterByPermission(items, { ...user, managerIds: [] }), []);
assert.deepEqual(filterByPermission(items, { ...user, permissionLevel: 'department' }), [items[1]]);
});
+20
View File
@@ -0,0 +1,20 @@
import pool from '../db/mysql.js';
/** Match login identifiers only, never human names. Ambiguous or disabled identities fail closed. */
export async function resolveVehicleManagerIds(userId: string, loginName: string): Promise<string[]> {
const schema = process.env.BI_IDENTITY_SCHEMA || 'lingniu_prod';
if (!/^[a-zA-Z0-9_]+$/.test(schema)) throw new Error('Invalid identity schema');
const [rows] = await pool.query<any[]>(`SELECT CAST(u.id AS CHAR) legacyId, CAST(n.user_id AS CHAR) vehicleId
FROM \`${schema}\`.tab_user u
LEFT JOIN \`ry-cloud\`.sys_user n ON BINARY n.user_name=BINARY u.login_name
AND n.tenant_id=? AND n.del_flag='0' AND n.status='0'
WHERE u.id=? AND BINARY u.login_name=BINARY ? AND u.enabled=1 AND u.is_deleted=0
AND (SELECT COUNT(*) FROM \`${schema}\`.tab_user duplicate
WHERE BINARY duplicate.login_name=BINARY u.login_name AND duplicate.enabled=1 AND duplicate.is_deleted=0)=1`,
[process.env.BI_VEHICLE_IDENTITY_TENANT_ID || '000000', userId, loginName]);
return verifiedManagerIds(userId, rows);
}
export function verifiedManagerIds(userId: string, rows: { legacyId: string; vehicleId: string | null }[]): string[] {
if (rows.length !== 1 || rows[0].legacyId !== userId) return [];
return [...new Set([userId, ...(rows[0].vehicleId ? [rows[0].vehicleId] : [])])];
}
+12
View File
@@ -20,6 +20,8 @@ test('固定密码模式默认关闭、失败限流、只读权限及换密失
app.use('/api/*', authMiddleware);
app.get('/api/data', c => c.json({ ok: true }));
app.post('/api/data', c => c.json({ ok: true }));
app.post('/api/vehicles/analysis', c => c.json({ ok: true }));
app.post('/api/vehicles/analysis/details', c => c.json({ ok: true }));
const login = (password: unknown) => app.request('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
assert.deepEqual(await (await app.request('/api/auth/config')).json(), { mode: 'sso' });
assert.equal((await login(process.env.BI_AUTH_PASSWORD)).status, 403);
@@ -35,6 +37,16 @@ test('固定密码模式默认关闭、失败限流、只读权限及换密失
assert.deepEqual(user.roles, ['BI-LEADER-ENERGY']);
const headers = { Authorization: `Bearer ${token}` };
assert.equal((await app.request('/api/data', { headers })).status, 200);
for (const readOnly of ['1', '0']) {
process.env.DB_READ_ONLY = readOnly;
for (const path of ['/api/vehicles/analysis', '/api/vehicles/analysis/details']) {
assert.equal((await app.request(path, { method: 'POST', headers })).status, 200);
assert.equal((await app.request(path, { method: 'POST' })).status, 401);
}
for (const path of ['/api/vehicles/analysis/extra', '/api/permission-preview/start', '/api/permission-preview/stop']) {
assert.equal((await app.request(path, { method: 'POST', headers })).status, 403);
}
}
const payload = verifyAuthToken(token);
assert.equal(Number(payload.exp) - Number(payload.iat), 8 * 60 * 60);
process.env.DB_READ_ONLY = '0';
+1 -1
View File
@@ -46,6 +46,6 @@ export function filterByPermission<T>(
// personal: 仅看自己负责的车辆 (bd = userId)
return items.filter(v => {
const obj = v as any;
return obj.managerId === user.userId;
return (user.managerIds ?? [user.userId]).includes(obj.managerId);
});
}
+61
View File
@@ -0,0 +1,61 @@
import pool from '../../db/mysql.js';
import type { AuthUser, PermissionLevel } from '../types.js';
import { FULL_ACCESS_ROLES, DEPT_ACCESS_ROLES, resolvePermissionLevel } from '../../../shared/auth/roles.js';
export type DirectoryUser = AuthUser & { orgId: string };
export interface UserDirectory {
get(userId: string): Promise<DirectoryUser | null>;
search(orgId: string, query: string, level: PermissionLevel | undefined, page: number): Promise<{ users: AuthUser[]; page: number; hasMore: boolean }>;
}
// This is the same identity source as the existing jump-token login, not the vehicle-system account IDs.
export function identitySchema() {
const name = process.env.BI_IDENTITY_SCHEMA || 'lingniu_prod';
if (!/^[a-zA-Z0-9_]+$/.test(name)) throw new Error('Invalid identity schema');
return `\`${name}\``;
}
const publicUser = (user: DirectoryUser): AuthUser => {
const { orgId: _, ...identity } = user;
return identity;
};
export { publicUser };
async function readUsers(where: string, values: unknown[], limit: number, offset = 0): Promise<DirectoryUser[]> {
const db = identitySchema();
const [rows] = await pool.query<any[]>(`SELECT CAST(u.id AS CHAR) userId, u.user_name userName,
u.login_name loginName, CAST(u.org_id AS CHAR) orgId, COALESCE(d.dep_code, '') depCode,
COALESCE(d.dep_name, '') depName FROM ${db}.tab_user u
LEFT JOIN ${db}.tab_department d ON d.id=u.dep_id AND d.is_deleted=0
WHERE u.is_deleted=0 AND u.enabled=1 AND ${where}
ORDER BY u.user_name, u.id LIMIT ? OFFSET ?`, [...values, limit, offset]);
if (!rows.length) return [];
const ids = rows.map(r => r.userId);
const [roleRows] = await pool.query<any[]>(`SELECT CAST(ur.user_id AS CHAR) userId, r.role_name roleName
FROM ${db}.tab_user_role ur JOIN ${db}.tab_role r ON r.id=ur.role_id AND r.is_deleted=0
JOIN ${db}.tab_user u ON u.id=ur.user_id AND r.org_id=u.org_id
WHERE ur.user_id IN (${ids.map(() => '?').join(',')})`, ids);
return rows.map(row => {
const roles = [...new Set<string>(roleRows.filter(r => r.userId === row.userId).map(r => r.roleName))].sort();
return { ...row, roles, permissionLevel: resolvePermissionLevel(roles) };
});
}
export const userDirectory: UserDirectory = {
async get(id) { return (await readUsers('u.id=?', [id], 1))[0] || null; },
async search(orgId, query, level, page) {
const values: unknown[] = [orgId];
let where = 'u.org_id=?';
if (query) {
// Escape LIKE wildcards; values are always parameters.
const term = `%${query.replace(/[!%_]/g, '!$&')}%`;
where += " AND (u.user_name LIKE ? ESCAPE '!' OR u.login_name LIKE ? ESCAPE '!' OR d.dep_name LIKE ? ESCAPE '!')";
values.push(term, term, term);
}
const hasRole = (roles: string[]) => {
values.push(...roles);
return `EXISTS (SELECT 1 FROM ${identitySchema()}.tab_user_role ur JOIN ${identitySchema()}.tab_role r ON r.id=ur.role_id WHERE ur.user_id=u.id AND r.org_id=u.org_id AND r.is_deleted=0 AND r.role_name IN (${roles.map(() => '?').join(',')}))`;
};
if (level === 'full') where += ` AND ${hasRole(FULL_ACCESS_ROLES)}`;
if (level === 'department') where += ` AND NOT ${hasRole(FULL_ACCESS_ROLES)} AND ${hasRole(DEPT_ACCESS_ROLES)}`;
if (level === 'personal') where += ` AND NOT ${hasRole([...FULL_ACCESS_ROLES, ...DEPT_ACCESS_ROLES])}`;
const users = await readUsers(where, values, 21, (page - 1) * 20);
return { users: users.slice(0, 20).map(publicUser), page, hasMore: users.length > 20 };
},
};
+149
View File
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import { createPreviewService, type PreviewAudit } from './service.js';
import { createPreviewMiddleware, createPreviewRouter } from './routes.js';
import type { AuthUser } from '../types.js';
import type { DirectoryUser } from './directory.js';
import { resolvePermissionLevel } from '../../../shared/auth/roles.js';
import { filterByPermission } from '../permissions.js';
import { getSession } from '../../routes/asset-assistant/session.js';
const identity = (id: string, level: 'full' | 'department' | 'personal' = 'personal'): DirectoryUser => ({
userId: id, loginName: id, userName: id, orgId: 'org', depCode: 'D1', depName: '部门甲',
roles: level === 'full' ? ['BI-Leader'] : level === 'department' ? ['BI-Leader-Dep'] : [], permissionLevel: level,
});
function setup() {
const actor = identity('authorized', 'full');
const users = new Map([actor, identity('personal'), identity('department', 'department'), identity('leader', 'full'), { ...identity('foreign'), orgId: 'another' }].map(u => [u.userId, u]));
const allowed = new Set([actor.userId]);
const events: PreviewAudit[] = [];
let now = 1000;
const service = createPreviewService({ managerIds: async id => [id], allowedIds: () => allowed, now: () => now,
audit: async event => { events.push(event); },
directory: { get: async id => users.get(id) || null, search: async (org, q, level, page) => ({ users: [...users.values()].filter(u => u.orgId === org && u.userName.includes(q) && (!level || level === u.permissionLevel)), page, hasMore: false }) },
});
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => { c.set('user', users.get(c.req.header('Test-Actor') || '') || identity('outsider')); await next(); });
app.route('/api/permission-preview', createPreviewRouter(service));
app.use('*', createPreviewMiddleware(service));
app.all('*', c => c.json({ user: c.get('user'), visible: filterByPermission([
{ id: 1, department: '部门甲', managerId: 'personal' }, { id: 2, department: '部门甲', managerId: 'colleague' }, { id: 3, department: '部门乙', managerId: 'other' },
], c.get('user')) }));
const request = (path: string, token?: string, method = 'GET', body?: unknown, as = actor.userId) => app.request(path, { method,
headers: { 'Test-Actor': as, ...(token ? { 'X-BI-Preview': token } : {}), 'Content-Type': 'application/json' },
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
return { actor, users, allowed, events, service, request, advance: () => { now += 30 * 60 * 1000; } };
}
test('login and preview share role precedence', () => {
assert.equal(resolvePermissionLevel([]), 'personal');
assert.equal(resolvePermissionLevel(['BI-Leader-Dep']), 'department');
for (const role of ['BI-Leader', '所有权限', '数智中心']) assert.equal(resolvePermissionLevel(['BI-Leader-Dep', role]), 'full');
});
test('only explicitly authorized actors can search or start; local bypass and full role alone never grant preview', async () => {
const s = setup();
for (const user of ['outsider', 'leader', 'dev-local']) {
assert.equal((await s.request('/api/permission-preview/context', undefined, 'GET', undefined, user)).status, 200);
assert.equal((await (await s.request('/api/permission-preview/context', undefined, 'GET', undefined, user)).json()).canPreview, false);
assert.equal((await s.request('/api/permission-preview/users', undefined, 'GET', undefined, user)).status, 403);
assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'personal' }, user)).status, 403);
}
assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'foreign' })).status, 403);
assert.equal((await s.request('/api/permission-preview/start', undefined, 'POST', { userId: 'personal', permissionLevel: 'full' })).status, 400);
});
test('the actual target identity is applied to list, aggregate and detail queries; actor rights are never unioned', async () => {
const s = setup();
for (const [id, expected] of [['personal', [1]], ['department', [1, 2]], ['leader', [1, 2, 3]]] as const) {
const { previewToken } = await s.service.start(s.actor, id);
for (const [path, method] of [['/api/vehicles/list', 'GET'], ['/api/vehicles/analysis', 'POST'], ['/api/vehicles/analysis/details', 'POST']]) {
const response = await s.request(path, previewToken, method);
assert.equal(response.status, 200);
const data = await response.json();
assert.deepEqual(data.visible.map((v: { id: number }) => v.id), expected);
assert.equal(data.user.userId, id);
assert.equal(data.user.preview.actorId, s.actor.userId);
assert.equal(response.headers.get('cache-control'), 'no-store');
}
}
assert.equal((await (await s.request('/api/vehicles/list')).json()).user.userId, s.actor.userId);
});
test('user search returns no directory list without keywords, while partial keywords still match', async () => {
const s = setup();
for (const suffix of ['', '?q=%20%20', '?level=full']) {
const res = await s.request(`/api/permission-preview/users${suffix}`);
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { users: [], page: 1, hasMore: false });
}
const res = await s.request('/api/permission-preview/users?q=erson');
assert.deepEqual((await res.json()).users.map((u: AuthUser) => u.userId), ['personal']);
});
test('tokens are actor-bound, revocable, expiring, and fail closed for deleted users or changed organizations', async () => {
const s = setup();
const { previewToken } = await s.service.start(s.actor, 'personal');
assert.equal((await s.request('/api/vehicles/list', previewToken, 'GET', undefined, 'leader')).status, 403);
assert.equal((await s.request('/api/vehicles/list', 'forged')).status, 410);
s.allowed.clear();
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403);
s.allowed.add(s.actor.userId);
s.users.get('personal')!.orgId = 'foreign';
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403);
s.users.get('personal')!.orgId = 'org';
s.users.get('personal')!.permissionLevel = 'department';
assert.equal((await (await s.request('/api/vehicles/list', previewToken)).json()).visible.length, 2);
s.users.delete('personal');
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 403);
s.advance();
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 410);
});
test('all writes, side-effect GETs and unrecognized POSTs are blocked; audited read POSTs and isolated session cleanup work', async () => {
const s = setup(), { previewToken } = await s.service.start(s.actor, 'personal');
for (const [path, method] of [['/api/feedback/upload', 'POST'], ['/api/feedback/submit', 'POST'], ['/api/feedback/1', 'PATCH'], ['/api/ele/import', 'POST'], ['/api/scheduling/notify', 'POST'], ['/api/asset-assistant/feedback', 'POST'], ['/api/vehicles/refresh', 'GET'], ['/api/vehicles/debug', 'HEAD'], ['/api/new-write-route', 'POST']]) {
const res = await s.request(path, previewToken, method);
assert.equal(res.status, 403, path);
if (method !== 'HEAD') assert.equal((await res.json()).code, 'PREVIEW_READ_ONLY');
}
assert.equal((await s.request('/api/asset-assistant/ask', previewToken, 'POST')).status, 200);
assert.equal((await s.request('/api/asset-assistant/session/test', previewToken, 'DELETE')).status, 200);
assert.ok(s.events.some(e => e.event === 'blocked-write'));
assert.ok(s.events.every(e => !JSON.stringify(e).includes(previewToken)));
await s.request('/api/permission-preview/stop', previewToken, 'POST');
assert.equal((await s.request('/api/vehicles/list', previewToken)).status, 410);
assert.ok(s.events.some(e => e.event === 'stop'));
});
test('AI sessions are isolated between real target, actor and every preview session', () => {
const real = identity('target');
const a = { ...real, preview: { actorId: 'actor-a', sessionId: 'one' } };
const session = getSession(a, null);
for (const user of [real, { ...a, preview: { actorId: 'actor-b', sessionId: 'one' } }, { ...a, preview: { actorId: 'actor-a', sessionId: 'two' } }])
assert.throws(() => getSession(user, null, session.id), /会话已过期/);
assert.equal(getSession(a, null, session.id).id, session.id);
});
test('audit failure never grants a session or returns target data', async () => {
const actor = identity('actor');
const service = createPreviewService({ managerIds: async id => [id], allowedIds: () => new Set(['actor']), directory: { get: async id => identity(id), search: async () => ({ users: [], page: 1, hasMore: false }) }, audit: async () => { throw new Error('disk unavailable'); } });
await assert.rejects(service.start(actor, 'target'), /disk unavailable/);
});
test('local preview requires both explicit dev switches, an authorized organization anchor, and never works in production', async () => {
const previous = { NODE_ENV: process.env.NODE_ENV, DEV_BYPASS_AUTH: process.env.DEV_BYPASS_AUTH, DEV_PERMISSION_PREVIEW: process.env.DEV_PERMISSION_PREVIEW, DEV_PERMISSION_PREVIEW_USER_ID: process.env.DEV_PERMISSION_PREVIEW_USER_ID };
try {
const actor = identity('dev-local', 'full'), anchor = identity('anchor');
const service = createPreviewService({ allowedIds: () => new Set(['anchor']), directory: { get: async id => id === 'anchor' ? anchor : identity(id), search: async () => ({ users: [], page: 1, hasMore: false }) }, audit: async () => {}, managerIds: async id => [id] });
process.env.DEV_PERMISSION_PREVIEW_USER_ID = 'anchor';
process.env.NODE_ENV = 'development'; process.env.DEV_BYPASS_AUTH = '1'; process.env.DEV_PERMISSION_PREVIEW = '0';
assert.equal((await service.context(actor)).canPreview, false);
process.env.DEV_PERMISSION_PREVIEW = '1';
assert.equal((await service.context(actor)).canPreview, true);
const session = await service.start(actor, 'target');
assert.equal((await service.current(actor, session.previewToken)).user.userId, 'target');
process.env.NODE_ENV = 'production';
assert.equal((await service.context(actor)).canPreview, false);
await assert.rejects(service.current(actor, session.previewToken), /没有权限/);
process.env.NODE_ENV = 'development'; process.env.DEV_BYPASS_AUTH = '0';
assert.equal((await service.context(actor)).canPreview, false);
process.env.DEV_BYPASS_AUTH = '1'; process.env.DEV_PERMISSION_PREVIEW_USER_ID = 'unauthorized';
assert.equal((await service.context(actor)).canPreview, false);
} finally {
for (const [key,value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; }
}
});
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import type { AuthUser } from '../types.js';
import assistantRouter from '../../routes/asset-assistant/index.js';
import { registerFeedbackRoutes } from '../../routes/feedback/routes.js';
import { registerEleRoutes } from '../../routes/ele/routes.js';
function previewApp() {
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => {
c.set('user', { userId: 'target', userName: 'Target', loginName: 'target', depCode: '', depName: '',
roles: ['BI-Leader', 'BI-LEADER-ENERGY'], permissionLevel: 'full', preview: { actorId: 'actor', sessionId: 'preview' } });
await next();
});
app.route('/assistant', assistantRouter);
const feedbackRouter = new Hono();
const eleRouter = new Hono();
const ensureTable = async () => { assert.fail('preview must not initialize schema'); };
registerFeedbackRoutes(feedbackRouter, { db: pool, ensureTable });
registerEleRoutes(eleRouter, { db: pool, ensureTable });
app.route('/feedback', feedbackRouter);
app.route('/ele', eleRouter);
return app;
}
const paths = ['/assistant/gaps', '/feedback/mine', '/feedback/list', '/ele/list', '/ele/batches', '/ele/aggregate'];
test('preview gaps, feedback and charge pages perform only SELECT without schema initialization or retention writes', async (t) => {
const statements: { sql: string; values: unknown }[] = [];
t.mock.method(pool, 'query', async (sql: string, values: unknown) => {
statements.push({ sql, values });
return [[], []];
});
const app = previewApp();
for (const path of paths) assert.equal((await app.request(path)).status, 200, path);
assert.ok(statements.length >= paths.length);
assert.ok(statements.every(({ sql }) => /^\s*SELECT\b/.test(sql)));
const gap = statements.find(({ sql }) => sql.includes('FROM bi_asset_ai_gaps'))!;
assert.match(gap.sql, /last_seen >= DATE_SUB/);
assert.deepEqual(gap.values, ['target']);
const feedback = statements.find(({ sql }) => sql.includes('WHERE user_id = ?'))!;
assert.deepEqual(feedback.values, ['target']);
});
test('preview pages report missing storage as 503 without attempting to repair or initialize it', async (t) => {
const statements: string[] = [];
t.mock.method(pool, 'query', async (sql: string) => {
statements.push(sql);
throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' });
});
const app = previewApp();
for (const path of paths) {
const response = await app.request(path);
assert.equal(response.status, 503, path);
const body = await response.json();
assert.ok(body.error || body.message, path);
}
// Aggregate runs its two independent SELECT queries concurrently.
assert.equal(statements.length, paths.length + 1);
assert.ok(statements.every(sql => /^\s*SELECT\b/.test(sql)));
});
+68
View File
@@ -0,0 +1,68 @@
import { Hono, type Context, type Next } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import type { AuthUser, PermissionLevel } from '../types.js';
import { previewService, PreviewError, type createPreviewService } from './service.js';
type Service = ReturnType<typeof createPreviewService>;
function failure(c: Context, error: unknown) {
if (error instanceof PreviewError) return c.json({ error: error.message, code: error.code }, error.status);
console.error('permission-preview unavailable');
return c.json({ error: '权限预览暂不可用,请稍后重试', code: 'PREVIEW_UNAVAILABLE' }, 503);
}
export function createPreviewRouter(service: Service = previewService) {
const router = new Hono<{ Variables: { user: AuthUser } }>();
router.use('*', bodyLimit({ maxSize: 2048 }));
router.use('*', async (c, next) => {
c.header('Cache-Control', 'no-store');
if (!c.get('user')) return c.json({ error: '请先登录' }, 401);
try { await next(); } catch (e) { return failure(c, e); }
});
router.get('/context', async c => c.json(await service.context(c.get('user'))));
router.get('/users', async c => {
const q = (c.req.query('q') || '').trim(), level = c.req.query('level') || undefined;
const page = Number(c.req.query('page') || '1');
if (q.length > 80 || !Number.isSafeInteger(page) || page < 1 || page > 10000 || (level && !['full', 'department', 'personal'].includes(level)))
return c.json({ error: '搜索条件无效' }, 400);
return c.json(await service.search(c.get('user'), q, level as PermissionLevel | undefined, page));
});
router.post('/start', async c => {
const body = await c.req.json().catch(() => null);
if (!body || typeof body.userId !== 'string' || !/^[\w-]{1,64}$/.test(body.userId) || Object.keys(body).some(key => key !== 'userId'))
return c.json({ error: '请选择有效用户' }, 400);
return c.json(await service.start(c.get('user'), body.userId));
});
router.get('/current', async c => {
const result = await service.current(c.get('user'), c.req.header('X-BI-Preview') || '');
return c.json({ user: result.user, expiresAt: result.expiresAt });
});
router.post('/stop', async c => {
await service.stop(c.get('user'), c.req.header('X-BI-Preview') || '');
return c.json({ ok: true });
});
router.onError((error, c) => failure(c, error));
return router;
}
// These POSTs only query data (AI previews suppress gap recording). Everything else is denied by default.
const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']);
export function createPreviewMiddleware(service: Service = previewService) {
return async (c: Context, next: Next) => {
const token = c.req.header('X-BI-Preview');
if (!token) return next();
const actor = c.get('user') as AuthUser | undefined;
if (!actor) return c.json({ error: '请先登录' }, 401);
c.header('Cache-Control', 'no-store');
try {
const session = await service.current(actor, token);
const readOnly = (!['/api/vehicles/refresh', '/api/vehicles/debug'].includes(c.req.path) && ['GET', 'HEAD'].includes(c.req.method)) || (c.req.method === 'POST' && READ_POSTS.has(c.req.path))
|| (c.req.method === 'DELETE' && /^\/api\/asset-assistant\/session\/[^/]+$/.test(c.req.path));
await service.audit({ event: readOnly ? 'read' : 'blocked-write', actorId: actor.userId, targetId: session.user.userId,
sessionId: session.sessionId, method: c.req.method, path: c.req.path, permissionLevel: session.user.permissionLevel });
if (!readOnly) return c.json({ error: '权限预览为只读模式,请退出预览后操作', code: 'PREVIEW_READ_ONLY' }, 403);
c.set('actor', actor);
c.set('user', { ...session.user, preview: { actorId: actor.userId, sessionId: session.sessionId } } satisfies AuthUser);
} catch (error) { return failure(c, error); }
// Do not catch downstream failures as authentication errors.
return next();
};
}
+99
View File
@@ -0,0 +1,99 @@
import { resolveVehicleManagerIds } from '../manager-identity.js';
import { randomBytes } from 'node:crypto';
import { appendFile, mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import type { AuthUser } from '../types.js';
import { publicUser, userDirectory, type UserDirectory, type DirectoryUser } from './directory.js';
const TTL = 30 * 60 * 1000;
// Verified legacy SSO accounts; override with a comma-separated list of immutable user IDs.
export const DEFAULT_PREVIEW_USER_IDS = [
'1014807041625047040', // puhongxia (default local preview organization anchor)
'1105261382487539712', // zhanglan
'1116631120763437056', // shishengliang
];
export function isLocalPreviewEnabled(actor: AuthUser) {
return actor?.userId === 'dev-local' && actor.loginName === 'dev-local'
&& process.env.NODE_ENV !== 'production' && process.env.DEV_BYPASS_AUTH === '1' && process.env.DEV_PERMISSION_PREVIEW === '1';
}
export const configuredPreviewIds = () => new Set((process.env.BI_PERMISSION_PREVIEW_USER_IDS ?? DEFAULT_PREVIEW_USER_IDS.join(',')).split(',').map(s => s.trim()).filter(Boolean));
export type PreviewAudit = { event: string; actorId: string; targetId?: string; sessionId?: string; method?: string; path?: string; permissionLevel?: string };
export async function writePreviewAudit(event: PreviewAudit) {
const path = resolve(process.env.BI_PERMISSION_PREVIEW_AUDIT_FILE || 'logs/permission-preview.jsonl');
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await appendFile(path, JSON.stringify({ time: new Date().toISOString(), ...event }) + '\n', { mode: 0o600 });
}
export class PreviewError extends Error {
constructor(public code: string, message: string, public status: 400 | 403 | 410 | 503 = 403) { super(message); }
}
type Session = { actorId: string; targetId: string; id: string; expires: number };
export function createPreviewService(options: { directory?: UserDirectory; allowedIds?: () => Set<string>; audit?: (event: PreviewAudit) => Promise<void>; now?: () => number; managerIds?: (userId: string, loginName: string) => Promise<string[]> } = {}) {
const directory = options.directory || userDirectory;
const managerIds = options.managerIds || resolveVehicleManagerIds;
const allowedIds = options.allowedIds || configuredPreviewIds;
const audit = options.audit || writePreviewAudit;
const now = options.now || Date.now;
const sessions = new Map<string, Session>();
const clean = () => { for (const [token, s] of sessions) if (s.expires <= now()) sessions.delete(token); };
async function authorized(actor: AuthUser): Promise<DirectoryUser> {
if (isLocalPreviewEnabled(actor)) {
const anchorId = process.env.DEV_PERMISSION_PREVIEW_USER_ID || DEFAULT_PREVIEW_USER_IDS[0];
if (!allowedIds().has(anchorId)) throw new PreviewError('PREVIEW_REVOKED', '本地预览未配置有效授权范围');
const anchor = await directory.get(anchorId);
if (!anchor) throw new PreviewError('PREVIEW_REVOKED', '本地预览组织范围不可用');
return anchor;
}
if (!actor || !allowedIds().has(actor.userId)) throw new PreviewError('PREVIEW_REVOKED', '当前账号没有权限视角预览授权');
const current = await directory.get(actor.userId);
if (!current || current.loginName !== actor.loginName) throw new PreviewError('PREVIEW_REVOKED', '预览授权账号已失效,请重新登录');
return current;
}
async function targetFor(actor: DirectoryUser, targetId: string) {
const target = await directory.get(targetId);
if (!target || !actor.orgId || target.orgId !== actor.orgId) throw new PreviewError('PREVIEW_TARGET_UNAVAILABLE', '该用户已停用或不在可预览的组织内');
return target.permissionLevel === 'personal' ? { ...target, managerIds: await managerIds(target.userId, target.loginName) } : target;
}
return {
async context(actor: AuthUser) {
if (!actor || (!isLocalPreviewEnabled(actor) && !allowedIds().has(actor.userId))) return { canPreview: false, actor };
try { await authorized(actor); return { canPreview: true, actor }; }
catch (error) { if (error instanceof PreviewError) return { canPreview: false, actor }; throw error; }
},
async search(actor: AuthUser, query: string, level: 'full' | 'department' | 'personal' | undefined, page: number) {
const identity = await authorized(actor);
const term = query.trim();
if (!term) return { users: [], page: 1, hasMore: false };
return directory.search(identity.orgId, term, level, page);
},
async start(actor: AuthUser, targetId: string) {
const identity = await authorized(actor), target = await targetFor(identity, targetId);
clean();
if (sessions.size >= 1000) throw new PreviewError('PREVIEW_BUSY', '预览会话已满,请稍后重试', 503);
const token = randomBytes(32).toString('base64url');
const s = { actorId: actor.userId, targetId, id: randomBytes(12).toString('hex'), expires: now() + TTL };
await audit({ event: 'start', actorId: actor.userId, targetId, sessionId: s.id, permissionLevel: target.permissionLevel });
// Bound per-user sessions, preserving a small number of independent tabs.
const own = [...sessions].filter(([, value]) => value.actorId === actor.userId);
while (own.length >= 5) sessions.delete(own.shift()![0]);
sessions.set(token, s);
return { previewToken: token, user: publicUser(target), expiresAt: new Date(s.expires).toISOString() };
},
async current(actor: AuthUser, token: string) {
const s = sessions.get(token);
if (!s || s.expires <= now()) { sessions.delete(token); throw new PreviewError('PREVIEW_EXPIRED', '预览已过期,请重新选择用户', 410); }
if (s.actorId !== actor.userId) throw new PreviewError('PREVIEW_INVALID', '预览会话与登录账号不匹配');
const identity = await authorized(actor), target = await targetFor(identity, s.targetId);
return { user: publicUser(target), expiresAt: new Date(s.expires).toISOString(), sessionId: s.id };
},
async stop(actor: AuthUser, token: string) {
const s = sessions.get(token);
if (s && s.actorId !== actor.userId) throw new PreviewError('PREVIEW_INVALID', '预览会话与登录账号不匹配');
if (s) {
sessions.delete(token);
await audit({ event: 'stop', actorId: actor.userId, targetId: s.targetId, sessionId: s.id });
}
},
audit,
};
}
export const previewService = createPreviewService();
+4
View File
@@ -1,6 +1,10 @@
export type PermissionLevel = 'full' | 'department' | 'personal';
export interface AuthUser {
/** Server-only preview identity; never accepted from client input. */
preview?: { actorId: string; sessionId: string };
/** Verified legacy/current vehicle identities, resolved only on the server. */
managerIds?: string[];
userId: string;
userName: string;
loginName: string;
+19 -16
View File
@@ -1,6 +1,7 @@
import type { Context, Next } from 'hono';
import { isReadOnlyRequest } from './read-only-policy.js';
import type { AuthUser } from '../auth/types.js';
import { resolveVehicleManagerIds } from '../auth/manager-identity.js';
import type { AuthUser, JwtPayload } from '../auth/types.js';
import { authMode, verifyAuthToken } from '../auth/config.js';
@@ -41,23 +42,25 @@ export async function authMiddleware(c: Context, next: Next) {
const token = authHeader.slice(7);
let payload: JwtPayload;
try {
const payload = verifyAuthToken(token);
if (payload.authMethod === 'password' && !isReadOnlyRequest(c.req.method, path)) {
return c.json({ error: 'Password account is read-only' }, 403);
}
const user: AuthUser = {
userId: payload.userId,
userName: payload.userName,
loginName: payload.loginName,
depCode: payload.depCode,
depName: payload.depName,
permissionLevel: payload.permissionLevel,
roles: payload.roles ?? [],
};
c.set('user', user);
return next();
payload = verifyAuthToken(token);
} catch {
return c.json({ error: 'Invalid or expired token' }, 401);
}
if (payload.authMethod === 'password' && !isReadOnlyRequest(c.req.method, path)) {
return c.json({ error: 'Password account is read-only' }, 403);
}
const user: AuthUser = {
userId: payload.userId, userName: payload.userName, loginName: payload.loginName,
depCode: payload.depCode, depName: payload.depName,
permissionLevel: payload.permissionLevel, roles: payload.roles ?? [],
};
try {
if (user.permissionLevel === 'personal') user.managerIds = await resolveVehicleManagerIds(user.userId, user.loginName);
} catch {
return c.json({ error: '账号数据范围暂不可用,请稍后重试' }, 503);
}
c.set('user', user);
return next();
}
@@ -1,6 +1,8 @@
/** Query POSTs must be explicitly audited; never allow an entire route prefix. */
export function isReadOnlyRequest(method: string, path: string): boolean {
return ['GET', 'HEAD', 'OPTIONS'].includes(method)
// Vehicle aggregation and detail queries only read permission-filtered data.
|| (method === 'POST' && ['/api/vehicles/analysis', '/api/vehicles/analysis/details'].includes(path))
// Interprets a question and reads permission-filtered assets; no business writes.
|| (method === 'POST' && ['/api/asset-assistant/ask', '/api/asset-assistant/export'].includes(path))
// Session cleanup only removes process-local analysis state.
+20
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import { Hono } from 'hono';
import { readOnlyMiddleware } from './read-only.js';
import assistantRouter from '../routes/asset-assistant/index.js';
import { createPreviewRouter } from '../auth/preview/routes.js';
test('read-only preview permits asset query POST while preserving authentication and write restrictions', async () => {
const previous = process.env.DB_READ_ONLY;
@@ -46,3 +47,22 @@ test('read-only preview blocks write handlers but permits reads; normal mode is
else process.env.DB_READ_ONLY = previous;
}
});
test('read-only mode permits preview session controls only through their authentication guard', async () => {
const previous = process.env.DB_READ_ONLY;
try {
process.env.DB_READ_ONLY = '1';
const app = new Hono();
app.use('*', readOnlyMiddleware);
app.route('/api/permission-preview', createPreviewRouter());
for (const action of ['start', 'stop']) {
assert.equal((await app.request(`/api/permission-preview/${action}`, { method: 'POST', body: '{}' })).status, 401);
assert.equal((await app.request(`/api/permission-preview/${action}`, { method: 'PATCH' })).status, 403);
}
assert.equal((await app.request('/api/permission-preview/other', { method: 'POST' })).status, 403);
} finally {
if (previous === undefined) delete process.env.DB_READ_ONLY;
else process.env.DB_READ_ONLY = previous;
}
});
+2
View File
@@ -5,6 +5,8 @@ import { isReadOnlyRequest } from './read-only-policy.js';
export const readOnlyMiddleware: MiddlewareHandler = async (context, next) => {
if (process.env.DB_READ_ONLY === '1'
&& context.req.path !== '/api/auth/password'
// Preview controls only manage in-memory sessions; downstream auth still applies.
&& !(context.req.method === 'POST' && ['/api/permission-preview/start', '/api/permission-preview/stop'].includes(context.req.path))
&& !isReadOnlyRequest(context.req.method, context.req.path)) {
return context.json({ error: '当前为只读预览环境,不允许写入操作' }, 403);
}
+6 -4
View File
@@ -55,13 +55,15 @@ export const gapStore = {
[userId, fingerprint, demand, reason],
);
},
async list(userId: string | null): Promise<DemandGap[]> {
await ensure();
await purge();
async list(userId: string | null, options: { readOnly?: boolean } = {}): Promise<DemandGap[]> {
if (!options.readOnly) {
await ensure();
await purge();
}
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id,demand,reason,status,occurrences,
DATE_FORMAT(first_seen,'%Y-%m-%dT%H:%i:%sZ') firstSeen, DATE_FORMAT(last_seen,'%Y-%m-%dT%H:%i:%sZ') lastSeen
FROM bi_asset_ai_gaps ${userId ? "WHERE user_id = ?" : ""} ORDER BY last_seen DESC LIMIT 200`,
FROM bi_asset_ai_gaps WHERE ${userId ? "user_id = ? AND " : ""}last_seen >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 90 DAY) ORDER BY last_seen DESC LIMIT 200`,
userId ? [userId] : [],
);
return rows as DemandGap[];
+9 -4
View File
@@ -59,6 +59,7 @@ router.get("/gaps", async (c) => {
return c.json({
items: await gapStore.list(
c.req.query("scope") === "all" ? null : user.userId,
{ readOnly: !!user.preview },
),
canManage: manage,
});
@@ -502,8 +503,10 @@ router.post("/ask", async (c) => {
if (unanswered?.length) demand = `${unanswered.map(item => `${item.question}:${item.reason}`).join(";")}(原问题:${demand})`.slice(0, 600);
let recorded = false;
try {
await gapStore.record(user.userId, demand, gapReason);
recorded = true;
if (!user.preview) {
await gapStore.record(user.userId, demand, gapReason);
recorded = true;
}
} catch {
/* Report failure explicitly, never pretend it was persisted. */
}
@@ -518,8 +521,10 @@ router.post("/ask", async (c) => {
if (!controller.signal.aborted) {
let recorded = false;
try {
await gapStore.record(user.userId, demand, "generation");
recorded = true;
if (!user.preview) {
await gapStore.record(user.userId, demand, "generation");
recorded = true;
}
} catch {
/* No raw error or query payload in logs. */
}
@@ -40,6 +40,9 @@ const owner = (u: AuthUser) =>
u.depCode,
u.permissionLevel,
[...u.roles].sort(),
[...(u.managerIds ?? [u.userId])].sort(),
u.preview?.actorId ?? null,
u.preview?.sessionId ?? null,
]),
)
.digest("hex");
+14 -3
View File
@@ -30,8 +30,19 @@ export interface EleDependencies {
parse?: (buf: ArrayBuffer) => ParsedRow[];
}
function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined {
return c.get?.('user') as AuthUser | undefined;
}
export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
const { db, ensureTable } = deps;
app.onError((error, c) => {
if (currentUser(c)?.preview && ['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes((error as { code?: string }).code || ''))
return c.json({ message: '充电数据存储尚未就绪,权限预览不会初始化或更新表结构,请联系管理员。', code: 'PREVIEW_ELE_STORAGE_UNAVAILABLE' }, 503);
console.error(error);
return c.text('Internal Server Error', 500);
});
const parse = deps.parse ?? parseSheet;
// 电能数据导入属于能源域的管理能力。该路由只展示在隐藏入口,但必须在服务端
@@ -96,7 +107,7 @@ export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
// GET /api/ele/list — 分页列表(最新优先)
app.get('/list', async (c) => {
await ensureTable();
if (!currentUser(c)?.preview) await ensureTable();
const page = Math.max(1, Number(c.req.query('page')) || 1);
const limit = Math.min(200, Math.max(1, Number(c.req.query('limit')) || 50));
const filter = buildListFilter({
@@ -113,13 +124,13 @@ export function registerEleRoutes(app: Hono, deps: EleDependencies): void {
// GET /api/ele/batches — 批次列表
app.get('/batches', async (c) => {
await ensureTable();
if (!currentUser(c)?.preview) await ensureTable();
return c.json({ items: await listBatches(db) });
});
// GET /api/ele/aggregate — 聚合统计
app.get('/aggregate', async (c) => {
await ensureTable();
if (!currentUser(c)?.preview) await ensureTable();
const [overall, daily] = await Promise.all([
aggregateByVehicleKind(db),
aggregateDaily30d(db),
+9 -2
View File
@@ -33,6 +33,13 @@ function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined
export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void {
const { db, ensureTable } = deps;
app.onError((error, c) => {
if (currentUser(c)?.preview && ['ER_NO_SUCH_TABLE', 'ER_BAD_FIELD_ERROR'].includes((error as { code?: string }).code || ''))
return c.json({ message: '反馈存储尚未就绪,权限预览不会初始化或更新表结构,请联系管理员。', code: 'PREVIEW_FEEDBACK_STORAGE_UNAVAILABLE' }, 503);
console.error(error);
return c.text('Internal Server Error', 500);
});
const uploadImage = deps.uploadImage ?? uploadFeedbackImage;
// POST /api/feedback/submit — 提交反馈
@@ -98,7 +105,7 @@ export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): v
// GET /api/feedback/mine — 当前用户的反馈历史
app.get('/mine', async (c) => {
await ensureTable();
if (!currentUser(c)?.preview) await ensureTable();
const user = currentUser(c);
if (!user?.userId) return c.json({ items: [] });
return c.json({ items: await listFeedbackForUser(db, user.userId) });
@@ -106,7 +113,7 @@ export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): v
// GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限)
app.get('/list', async (c) => {
await ensureTable();
if (!currentUser(c)?.preview) await ensureTable();
const user = currentUser(c);
if (!canManageFeedback(user?.roles)) {
return c.json({ ok: false, message: '无权限' }, 403);
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import pool from '../../db/mysql.js';
import type { AuthUser } from '../../auth/types.js';
import dailyReportRouter from './daily-report.js';
import { DailyReportStorageUnavailable, getDailyReportSnapshot, listDailyReportHistory } from './daily-report-store.js';
test('preview report storage reads never initialize or write tables, including missing-table failures', async (t) => {
const statements: string[] = [];
let missing = false;
t.mock.method(pool, 'query', async (sql: string) => {
statements.push(sql);
if (missing) throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' });
return [[], []];
});
assert.equal(await getDailyReportSnapshot('2026-09-28', { readOnly: true }), null);
assert.deepEqual(await listDailyReportHistory(90, { readOnly: true }), { items: [], latestDate: null });
missing = true;
await assert.rejects(getDailyReportSnapshot('2026-09-28', { readOnly: true }), DailyReportStorageUnavailable);
await assert.rejects(listDailyReportHistory(90, { readOnly: true }), DailyReportStorageUnavailable);
assert.equal(statements.length, 4);
assert.ok(statements.every(sql => /^SELECT\b/.test(sql)));
});
test('both preview report endpoints return an actionable error without DDL when storage is absent', async (t) => {
const statements: string[] = [];
t.mock.method(pool, 'query', async (sql: string) => {
statements.push(sql);
throw Object.assign(new Error('missing'), { code: 'ER_NO_SUCH_TABLE' });
});
const app = new Hono<{ Variables: { user: AuthUser } }>();
app.use('*', async (c, next) => {
c.set('user', { userId: 'target', userName: 'Target', loginName: 'target', depCode: '', depName: '',
roles: [], permissionLevel: 'personal', preview: { actorId: 'actor', sessionId: 'preview' } });
await next();
});
app.route('/reports', dailyReportRouter);
for (const path of ['/reports?date=2026-09-28', '/reports/history']) {
const response = await app.request(path);
assert.equal(response.status, 503);
assert.equal((await response.json()).code, 'PREVIEW_REPORT_STORAGE_UNAVAILABLE');
}
assert.equal(statements.length, 2);
assert.ok(statements.every(sql => /^SELECT\b/.test(sql)));
});
@@ -18,7 +18,7 @@ import {
mileageBand,
roundMileage,
} from './daily-report-model.js';
import { getDailyReportSnapshot, saveDailyReportSnapshot } from './daily-report-store.js';
import { getDailyReportSnapshot, saveDailyReportSnapshot, type DailyReportReadOptions } from './daily-report-store.js';
interface AssessmentRow extends RowDataPacket {
target_id: number;
@@ -264,8 +264,8 @@ export async function generateDailyMileageReport(
});
}
export async function getOrGenerateDailyMileageReport(reportDate: string): Promise<DailyMileageReport> {
const snapshot = await getDailyReportSnapshot(reportDate);
export async function getOrGenerateDailyMileageReport(reportDate: string, options: DailyReportReadOptions = {}): Promise<DailyMileageReport> {
const snapshot = await getDailyReportSnapshot(reportDate, options);
if (snapshot) return snapshot;
return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE');
}
@@ -5,9 +5,8 @@ import {
loadDailyReportPayload,
updateDailyReportPayload,
} from './repository.js';
import type { ResultSetHeader, RowDataPacket } from 'mysql2';
import pool from '../../db/mysql.js';
import { ensureDailyReportTable, DAILY_REPORT_TABLE as TABLE } from '../../db/schema/mileage-report.js';
import { ensureDailyReportTable } from '../../db/schema/mileage-report.js';
import type {
DailyMileageReport,
MileageReportHistoryItem,
@@ -15,6 +14,22 @@ import type {
MileageReportSource,
} from '../../../shared/mileage/daily-report.js';
export type DailyReportReadOptions = { readOnly?: boolean };
export class DailyReportStorageUnavailable extends Error {
constructor() { super('日报归档尚未初始化,权限预览不会创建存储,请联系管理员。'); }
}
async function readReportRows<T>(read: () => Promise<T>, options: DailyReportReadOptions): Promise<T> {
if (!options.readOnly) await ensureDailyReportTable();
try {
return await read();
} catch (error) {
if (options.readOnly && (error as { code?: string })?.code === 'ER_NO_SUCH_TABLE')
throw new DailyReportStorageUnavailable();
throw error;
}
}
function parsePayload(value: unknown): DailyMileageReport | null {
if (!value) return null;
if (typeof value === 'object') return value as DailyMileageReport;
@@ -26,9 +41,8 @@ function parsePayload(value: unknown): DailyMileageReport | null {
}
}
export async function getDailyReportSnapshot(reportDate: string): Promise<DailyMileageReport | null> {
await ensureDailyReportTable();
const rows = await loadDailyReportPayload(pool, { reportDate });
export async function getDailyReportSnapshot(reportDate: string, options: DailyReportReadOptions = {}): Promise<DailyMileageReport | null> {
const rows = await readReportRows(() => loadDailyReportPayload(pool, { reportDate }), options);
return parsePayload(rows[0]?.report_payload);
}
@@ -68,10 +82,9 @@ export async function updateDailyReportSnapshotPayload(report: DailyMileageRepor
return affected > 0;
}
export async function listDailyReportHistory(limit = 90): Promise<MileageReportHistoryResponse> {
await ensureDailyReportTable();
export async function listDailyReportHistory(limit = 90, options: DailyReportReadOptions = {}): Promise<MileageReportHistoryResponse> {
const safeLimit = Math.min(366, Math.max(1, limit));
const rows = await listDailyReportHistoryRows(pool, { safeLimit });
const rows = await readReportRows(() => listDailyReportHistoryRows(pool, { safeLimit }), options);
const items: MileageReportHistoryItem[] = rows.map(row => ({
reportDate: row.report_date,
status: row.status,
+9 -4
View File
@@ -1,8 +1,9 @@
import { Hono } from 'hono';
import type { AuthUser } from '../../auth/types.js';
import { getOrGenerateDailyMileageReport } from './daily-report-service.js';
import { listDailyReportHistory } from './daily-report-store.js';
import { DailyReportStorageUnavailable, listDailyReportHistory } from './daily-report-store.js';
const app = new Hono();
const app = new Hono<{ Variables: { user: AuthUser } }>();
function isYmd(value: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(value);
@@ -11,8 +12,10 @@ function isYmd(value: string): boolean {
app.get('/history', async (c) => {
try {
const limit = Number(c.req.query('limit')) || 90;
return c.json(await listDailyReportHistory(limit));
return c.json(await listDailyReportHistory(limit, { readOnly: !!c.get('user')?.preview }));
} catch (error: unknown) {
if (error instanceof DailyReportStorageUnavailable)
return c.json({ message: error.message, code: 'PREVIEW_REPORT_STORAGE_UNAVAILABLE' }, 503);
console.error('mileage daily report history error:', error);
return c.json({ items: [], latestDate: null }, 500);
}
@@ -22,8 +25,10 @@ app.get('/', async (c) => {
const date = c.req.query('date') || '';
if (!isYmd(date)) return c.json({ message: 'date 必须为 YYYY-MM-DD' }, 400);
try {
return c.json(await getOrGenerateDailyMileageReport(date));
return c.json(await getOrGenerateDailyMileageReport(date, { readOnly: !!c.get('user')?.preview }));
} catch (error: unknown) {
if (error instanceof DailyReportStorageUnavailable)
return c.json({ message: error.message, code: 'PREVIEW_REPORT_STORAGE_UNAVAILABLE' }, 503);
console.error('mileage daily report error:', error);
return c.json({ message: error instanceof Error ? error.message : '日报生成失败' }, 500);
}
@@ -0,0 +1,271 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { Hono } from 'hono';
import type { AuthUser } from '../../auth/types.js';
import type { Vehicle } from '../../types.js';
import type { AssetAnalysisResponse, AssetAnalysisScope } from '../../../shared/assets/analytics.js';
import app from './routes.js';
import { vehicleRepository } from './repository.js';
import {
abnormalAgeBucket, abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis,
matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric,
validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope,
type ApprovalTimes,
} from './analytics.js';
const NOW = Date.parse('2026-09-28T12:00:00+08:00');
const approvals: ApprovalTimes = { available: true, byMoveId: new Map() };
const make = (id: string, overrides: Partial<Vehicle> = {}): Vehicle => ({
id, plateNumber: `TEST-${id}`, vin: `PRIVATE-VIN-${id}`, type: '4.5T', model: '现代4.5T普货',
color: '白色', location: '嘉兴', region: '嘉兴', province: '浙江省', city: '嘉兴市',
status: 'Operating', operationStatus: '1', ownership: 'Self', rentCompany: '',
contractNo: '合同甲', customerName: '测试客户公司', orgName: null, departmentName: '部门甲',
subjectOrg: '公司甲', projectName: null, customerManager: '负责人甲', managerId: 'manager-a',
brandLabel: '现代', ...overrides,
});
test('duration buckets use exact approval-time boundaries and reject missing or future dates', () => {
const since = (days: number) => new Date(NOW - days * 86400000).toISOString();
for (const [days, expected] of [[0, '0-3d'], [2.999, '0-3d'], [3, '3-7d'], [6.999, '3-7d'], [7, '7-15d'], [14.999, '7-15d'], [15, '15-30d'], [29.999, '15-30d'], [30, '30d+'], [90, '30d+']] as const) {
assert.equal(abnormalAgeBucket(since(days), NOW), expected);
}
for (const invalid of [undefined, '', 'not-a-date', since(-1)]) {
assert.equal(abnormalAgeBucket(invalid, NOW), 'unknown');
assert.equal(abnormalElapsedDays(invalid, NOW), null);
}
assert.equal(abnormalElapsedDays('2026-09-27T12:00:00+08:00', NOW), 1);
assert.equal(abnormalAgeBucket('2026-09-25T04:00:00Z', NOW), '3-7d');
});
test('asset dispositions reconcile to total while abnormal and pending stay inventory subsets', () => {
const data = [
make('leased'), make('self', { operationStatus: '2' }),
make('available', { operationStatus: '3', status: 'Inventory' }),
make('abnormal', { operationStatus: '3', status: 'Abnormal' }),
make('pending', { operationStatus: '4', status: 'Pending' }),
make('unknown', { operationStatus: null, status: 'Inventory' }),
];
const result = buildAssetAnalysis(data, approvals, NOW);
assert.equal(result.summary.totalAssets, 6);
assert.equal(result.summary.operating.total, 2);
assert.equal(result.summary.operating.leased, 1);
assert.equal(result.summary.operating.self, 1);
assert.deepEqual(result.summary.inventory, { total: 3, inStock: 1, abnormal: 1 });
assert.equal(result.summary.pendingDelivery, 1);
assert.equal(result.summary.otherAssets, 1);
assert.equal(result.cells.reduce((sum, row) => sum + row.quantity, 0), 6);
const count = (metric: 'assets' | 'operating' | 'inventory' | 'abnormal' | 'pending') => result.cells.filter(cell => matchesAnalysisMetric(cell, metric)).reduce((sum, row) => sum + row.quantity, 0);
assert.deepEqual(['assets', 'operating', 'inventory', 'abnormal', 'pending'].map(metric => count(metric as Parameters<typeof count>[0])), [6, 2, 3, 1, 1]);
assert.equal(analysisCellForVehicle(data[0], approvals, NOW).assetDisposition, '租赁');
assert.equal(analysisCellForVehicle(data[1], approvals, NOW).assetDisposition, '自营');
assert.equal(analysisCellForVehicle(data[2], approvals, NOW).assetDisposition, '在库');
assert.equal(analysisCellForVehicle(data[5], approvals, NOW).assetDisposition, '其他');
});
test('one grouping keeps municipality, cold-chain and other-type detail filters consistent', () => {
const first = make('1', { province: '上海市', city: '松江区', model: '现代4.5T冷链' });
const second = make('2', { province: '上海市', city: '浦东新区', model: '现代4.5T冷链' });
const result = buildAssetAnalysis([first, second, make('3', { type: '挂车', model: '挂车' })], approvals, NOW);
const cold = result.cells.find(cell => cell.type === '4.5T冷链')!;
assert.equal(cold.quantity, 2);
assert.equal(cold.city, '上海市');
assert.equal(result.cells.find(cell => cell.type === '其他')?.quantity, 1);
assert.equal(matchesAnalysisFilters(cold, { region: '华东', city: '上海市', type: '4.5T冷链' }), true);
assert.equal(matchesAnalysisFilters(cold, { region: '华南', city: '上海市' }), false);
});
test('analysis regions and cities exactly match inventory statistics and retain unmapped locations', async (t) => {
const stock = (id: string, province: string | null, city: string | null) => make(id, {
operationStatus: '3', status: 'Inventory', province, city,
// This legacy field intentionally disagrees with coordinates: neither API may use it.
location: '广东', region: '广东',
});
const vehicles = [
stock('east-1', '上海市', '松江区'), stock('east-2', '上海市', '浦东新区'),
stock('south', '广东省', '广州市'), stock('northwest', '新疆', '乌鲁木齐市'),
stock('southwest', '四川省', '成都市'), stock('north', '北京市', '朝阳区'),
stock('central', '湖北省', '武汉市'), stock('unmapped', null, null),
];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
const inventoryResponse = await app.request('/inventory-stats?subject=公司甲');
const analysisResponse = await app.request('/analysis?subject=公司甲');
assert.equal(inventoryResponse.status, 200);
assert.equal(analysisResponse.status, 200);
const inventory = await inventoryResponse.json() as Array<{
region: string; city: string; brand: string; type: string; model: string; quantity: number;
}>;
const analysis = await analysisResponse.json() as AssetAnalysisResponse;
const locations = (rows: Array<{ region: string; city: string; quantity: number }>) => rows
.map(row => ({ region: row.region, city: row.city, quantity: row.quantity }))
.sort((a, b) => `${a.region}:${a.city}`.localeCompare(`${b.region}:${b.city}`));
assert.deepEqual(locations(analysis.cells), locations(inventory));
assert.equal(analysis.summary.inventory.total, vehicles.length);
for (const name of ['华东', '华南', '西北', '西南']) assert.ok(analysis.cells.some(cell => cell.region === name));
// The inventory mapper also supports these regions. Do not arbitrarily reassign or discard them.
assert.ok(analysis.cells.some(cell => cell.region === '华北'));
assert.ok(analysis.cells.some(cell => cell.region === '华中'));
const unknown = analysis.cells.find(cell => cell.region === '其他');
assert.equal(unknown?.quantity, 1);
assert.equal(unknown?.city, '其他');
for (const row of inventory) {
const key = JSON.stringify([row.region, row.city, row.brand, row.type, row.model]);
const scopedResponse = await app.request('/analysis?subject=公司甲', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inventoryKeys: [key] }),
});
assert.equal(scopedResponse.status, 200);
const scoped = await scopedResponse.json() as AssetAnalysisResponse;
assert.equal(scoped.summary.inventory.total, row.quantity);
assert.ok(scoped.cells.every(cell => cell.region === row.region && cell.city === row.city));
const details = await app.request('/analysis/details?subject=公司甲', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: 'inventory', inventoryKeys: [key], filters: { region: row.region, city: row.city } }),
});
assert.equal(details.status, 200);
assert.equal((await details.json() as unknown[]).length, row.quantity);
}
});
test('abnormal classification never substitutes target or actual completion for approval time', () => {
const vehicle = make('1', {
operationStatus: '3', status: 'Abnormal', abnormalMove: {
id: 'move-1', type: '维修', work: 'PRIVATE-WORK', targetCompletionTime: '2026-09-01 12:00',
actualCompletionTime: '2026-09-27 12:00', initiatingDepartment: null, executingDepartment: null,
responsiblePerson: null, driver: null, status: '待结束',
},
});
assert.equal(analysisCellForVehicle(vehicle, approvals, NOW).abnormalAge, 'unknown');
const history = { available: true, byMoveId: new Map([['move-1', '2026-09-21T12:00:00+08:00']]) };
assert.equal(analysisCellForVehicle(vehicle, history, NOW).abnormalAge, '7-15d');
const noWorkflow = buildAssetAnalysis([vehicle], { available: false, byMoveId: new Map() }, NOW);
assert.equal(noWorkflow.approvalTimeAvailable, false);
assert.equal(noWorkflow.summary.inventory.abnormal, 1);
});
test('scope and dimensional filter validation rejects unknown keys, arrays and malformed enums', () => {
assert.equal(validAnalysisScope({ inventoryKeys: [], subject: null, attendance: 'idle', isColdChain: 'false' }), true);
assert.equal(validAnalysisFilters({ region: '华东', assetDisposition: '在库', abnormalAge: 'unknown' }), true);
for (const bad of [null, [], 'all', { managerId: 'other' }, { inventoryKeys: [1] }, { inventoryKeys: null }, { category: 'anything' }, { attendance: 'all' }, { isTrailer: false }, { isColdChain: 'yes' }, { subject: 4 }]) {
assert.equal(validAnalysisScope(bad), false, JSON.stringify(bad));
}
for (const bad of [null, [], { quantity: 10 }, { region: ['华东'] }, { unknown: 'value' }]) {
assert.equal(validAnalysisFilters(bad), false, JSON.stringify(bad));
}
});
test('multi-value filters validate known dimensions and string arrays without changing single-value filters', () => {
for (const valid of [{}, { region: [] }, { region: ['华东', '华南'], type: ['4.5T普货', '18T'] }]) {
assert.equal(validAnalysisFilterValues(valid), true);
}
for (const invalid of [undefined, null, [], 'all', { region: '华东' }, { region: [1] }, { region: ['华东', null] }, { region: [['华东']] }, { region: null }, { region: undefined }, { notADimension: [] }]) {
assert.equal(validAnalysisFilterValues(invalid), false, JSON.stringify(invalid));
}
assert.equal(validAnalysisFilters({ region: ['华东', '华南'] }), false);
});
test('multi-value alternatives are ORed inside a dimension and ANDed with row filters', () => {
const cell = analysisCellForVehicle(make('1'), approvals, NOW);
assert.equal(matchesAnalysisFilterValues(cell, {}), true);
assert.equal(matchesAnalysisFilterValues(cell, { region: [], type: [] }), true);
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T', '4.5T普货'] }), true);
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华东', '华南'], type: ['18T'] }), false);
assert.equal(matchesAnalysisFilterValues(cell, { region: ['华南'], type: ['4.5T普货'] }), false);
const multiMatches = matchesAnalysisFilterValues(cell, { region: ['华东', '华南'] });
assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华东' }), true);
assert.equal(multiMatches && matchesAnalysisFilters(cell, { region: '华南' }), false);
});
test('detail multi-select preserves permissions, company, scope and a clicked row intersection', async (t) => {
const vehicles = [
make('east-light'), make('east-heavy', { type: '18T', model: '飞驰18T' }),
make('south-light', { province: '广东省', city: '广州市' }),
make('south-heavy', { province: '广东省', city: '广州市', type: '18T', model: '飞驰18T' }),
make('hidden-user', { managerId: 'manager-b' }),
make('hidden-company', { subjectOrg: '公司乙' }),
make('hidden-department', { departmentName: '部门乙' }),
make('inventory', { operationStatus: '3', status: 'Inventory' }),
];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
const secured = new Hono<{ Variables: { user: AuthUser } }>();
secured.use('*', async (c, next) => {
c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] });
await next();
});
secured.route('/', app);
const request = (extra: Record<string, unknown>) => secured.request('/analysis/details?subject=公司甲', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metric: 'operating', scope: { department: '部门甲' }, filters: {}, ...extra }),
});
const ids = async (extra: Record<string, unknown>) => {
const response = await request(extra);
assert.equal(response.status, 200);
return (await response.json() as Array<{ id: string }>).map(row => row.id);
};
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货', '18T'] } }), ['east-light', 'east-heavy', 'south-light', 'south-heavy']);
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'], type: ['4.5T普货'] }, filters: { region: '华东' } }), ['east-light']);
assert.deepEqual(await ids({ filterValues: { region: ['华南'] }, filters: { region: '华东' } }), []);
assert.deepEqual(await ids({ filterValues: { region: [], type: [] }, filters: { type: '18T' } }), ['east-heavy', 'south-heavy']);
assert.deepEqual(await ids({ filterValues: { region: ['华东', '华南'] }, scope: { subject: '公司乙' } }), []);
for (const invalid of [null, [], { region: '华东' }, { region: [1] }, { managerId: ['manager-b'] }]) {
assert.equal((await request({ filterValues: invalid })).status, 400);
}
assert.equal((await request({ filters: { region: ['华东'] } })).status, 400);
});
test('analysis and final details intersect company, permissions and exact entry scope without leaking row fields', async (t) => {
const vehicles = [make('visible'), make('hidden', { managerId: 'manager-b' }), make('other-company', { subjectOrg: '公司乙' })];
t.mock.method(vehicleRepository, 'getVehicles', async () => vehicles);
const secured = new Hono<{ Variables: { user: AuthUser } }>();
secured.use('*', async (c, next) => {
c.set('user', { userId: 'manager-a', userName: '测试', loginName: 'test', depCode: 'a', depName: '部门甲', permissionLevel: 'personal', roles: [] });
await next();
});
secured.route('/', app);
const post = (path: string, body: unknown) => secured.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const response = await post('/analysis?subject=公司甲', { scope: { model: '现代4.5T普货' } });
assert.equal(response.status, 200);
const aggregate = await response.json() as AssetAnalysisResponse;
assert.equal(aggregate.summary.totalAssets, 1);
const serialized = JSON.stringify(aggregate);
for (const secret of ['PRIVATE-VIN', 'TEST-visible', 'manager-a', 'manager-b', '测试客户公司', 'plateNumber', 'managerId', 'contractNo']) {
assert.equal(serialized.includes(secret), false, `aggregate leaked ${secret}`);
}
const details = await post('/analysis/details?subject=公司甲', { metric: 'operating', filters: { region: '华东' }, generatedAt: aggregate.generatedAt, scope: { model: '现代4.5T普货' } });
assert.equal(details.status, 200);
assert.deepEqual((await details.json() as Array<{ id: string }>).map(row => row.id), ['visible']);
const excluded = await post('/analysis?subject=公司甲', { scope: { subject: '公司乙' } });
assert.equal((await excluded.json() as AssetAnalysisResponse).summary.totalAssets, 0);
});
test('analysis inherits legacy list filtering including attendance and empty inventory scopes', async (t) => {
const rows = [
make('1'), make('2', { model: '现代4.5T冷链' }),
make('3', { operationStatus: '3', status: 'Inventory' }),
make('4', { type: '挂车', model: '挂车' }),
];
t.mock.method(vehicleRepository, 'getVehicles', async () => rows);
t.mock.method(vehicleRepository, 'getAttendanceMileageRows', async () => [
{ plate_number: 'TEST-1', day_mileage: 10 }, { plate_number: 'TEST-2', day_mileage: 0 },
]);
const scopes: AssetAnalysisScope[] = [
{ source: 'region', location: '华东' }, { vehicleType: '4.5T冷链', category: 'Operating' },
{ department: '部门甲', manager: '负责人甲', attendance: 'active' },
{ isTrailer: 'true' }, { isColdChain: 'false', brand: '现代', batch: '合同甲' },
{ subject: ' 公司甲 ' }, { inventoryKeys: [] },
];
for (const scope of scopes) {
const { inventoryKeys, ...params } = scope;
const query = new URLSearchParams(Object.entries(params).filter((pair): pair is [string, string] => typeof pair[1] === 'string'));
const listResponse = await app.request(`/list?${query}`, inventoryKeys ? {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ inventoryKeys }),
} : undefined);
const analysisResponse = await app.request('/analysis', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope }),
});
assert.equal(analysisResponse.status, 200);
assert.equal((await analysisResponse.json() as AssetAnalysisResponse).summary.totalAssets, (await listResponse.json() as unknown[]).length, JSON.stringify(scope));
}
for (const body of [{ scope: { inventoryKeys: [42] } }, { scope: { unauthorizedField: 'x' } }, { inventoryKeys: 'all' }]) {
assert.equal((await app.request('/analysis', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).status, 400);
}
});
+170
View File
@@ -0,0 +1,170 @@
import { queryMovementApprovalTimes } from './repository.js';
import type { Vehicle } from '../../types.js';
import {
ASSET_ANALYSIS_DIMENSIONS,
type AbnormalAgeBucket,
type AssetAnalysisCell,
type AssetAnalysisFilters,
type AssetAnalysisFilterValues,
type AssetAnalysisMetric,
type AssetAnalysisResponse,
type AssetAnalysisScope,
} from '../../../shared/assets/analytics.js';
import {
isInventoryAbnormal, isInventoryPending, isInventoryVehicle, isOperatingVehicle,
mapMacroRegion, resolveCity,
} from './model.js';
export interface ApprovalTimes {
available: boolean;
byMoveId: Map<string, string>;
}
/**
* Verified workflow source: movement = 异动审批; business_id is the movement id.
* Only a PASS task reaching an end node in a finished instance counts as approval.
* flow_his_task.update_time is documented as 审批完成时间. Movement start/create
* timestamps are intentionally never substituted when workflow history is absent.
*/
export async function loadMovementApprovalTimes(vehicles: Vehicle[]): Promise<ApprovalTimes> {
const ids = [...new Set(vehicles.filter(isInventoryAbnormal).flatMap(v => v.abnormalMove?.id ? [v.abnormalMove.id] : []))];
if (!ids.length) return { available: true, byMoveId: new Map() };
try {
const rows = await queryMovementApprovalTimes(ids);
return {
available: true,
byMoveId: new Map((rows as Array<{ move_id: string; approved_at: string | null }> )
.filter(row => row.approved_at).map(row => [String(row.move_id), row.approved_at!])),
};
} catch {
// Workflow access is optional; totals remain usable and missing ages stay explicit.
return { available: false, byMoveId: new Map() };
}
}
export function abnormalElapsedDays(approvedAt: string | undefined, now: number): number | null {
const timestamp = approvedAt ? Date.parse(approvedAt) : NaN;
return Number.isFinite(timestamp) && timestamp <= now ? (now - timestamp) / 86400000 : null;
}
export function abnormalAgeBucket(approvedAt: string | undefined, now: number): AbnormalAgeBucket {
const days = abnormalElapsedDays(approvedAt, now);
if (days === null) return 'unknown';
if (days < 3) return '0-3d';
if (days < 7) return '3-7d';
if (days < 15) return '7-15d';
if (days < 30) return '15-30d';
return '30d+';
}
export function analysisCellForVehicle(vehicle: Vehicle, approvals: ApprovalTimes, now: number): AssetAnalysisCell {
const operating = isOperatingVehicle(vehicle);
const inventory = isInventoryVehicle(vehicle);
const abnormal = isInventoryAbnormal(vehicle);
const pending = isInventoryPending(vehicle);
const operation = (vehicle.operationStatus || '').trim();
const operationKind = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[operation] || operation || '未分类';
const inventoryState = !inventory ? 'not-applicable' : abnormal ? 'abnormal' : pending ? 'pending'
: operationKind === '可运营' ? 'available' : operationKind === '待运营' ? 'awaiting' : 'unclassified';
return {
type: vehicle.type === '4.5T' ? (vehicle.model.includes('冷链') ? '4.5T冷链' : '4.5T普货')
: ['18T', '49T'].includes(vehicle.type) ? vehicle.type : '其他',
model: vehicle.model || '未标注型号',
// Match /inventory-stats inventoryDimensions exactly: the legacy vehicle.location
// field uses a different, older region taxonomy and must not drive BI drill-down.
// Keep the mapper's unknown bucket so missing locations never disappear.
region: mapMacroRegion(vehicle.province, vehicle.city),
city: resolveCity(vehicle.city, vehicle.province),
assetStatus: operating ? 'operating' : inventory ? 'inventory' : 'other',
assetDisposition: inventory ? '在库' : operating ? (operationKind === '租赁' ? '租赁' : operationKind === '自营' ? '自营' : '其他运营') : '其他',
operationKind,
inventoryState,
abnormalType: abnormal ? (vehicle.abnormalMove?.type || '未提供') : '不适用',
abnormalAge: abnormal ? abnormalAgeBucket(approvals.byMoveId.get(vehicle.abnormalMove?.id || ''), now) : 'unknown',
department: vehicle.model === '公务车/挂靠车' ? '公务车' : vehicle.departmentName || '未分配部门',
manager: vehicle.customerManager || '未分配',
customer: vehicle.customerName || '未分配客户',
quantity: 1,
};
}
export function matchesAnalysisMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
if (metric === 'assets') return true;
if (metric === 'operating') return cell.assetStatus === 'operating';
if (metric === 'inventory') return cell.assetStatus === 'inventory';
if (metric === 'abnormal') return cell.inventoryState === 'abnormal';
return cell.inventoryState === 'pending';
}
export function matchesAnalysisFilters(cell: AssetAnalysisCell, filters: AssetAnalysisFilters): boolean {
return ASSET_ANALYSIS_DIMENSIONS.every(dimension => filters[dimension] === undefined || cell[dimension] === filters[dimension]);
}
export function matchesAnalysisFilterValues(cell: AssetAnalysisCell, filterValues: AssetAnalysisFilterValues): boolean {
return ASSET_ANALYSIS_DIMENSIONS.every(dimension => {
const values = filterValues[dimension];
return !values?.length || values.includes(cell[dimension]);
});
}
export function buildAssetAnalysis(vehicles: Vehicle[], approvals: ApprovalTimes, now: number): AssetAnalysisResponse {
const groups = new Map<string, AssetAnalysisCell>();
for (const vehicle of vehicles) {
const cell = analysisCellForVehicle(vehicle, approvals, now);
const key = JSON.stringify(ASSET_ANALYSIS_DIMENSIONS.map(dimension => cell[dimension]));
const existing = groups.get(key);
if (existing) existing.quantity += 1;
else groups.set(key, cell);
}
const operating = vehicles.filter(isOperatingVehicle);
const inventory = vehicles.filter(isInventoryVehicle);
return {
generatedAt: new Date(now).toISOString(),
approvalTimeAvailable: approvals.available,
summary: {
totalAssets: vehicles.length,
operating: {
total: operating.length,
self: operating.filter(v => ['2', '自营'].includes((v.operationStatus || '').trim())).length,
leased: operating.filter(v => ['1', '租赁'].includes((v.operationStatus || '').trim())).length,
public: operating.filter(v => v.ownership === 'Public').length,
hanging: 0,
},
inventory: {
total: inventory.length,
inStock: inventory.filter(v => v.status === 'Inventory').length,
abnormal: inventory.filter(isInventoryAbnormal).length,
},
pendingDelivery: inventory.filter(isInventoryPending).length,
otherAssets: vehicles.length - operating.length - inventory.length,
},
cells: [...groups.values()].sort((a, b) => b.quantity - a.quantity),
};
}
export function validAnalysisFilters(value: unknown): value is AssetAnalysisFilters {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.entries(value).every(([key, item]) => ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number]) && typeof item === 'string');
}
export function validAnalysisFilterValues(value: unknown): value is AssetAnalysisFilterValues {
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.entries(value).every(([key, values]) =>
ASSET_ANALYSIS_DIMENSIONS.includes(key as typeof ASSET_ANALYSIS_DIMENSIONS[number])
&& Array.isArray(values) && values.every(item => typeof item === 'string'));
}
export function validAnalysisScope(value: unknown): value is AssetAnalysisScope {
const keys = new Set(['brand', 'batch', 'model', 'location', 'status', 'category', 'vehicleType', 'manager', 'customer', 'department', 'attendance', 'isColdChain', 'isTrailer', 'source', 'subject']);
return value !== null && typeof value === 'object' && !Array.isArray(value)
&& Object.entries(value).every(([key, item]) => {
if (key === 'inventoryKeys') return Array.isArray(item) && item.every(entry => typeof entry === 'string');
if (!keys.has(key)) return false;
if (key === 'subject' && item === null) return true;
if (typeof item !== 'string') return false;
if (key === 'category') return ['Inventory', 'Abnormal', 'Operating', 'Pending'].includes(item);
if (key === 'attendance') return ['active', 'idle'].includes(item);
if (key === 'isColdChain' || key === 'isTrailer') return ['true', 'false'].includes(item);
return true;
});
}
@@ -39,6 +39,7 @@ test('keeps all vehicle GET endpoints in their established order', () => {
'/dept-stats',
'/region-stats',
'/customer-stats',
'/analysis',
'/list',
'/inventory-stats',
'/weekly-detail',
+18
View File
@@ -458,3 +458,21 @@ export class VehicleRepository {
}
export const vehicleRepository = new VehicleRepository(pool as unknown as Database);
/** Workflow approval timestamps for abnormal-movement analysis. */
export async function queryMovementApprovalTimes(ids: string[]) {
if (!ids.length) return [];
const [rows] = await pool.query(`
SELECT f.business_id AS move_id,
DATE_FORMAT(MAX(h.update_time), '%Y-%m-%dT%H:%i:%s+08:00') AS approved_at
FROM \`ry-workflow\`.flow_instance f
JOIN \`ry-workflow\`.flow_definition d ON d.id = f.definition_id AND d.flow_code = 'movement'
JOIN \`ry-workflow\`.flow_his_task h ON h.instance_id = f.id
AND h.del_flag = '0' AND h.skip_type = 'PASS' AND h.flow_status IN ('pass', '2')
JOIN \`ry-workflow\`.flow_node n ON n.definition_id = f.definition_id
AND n.node_code = h.target_node_code AND n.node_type = 2 AND n.del_flag = '0'
WHERE f.del_flag = '0' AND f.flow_status IN ('finish', '8')
AND f.business_id IN (${ids.map(() => '?').join(',')})
GROUP BY f.business_id`, ids);
return rows;
}
+75 -9
View File
@@ -37,6 +37,13 @@ import {
} from './utils.js';
import { vehicleRepository, type FlowDetailRow, type WeeklyDetailType } from './repository.js';
import type { AssetAnalysisMetric, AssetAnalysisScope } from '../../../shared/assets/analytics.js';
import {
abnormalElapsedDays, analysisCellForVehicle, buildAssetAnalysis, loadMovementApprovalTimes,
matchesAnalysisFilters, matchesAnalysisFilterValues, matchesAnalysisMetric,
validAnalysisFilters, validAnalysisFilterValues, validAnalysisScope,
} from './analytics.js';
export { mapRegion } from './model.js';
const app = new Hono();
@@ -354,6 +361,54 @@ app.get('/customer-stats', async (c) => {
return c.json(result);
});
/** Analysis uses the same permission-filtered collection and exact entry scope as details. */
app.on(['GET', 'POST'], '/analysis', async (c) => {
const body = c.req.method === 'POST' ? await c.req.json().catch(() => null) : {};
if (!body || typeof body !== 'object' || Array.isArray(body)) return c.json({ error: '统计范围无效' }, 400);
const scope = body.scope || {};
if (!validAnalysisScope(scope) || (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) {
return c.json({ error: '统计范围无效' }, 400);
}
let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), scope);
if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys });
const approvals = await loadMovementApprovalTimes(vehicles);
return c.json(buildAssetAnalysis(vehicles, approvals, Date.now()));
});
app.post('/analysis/details', async (c) => {
const body = await c.req.json().catch(() => null);
const filterValues = body?.filterValues === undefined ? {} : body.filterValues;
const metrics: AssetAnalysisMetric[] = ['assets', 'operating', 'inventory', 'abnormal', 'pending'];
if (!body || !metrics.includes(body.metric) || !validAnalysisFilters(body.filters || {}) || !validAnalysisScope(body.scope || {})
|| !validAnalysisFilterValues(filterValues)
|| (body.inventoryKeys !== undefined && (!Array.isArray(body.inventoryKeys) || !body.inventoryKeys.every((key: unknown) => typeof key === 'string')))) {
return c.json({ error: '下钻条件无效' }, 400);
}
const now = body.generatedAt === undefined ? Date.now() : typeof body.generatedAt === 'string' ? Date.parse(body.generatedAt) : NaN;
if (!Number.isFinite(now) || now > Date.now() + 60000) return c.json({ error: '统计时间无效,请刷新分析' }, 400);
let vehicles = await filterVehicleListScope(await getVehiclesForUser(c), body.scope || {});
if (body.inventoryKeys) vehicles = await filterVehicleListScope(vehicles, { inventoryKeys: body.inventoryKeys });
const approvals = await loadMovementApprovalTimes(vehicles);
const filtered = vehicles.filter(vehicle => {
const cell = analysisCellForVehicle(vehicle, approvals, now);
return matchesAnalysisMetric(cell, body.metric)
&& matchesAnalysisFilters(cell, body.filters || {})
&& matchesAnalysisFilterValues(cell, filterValues);
});
return c.json(filtered.map(vehicle => {
const result = vehicleListItem(vehicle);
const approvedAt = approvals.byMoveId.get(vehicle.abnormalMove?.id || '');
return {
...result,
abnormalMove: result.abnormalMove ? {
...result.abnormalMove,
approvalPassedAt: approvedAt || null,
elapsedDays: abnormalElapsedDays(approvedAt, now),
} : null,
};
}));
});
// POST uses a JSON scope to avoid URL length limits for inventory subtotal drilldown.
app.on(['GET', 'POST'], '/list', async (c) => {
let inventoryKeys: Set<string> | undefined;
@@ -365,12 +420,20 @@ app.on(['GET', 'POST'], '/list', async (c) => {
inventoryKeys = new Set(body.inventoryKeys);
}
const vehicles = await getVehiclesForUser(c);
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = c.req.query();
const filtered = await filterVehicleListScope(vehicles, { ...c.req.query(), inventoryKeys: inventoryKeys ? [...inventoryKeys] : undefined });
return c.json(filtered.map(vehicleListItem));
});
let filtered = c.req.query('source') === 'region'
async function filterVehicleListScope(vehicles: Vehicle[], scope: AssetAnalysisScope): Promise<Vehicle[]> {
const { brand, batch, model, location, status, category, vehicleType, manager, customer, isColdChain, isTrailer, department, attendance } = scope;
let filtered = scope.source === 'region'
? vehicles.filter(v => isOperatingVehicle(v) || isInventoryPending(v))
: vehicles;
const subject = (scope.subject || '').trim();
if (subject) filtered = filtered.filter(v => v.subjectOrg === subject);
const inventoryKeys = scope.inventoryKeys ? new Set(scope.inventoryKeys) : undefined;
if (inventoryKeys) {
filtered = filtered.filter(v => isInventoryVehicle(v) && inventoryKeys.has(inventoryKey(inventoryDimensions(v))));
}
@@ -388,7 +451,7 @@ app.on(['GET', 'POST'], '/list', async (c) => {
}
}
if (vehicleType) {
if (vehicleType === '区域其他' && c.req.query('source') === 'region') {
if (vehicleType === '区域其他' && scope.source === 'region') {
filtered = filtered.filter(v => !['4.5T', '18T', '49T'].includes(v.type));
} else if (VEHICLE_TYPE_FILTERS[vehicleType]) {
filtered = filtered.filter(VEHICLE_TYPE_FILTERS[vehicleType]);
@@ -408,7 +471,7 @@ app.on(['GET', 'POST'], '/list', async (c) => {
filtered = filtered.filter((v) => v.model === model);
}
if (location && location !== 'All') {
filtered = filterByLocation(filtered, location, c.req.query('source'));
filtered = filterByLocation(filtered, location, scope.source);
}
if (status && status !== 'All') {
filtered = filtered.filter((v) => v.status === status);
@@ -446,8 +509,11 @@ app.on(['GET', 'POST'], '/list', async (c) => {
filtered = filtered.filter((v) => wantTrailer ? (v.type === '挂车' || v.model.includes('挂车')) : !(v.type === '挂车' || v.model.includes('挂车')));
}
return c.json(
filtered.map((v) => ({
return filtered;
}
function vehicleListItem(v: Vehicle) {
return {
id: v.id,
plateNumber: v.plateNumber,
vin: v.vin,
@@ -471,9 +537,9 @@ app.on(['GET', 'POST'], '/list', async (c) => {
customerManager: v.customerManager,
brandLabel: v.brandLabel,
orgName: v.orgName,
})),
);
});
};
}
// Inventory keeps its existing aggregate visibility; vehicle details retain user permissions.
const INVENTORY_TYPE_NAMES: Record<string, string> = { t4_5: '4.5T普货', t4_5c: '4.5T冷链', t18: '18T', t49: '49T', trailer: '挂车', other: '其他' };