import { Hono } from 'hono'; import pool from '../../db/mysql.js'; import { ensureFeedbackTable } from '../../db/schema/feedback.js'; import type { AuthUser } from '../../auth/types.js'; import { canManageFeedback } from '../../auth/types.js'; import { uploadFeedbackImage } from './oss.js'; import { insertFeedback, listFeedbackForAdmin, listFeedbackForUser, updateFeedback, type Database, } from './repository.js'; const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']); const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']); export interface FeedbackDependencies { db: Database; /** 建表(只读模式下由 schema 层自行跳过)。测试注入空实现。 */ ensureTable: () => Promise; /** 截图上传实现,测试可注入。 */ uploadImage?: typeof uploadFeedbackImage; } function currentUser(c: { get?: (k: string) => unknown }): AuthUser | undefined { return c.get?.('user') as AuthUser | undefined; } export function registerFeedbackRoutes(app: Hono, deps: FeedbackDependencies): void { const { db, ensureTable } = deps; const uploadImage = deps.uploadImage ?? uploadFeedbackImage; // POST /api/feedback/submit — 提交反馈 app.post('/submit', async (c) => { await ensureTable(); const body = await c.req.json().catch(() => ({})) as { type?: string; module?: string | null; content?: string; contact?: string | null; userAgent?: string; screenshots?: string[]; }; const type = (body.type || '').trim(); const content = (body.content || '').trim(); if (!VALID_TYPES.has(type)) { return c.json({ ok: false, message: '类型不合法' }, 400); } if (!content || content.length > 2000) { return c.json({ ok: false, message: '内容长度需在 1-2000 字之间' }, 400); } const user = currentUser(c); const moduleVal = (body.module || '').slice(0, 64) || null; const contact = (body.contact || '').slice(0, 200) || null; const userAgent = (body.userAgent || '').slice(0, 512) || null; const screenshots = Array.isArray(body.screenshots) ? body.screenshots.filter(s => typeof s === 'string' && /^https?:\/\//.test(s)).slice(0, 6) : []; const id = await insertFeedback(db, { type, module: moduleVal, content, contact, screenshotsJson: JSON.stringify(screenshots), userId: user?.userId || null, userName: user?.userName || null, userAgent, }); return c.json({ ok: true, id }); }); // POST /api/feedback/upload — 单张截图上传(multipart/form-data, field=file) app.post('/upload', async (c) => { const form = await c.req.formData(); const file = form.get('file'); if (!(file instanceof File)) { return c.json({ ok: false, message: '未上传文件' }, 400); } const mime = file.type || 'image/png'; if (!ALLOWED_MIME.has(mime)) { return c.json({ ok: false, message: `不支持的文件类型:${mime}` }, 400); } if (file.size > MAX_IMAGE_SIZE) { return c.json({ ok: false, message: `图片过大(${(file.size / 1024 / 1024).toFixed(1)}MB)`}, 400); } const buf = Buffer.from(await file.arrayBuffer()); try { const url = await uploadImage(file.name || 'screenshot.png', buf, mime); return c.json({ ok: true, url }); } catch (e) { console.error('feedback upload error:', e); return c.json({ ok: false, message: e instanceof Error ? e.message : '上传失败' }, 500); } }); // GET /api/feedback/mine — 当前用户的反馈历史 app.get('/mine', async (c) => { await ensureTable(); const user = currentUser(c); if (!user?.userId) return c.json({ items: [] }); return c.json({ items: await listFeedbackForUser(db, user.userId) }); }); // GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限) app.get('/list', async (c) => { await ensureTable(); const user = currentUser(c); if (!canManageFeedback(user?.roles)) { return c.json({ ok: false, message: '无权限' }, 403); } const limit = Math.min(500, Math.max(1, Number(c.req.query('limit')) || 100)); const rawStatus = c.req.query('status') || ''; const status = VALID_STATUS.has(rawStatus) ? rawStatus : null; return c.json({ items: await listFeedbackForAdmin(db, { status, limit }) }); }); // PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限) app.patch('/:id', async (c) => { await ensureTable(); const user = currentUser(c); if (!canManageFeedback(user?.roles)) { return c.json({ ok: false, message: '无权限' }, 403); } const id = Number(c.req.param('id')); if (!Number.isFinite(id) || id <= 0) return c.json({ ok: false, message: 'id 不合法' }, 400); const body = await c.req.json().catch(() => ({})) as { status?: string; reply?: string }; let status: string | undefined; if (body.status) { if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400); status = body.status; } const reply = typeof body.reply === 'string' ? body.reply.trim().slice(0, 2000) : undefined; const updated = await updateFeedback(db, id, { status, reply, replyUser: user?.userName || user?.userId || null, }); if (!updated) return c.json({ ok: false, message: '没有可更新的字段' }, 400); return c.json({ ok: true }); }); } /** 生产用路由器:绑定真实连接池与建表函数。 */ export function createFeedbackRouter(): Hono { const app = new Hono(); registerFeedbackRoutes(app, { db: pool, ensureTable: ensureFeedbackTable }); return app; } const app = createFeedbackRouter(); export default app;