import crypto from 'node:crypto'; export const FEEDBACK_TYPES = new Set(['bug', 'feature', 'other']); export const FEEDBACK_STATUSES = new Set(['pending', 'reviewing', 'resolved', 'closed']); export const FEEDBACK_MAX_IMAGES = 5; export const FEEDBACK_MAX_TITLE = 120; export const FEEDBACK_MAX_DESCRIPTION = 5000; export const FEEDBACK_MAX_CONTACT = 120; export const FEEDBACK_MAX_IMAGE_BYTES = Math.floor(1.5 * 1024 * 1024); function feedbackError(message, code = 'invalid_input') { const err = new Error(message); err.code = code; return err; } function normalizeType(value) { const type = String(value ?? '').trim().toLowerCase(); if (!FEEDBACK_TYPES.has(type)) { throw feedbackError('请选择反馈类型'); } return type; } function normalizeImages(images) { if (!Array.isArray(images)) return []; if (images.length > FEEDBACK_MAX_IMAGES) { throw feedbackError(`最多上传 ${FEEDBACK_MAX_IMAGES} 张图片`); } return images.map((item, index) => { const mimeType = String(item?.mimeType ?? item?.mime_type ?? '').trim().toLowerCase(); const dataBase64 = String(item?.dataBase64 ?? item?.data_base64 ?? '').trim(); const filename = String(item?.filename ?? item?.name ?? `screenshot-${index + 1}.jpg`).trim(); if (!mimeType.startsWith('image/')) { throw feedbackError('仅支持图片附件'); } if (!dataBase64) { throw feedbackError('图片数据无效'); } let buffer; try { buffer = Buffer.from(dataBase64, 'base64'); } catch { throw feedbackError('图片数据无效'); } if (!buffer.length) { throw feedbackError('图片数据无效'); } if (buffer.length > FEEDBACK_MAX_IMAGE_BYTES) { throw feedbackError('单张图片不能超过 1.5MB'); } return { filename: filename.slice(0, 120), mimeType, sizeBytes: buffer.length, dataBase64, }; }); } function normalizeContext(context) { if (!context || typeof context !== 'object') return {}; const pageUrl = String(context.pageUrl ?? context.page_url ?? '').trim().slice(0, 500); const pagePath = String(context.pagePath ?? context.page_path ?? '').trim().slice(0, 300); const userAgent = String(context.userAgent ?? context.user_agent ?? '').trim().slice(0, 500); const viewport = String(context.viewport ?? '').trim().slice(0, 64); const sessionId = String(context.sessionId ?? context.session_id ?? '').trim().slice(0, 128); const appVersion = String(context.appVersion ?? context.app_version ?? '').trim().slice(0, 64); const normalized = {}; if (pageUrl) normalized.pageUrl = pageUrl; if (pagePath) normalized.pagePath = pagePath; if (userAgent) normalized.userAgent = userAgent; if (viewport) normalized.viewport = viewport; if (sessionId) normalized.sessionId = sessionId; if (appVersion) normalized.appVersion = appVersion; return normalized; } function rowToFeedback(row, { includeImages = true, includeContact = true } = {}) { if (!row) return null; let images = []; let context = {}; try { images = row.images_json ? JSON.parse(row.images_json) : []; } catch { images = []; } try { context = row.context_json ? JSON.parse(row.context_json) : {}; } catch { context = {}; } const item = { id: row.id, userId: row.user_id, type: row.type, title: row.title, description: row.description, status: row.status, context, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), submitterDisplayName: row.submitter_display_name ?? null, imageCount: images.length, }; if (includeContact) { item.contact = row.contact ?? null; } if (includeImages) { item.images = images; } return item; } export function createFeedbackService(pool) { return { async submit(userId, input = {}) { const type = normalizeType(input.type); const title = String(input.title ?? '').trim(); const description = String(input.description ?? '').trim(); const contact = String(input.contact ?? '').trim().slice(0, FEEDBACK_MAX_CONTACT); if (!title) throw feedbackError('请填写标题'); if (title.length > FEEDBACK_MAX_TITLE) throw feedbackError(`标题不能超过 ${FEEDBACK_MAX_TITLE} 字`); if (!description) throw feedbackError('请填写详细描述'); if (description.length > FEEDBACK_MAX_DESCRIPTION) { throw feedbackError(`描述不能超过 ${FEEDBACK_MAX_DESCRIPTION} 字`); } const images = normalizeImages(input.images); const context = normalizeContext(input.context); const now = Date.now(); const id = crypto.randomUUID(); await pool.query( `INSERT INTO h5_user_feedback (id, user_id, type, title, description, contact, status, images_json, context_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, [ id, userId, type, title, description, contact || null, JSON.stringify(images), JSON.stringify(context), now, now, ], ); return { id, type, title, status: 'pending', createdAt: now, }; }, async listForUser(userId, { limit = 20 } = {}) { const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 50); const [rows] = await pool.query( `SELECT f.id, f.user_id, f.type, f.title, f.description, f.contact, f.status, f.images_json, f.context_json, f.created_at, f.updated_at, u.display_name AS submitter_display_name FROM h5_user_feedback f JOIN h5_users u ON u.id = f.user_id WHERE f.user_id = ? ORDER BY f.created_at DESC LIMIT ?`, [userId, safeLimit], ); return rows.map((row) => rowToFeedback(row)); }, async listAll({ page = 1, limit = 10 } = {}) { const safeLimit = Math.min(Math.max(Number(limit) || 10, 1), 10); const safePage = Math.max(Number(page) || 1, 1); const offset = (safePage - 1) * safeLimit; const [[countRow]] = await pool.query(`SELECT COUNT(*) AS total FROM h5_user_feedback`); const total = Number(countRow?.total ?? 0); const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); const [rows] = await pool.query( `SELECT f.id, f.user_id, f.type, f.title, f.description, f.status, f.images_json, f.context_json, f.created_at, f.updated_at, u.display_name AS submitter_display_name FROM h5_user_feedback f JOIN h5_users u ON u.id = f.user_id ORDER BY f.created_at DESC LIMIT ? OFFSET ?`, [safeLimit, offset], ); return { items: rows.map((row) => rowToFeedback(row, { includeImages: false, includeContact: false }), ), page: safePage, pageSize: safeLimit, total, totalPages, }; }, async getById(feedbackId, viewerUserId) { const id = String(feedbackId ?? '').trim(); if (!id) throw feedbackError('反馈不存在', 'feedback_not_found'); const [rows] = await pool.query( `SELECT f.id, f.user_id, f.type, f.title, f.description, f.contact, f.status, f.images_json, f.context_json, f.created_at, f.updated_at, u.display_name AS submitter_display_name FROM h5_user_feedback f JOIN h5_users u ON u.id = f.user_id WHERE f.id = ? LIMIT 1`, [id], ); const row = rows[0]; if (!row) throw feedbackError('反馈不存在', 'feedback_not_found'); const isOwner = viewerUserId && row.user_id === viewerUserId; return rowToFeedback(row, { includeImages: true, includeContact: isOwner }); }, }; }