Files
memind/user-feedback.mjs
T
john 18ea4f82fd feat(feedback): 新增用户反馈提交、分页列表与语音描述
支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 20:49:15 +08:00

236 lines
7.7 KiB
JavaScript

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 });
},
};
}