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; 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 do not change business data. AI previews suppress gap feedback but retain attributed question audit records. 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(); }; }