import { Hono } from 'hono'; import type { ResultSetHeader, RowDataPacket } from 'mysql2'; import pool from '../../db.js'; import type { AuthUser } from '../../auth/types.js'; import { canManageFeedback } from '../../auth/types.js'; import { uploadFeedbackImage } from './oss.js'; const app = new Hono(); const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); const CREATE_TABLE_SQL = ` CREATE TABLE IF NOT EXISTS bi_user_feedback ( id BIGINT AUTO_INCREMENT PRIMARY KEY, type ENUM('dimension','bug','ux','other') NOT NULL DEFAULT 'other', module VARCHAR(64) NULL, content TEXT NOT NULL, contact VARCHAR(200) NULL, screenshots JSON NULL, user_id VARCHAR(64) NULL, user_name VARCHAR(128) NULL, user_agent VARCHAR(512) NULL, status ENUM('open','in_progress','done','rejected') NOT NULL DEFAULT 'open', reply_content TEXT NULL, reply_user VARCHAR(128) NULL, reply_at DATETIME NULL, created_at DATETIME NOT NULL, KEY idx_created_at (created_at), KEY idx_type (type), KEY idx_status (status), KEY idx_user_id (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `; let ensured = false; async function ensureTable(): Promise { if (ensured) return; await pool.query(CREATE_TABLE_SQL); // 兼容旧表:补齐缺失列 for (const alter of [ `ALTER TABLE bi_user_feedback ADD COLUMN screenshots JSON NULL AFTER contact`, `ALTER TABLE bi_user_feedback ADD COLUMN reply_content TEXT NULL AFTER status`, `ALTER TABLE bi_user_feedback ADD COLUMN reply_user VARCHAR(128) NULL AFTER reply_content`, `ALTER TABLE bi_user_feedback ADD COLUMN reply_at DATETIME NULL AFTER reply_user`, `ALTER TABLE bi_user_feedback ADD INDEX idx_user_id (user_id)`, ]) { try { await pool.query(alter); } catch { /* 已存在则忽略 */ } } ensured = true; } const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'rejected']); const VALID_TYPES = new Set(['dimension', 'bug', 'ux', 'other']); // 写入时间戳一律用东八区 CST,避免依赖 MySQL/容器时区设置 const CST_NOW = `DATE_ADD(UTC_TIMESTAMP(), INTERVAL 8 HOUR)`; 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 = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; 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 [r] = await pool.query( `INSERT INTO bi_user_feedback (type, module, content, contact, screenshots, user_id, user_name, user_agent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ${CST_NOW})`, [type, moduleVal, content, contact, JSON.stringify(screenshots), user?.userId || null, user?.userName || null, userAgent], ); return c.json({ ok: true, id: r.insertId }); }); // ========================================================= // 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 uploadFeedbackImage(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 = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; if (!user?.userId) return c.json({ items: [] }); const [rows] = await pool.query( `SELECT id, type, module, content, contact, screenshots, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE user_id = ? ORDER BY created_at DESC LIMIT 100`, [user.userId], ); return c.json({ items: rows }); }); // GET /api/feedback/list — 管理列表(仅 BI-ADMIN-FEEDBACK / 全量权限) app.get('/list', async (c) => { await ensureTable(); const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; 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 status = c.req.query('status') || ''; const where: string[] = ['1=1']; const params: (string | number)[] = []; if (VALID_STATUS.has(status)) { where.push('status = ?'); params.push(status); } const [rows] = await pool.query( `SELECT id, type, module, content, contact, screenshots, user_id, user_name, status, reply_content, reply_user, reply_at, created_at FROM bi_user_feedback WHERE ${where.join(' AND ')} ORDER BY created_at DESC LIMIT ?`, [...params, limit], ); return c.json({ items: rows }); }); // PATCH /api/feedback/:id — 管理:更新状态与回复(仅 BI-ADMIN-FEEDBACK / 全量权限) app.patch('/:id', async (c) => { await ensureTable(); const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; 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 }; const fields: string[] = []; const params: (string | number | null)[] = []; if (body.status) { if (!VALID_STATUS.has(body.status)) return c.json({ ok: false, message: '状态不合法' }, 400); fields.push('status = ?'); params.push(body.status); } if (typeof body.reply === 'string') { const reply = body.reply.trim().slice(0, 2000); fields.push('reply_content = ?', 'reply_user = ?', `reply_at = ${CST_NOW}`); const user = (c as { get?: (k: string) => unknown }).get?.('user') as AuthUser | undefined; params.push(reply || null, user?.userName || user?.userId || null); } if (fields.length === 0) return c.json({ ok: false, message: '没有可更新的字段' }, 400); params.push(id); await pool.query(`UPDATE bi_user_feedback SET ${fields.join(', ')} WHERE id = ?`, params); return c.json({ ok: true }); }); export default app;