feat(feedback): 新增用户反馈提交、分页列表与语音描述
支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user