18ea4f82fd
支持 Bug/需求提交、截图与聊天同款语音输入,全站反馈分页浏览与详情页,并从聊天与空间页提供入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
236 lines
7.7 KiB
JavaScript
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 });
|
|
},
|
|
};
|
|
}
|