www:新增问题反馈功能;dashboard:适配问题反馈功能,可以在后台系统和用户沟通

This commit is contained in:
eafonyang
2026-08-20 16:42:01 +08:00
parent 5ae979f7e1
commit 3702ad4163
31 changed files with 1956 additions and 11 deletions
+2
View File
@@ -22,6 +22,7 @@ import {
wwwI18nRouter,
wwwContactRouter,
wwwPageSeoRouter,
wwwSupportFeedbackRouter,
} from './www/index.js';
const routes: Router[] = [
@@ -51,6 +52,7 @@ const routes: Router[] = [
wwwI18nRouter,
wwwContactRouter,
wwwPageSeoRouter,
wwwSupportFeedbackRouter,
];
export default routes;
+258 -1
View File
@@ -1,5 +1,9 @@
import { Router, type Request, type Response } from 'express';
import { eq, and, desc, count, like, isNull, inArray } from 'drizzle-orm';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { db } from '../config/database.js';
import {
wwwSiteSettings,
@@ -24,16 +28,23 @@ import {
wwwFormSettings,
wwwFormFields,
wwwFormSubmissions,
wwwSupportFeedbacks,
} from '../schemas/index.js';
import logger from '../config/logger.js';
import { ApiResponse } from '../utils/response.js';
import {
getFeedbackByNo,
verifyFeedbackEmail,
listFeedbackMessagesForDisplay,
insertUserMessage,
} from '../services/supportFeedbackMessages.js';
/**
* 官网前台公开只读接口(供 Nuxt 官网消费)
*
* 与 dashboard 管理端接口(/api/www/*,需 JWT 鉴权)完全隔离:
* - 无需登录,公开访问
* - 仅提供 GET 只读能力
* - GET 只读为主,另含联系表单 / 问题反馈等公开提交接口
* - 仅返回对前台可见的内容(is_visible=1、已发布等)
*/
const router = Router();
@@ -384,4 +395,250 @@ router.post('/api/site/contact/submit', async (req: Request, res: Response) => {
}
});
// ===== 问题反馈:日志上传存储(公开) =====
const FEEDBACK_LOG_DIR = path.resolve(process.cwd(), 'uploads/support-logs');
const feedbackUpload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => {
fs.mkdirSync(FEEDBACK_LOG_DIR, { recursive: true });
cb(null, FEEDBACK_LOG_DIR);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).slice(0, 20);
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
},
}),
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
});
const FEEDBACK_EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const FEEDBACK_NO_RE = /^FB\d{8}[0-9A-Z]{6}$/;
function formatFeedbackNoDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}${m}${day}`;
}
function generateFeedbackNo(): string {
const rand = crypto.randomBytes(3).toString('hex').toUpperCase();
return `FB${formatFeedbackNoDate(new Date())}${rand}`;
}
const feedbackSelectFields = {
id: wwwSupportFeedbacks.id,
feedbackNo: wwwSupportFeedbacks.feedbackNo,
productId: wwwSupportFeedbacks.productId,
productNameZh: wwwSupportFeedbacks.productNameZh,
productNameEn: wwwSupportFeedbacks.productNameEn,
description: wwwSupportFeedbacks.description,
status: wwwSupportFeedbacks.status,
logFileName: wwwSupportFeedbacks.logFileName,
logFilePath: wwwSupportFeedbacks.logFilePath,
logFileSize: wwwSupportFeedbacks.logFileSize,
createdAt: wwwSupportFeedbacks.createdAt,
};
// ===== 问题反馈:提交(公开,multipart/form-data =====
// POST /api/site/support/feedback
// fields: product_id / description / email / log_file(可选)
router.post('/api/site/support/feedback', feedbackUpload.single('log_file'), async (req: Request, res: Response) => {
try {
const b = req.body;
const productId = Number(b.product_id);
const description = String(b.description ?? '').trim();
const email = String(b.email ?? '').trim().slice(0, 100);
if (!Number.isInteger(productId) || productId <= 0) {
res.json(ApiResponse.error('请选择型号'));
return;
}
if (!description || description.length > 2000) {
res.json(ApiResponse.error('问题描述长度需在 1-2000 字之间'));
return;
}
if (!FEEDBACK_EMAIL_RE.test(email)) {
res.json(ApiResponse.error('请输入有效的邮箱地址'));
return;
}
// 型号必须为产品列表中可见的产品
const [product] = await db.select().from(wwwProducts)
.where(and(eq(wwwProducts.id, productId), eq(wwwProducts.isVisible, 1)));
if (!product) {
res.json(ApiResponse.error('所选型号不存在'));
return;
}
// 防滥用:同一 IP 60 秒冷却
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip || '';
if (ip) {
const [last] = await db.select().from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.visitorIp, ip))
.orderBy(desc(wwwSupportFeedbacks.createdAt))
.limit(1);
if (last) {
const elapsed = (Date.now() - new Date(last.createdAt).getTime()) / 1000;
if (elapsed < 60) {
if (req.file) fs.unlink(req.file.path, () => {});
res.json(ApiResponse.error(`提交过于频繁,请 ${Math.ceil(60 - elapsed)} 秒后再试`));
return;
}
}
}
const file = req.file;
// 生成唯一反馈编号(冲突时重试)
let feedbackNo = generateFeedbackNo();
for (let attempt = 0; attempt < 5; attempt++) {
try {
await db.insert(wwwSupportFeedbacks).values({
feedbackNo,
productId,
productNameZh: product.nameZh,
productNameEn: product.nameEn,
description,
email,
logFilePath: file ? `/uploads/support-logs/${path.basename(file.path)}` : '',
logFileName: file ? file.originalname.slice(0, 255) : '',
logFileSize: file ? file.size : 0,
visitorIp: ip,
});
break;
} catch (insertErr: unknown) {
const msg = insertErr instanceof Error ? insertErr.message : String(insertErr);
if (msg.includes('Duplicate') && msg.includes('feedback_no') && attempt < 4) {
feedbackNo = generateFeedbackNo();
continue;
}
throw insertErr;
}
}
res.json(ApiResponse.success({ feedback_no: feedbackNo }, '反馈提交成功'));
} catch (e: unknown) {
if (e instanceof multer.MulterError && e.code === 'LIMIT_FILE_SIZE') {
res.json(ApiResponse.error('日志文件大小不能超过 20MB'));
return;
}
logger.error(`[site/support/feedback] POST error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('提交失败,请稍后重试'));
}
});
// ===== 问题反馈:按邮箱或编号查询(公开) =====
// GET /api/site/support/feedbacks?email=xxx
// GET /api/site/support/feedbacks?feedback_no=FB20260818A3F2B1
router.get('/api/site/support/feedbacks', async (req: Request, res: Response) => {
try {
const email = String(req.query.email ?? '').trim();
const feedbackNo = String(req.query.feedback_no ?? '').trim().toUpperCase();
if (!email && !feedbackNo) {
res.json(ApiResponse.error('请提供 email 或 feedback_no 参数'));
return;
}
if (email && feedbackNo) {
res.json(ApiResponse.error('email 与 feedback_no 不能同时传入'));
return;
}
if (feedbackNo) {
if (!FEEDBACK_NO_RE.test(feedbackNo)) {
res.json(ApiResponse.error('反馈编号格式不正确'));
return;
}
const rows = await db.select(feedbackSelectFields).from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.feedbackNo, feedbackNo))
.limit(1);
res.json(ApiResponse.success({ items: rows, total: rows.length }));
return;
}
const rows = await db.select(feedbackSelectFields).from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.email, email))
.orderBy(desc(wwwSupportFeedbacks.createdAt), desc(wwwSupportFeedbacks.id))
.limit(50);
res.json(ApiResponse.success({ items: rows, total: rows.length }));
} catch (e: unknown) {
logger.error(`[site/support/feedbacks] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取反馈列表失败'));
}
});
// ===== 问题反馈:对话消息(公开,需 feedback_no + email 校验) =====
// GET /api/site/support/feedbacks/:feedback_no/messages?email=xxx&since_id=
router.get('/api/site/support/feedbacks/:feedback_no/messages', async (req: Request, res: Response) => {
try {
const feedbackNo = String(req.params.feedback_no ?? '').trim().toUpperCase();
const email = String(req.query.email ?? '').trim();
const sinceId = parseInt(req.query.since_id as string || '0', 10);
if (!FEEDBACK_NO_RE.test(feedbackNo)) {
res.json(ApiResponse.error('反馈编号格式不正确'));
return;
}
if (!FEEDBACK_EMAIL_RE.test(email)) {
res.json(ApiResponse.error('请输入有效的邮箱地址'));
return;
}
const feedback = await getFeedbackByNo(feedbackNo);
if (!feedback || !verifyFeedbackEmail(feedback, email)) {
res.json(ApiResponse.error('反馈不存在或邮箱不匹配'));
return;
}
const messages = await listFeedbackMessagesForDisplay(feedback.id, sinceId > 0 ? sinceId : undefined);
res.json(ApiResponse.success({ items: messages, total: messages.length, status: feedback.status }));
} catch (e: unknown) {
logger.error(`[site/support/feedbacks/:feedback_no/messages] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取对话消息失败'));
}
});
// POST /api/site/support/feedbacks/:feedback_no/messages
router.post('/api/site/support/feedbacks/:feedback_no/messages', async (req: Request, res: Response) => {
try {
const feedbackNo = String(req.params.feedback_no ?? '').trim().toUpperCase();
const email = String(req.body.email ?? '').trim();
const content = String(req.body.content ?? '').trim();
if (!FEEDBACK_NO_RE.test(feedbackNo)) {
res.json(ApiResponse.error('反馈编号格式不正确'));
return;
}
if (!FEEDBACK_EMAIL_RE.test(email)) {
res.json(ApiResponse.error('请输入有效的邮箱地址'));
return;
}
if (!content || content.length > 2000) {
res.json(ApiResponse.error('消息长度需在 1-2000 字之间'));
return;
}
const feedback = await getFeedbackByNo(feedbackNo);
if (!feedback || !verifyFeedbackEmail(feedback, email)) {
res.json(ApiResponse.error('反馈不存在或邮箱不匹配'));
return;
}
if (feedback.status === 'resolved') {
res.json(ApiResponse.error('该问题已解决,无法继续发送消息'));
return;
}
await insertUserMessage(feedback.id, content);
const messages = await listFeedbackMessagesForDisplay(feedback.id);
res.json(ApiResponse.success({ items: messages, total: messages.length }, '发送成功'));
} catch (e: unknown) {
logger.error(`[site/support/feedbacks/:feedback_no/messages] POST error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('发送失败,请稍后重试'));
}
});
export default router;
@@ -11,3 +11,4 @@ export { default as wwwMediaRouter } from './media.js';
export { default as wwwI18nRouter } from './i18n.js';
export { default as wwwContactRouter } from './contact.js';
export { default as wwwPageSeoRouter } from './pageSeo.js';
export { default as wwwSupportFeedbackRouter } from './supportFeedback.js';
@@ -0,0 +1,197 @@
import { Router, type Request, type Response } from 'express';
import { eq, and, count, desc, gte, lte, like } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { wwwSupportFeedbacks } from '../../schemas/index.js';
import logger from '../../config/logger.js';
import { ApiResponse } from '../../utils/response.js';
import { authMiddleware } from '../../middleware/auth.js';
import {
getFeedbackById,
listFeedbackMessagesForDisplay,
insertStaffMessage,
updateFeedbackStatus,
} from '../../services/supportFeedbackMessages.js';
const router = Router();
router.use(authMiddleware);
const VALID_STATUSES = new Set(['pending', 'processing', 'resolved']);
const MESSAGE_MAX_LEN = 2000;
const feedbackListFields = {
id: wwwSupportFeedbacks.id,
feedbackNo: wwwSupportFeedbacks.feedbackNo,
productId: wwwSupportFeedbacks.productId,
productNameZh: wwwSupportFeedbacks.productNameZh,
productNameEn: wwwSupportFeedbacks.productNameEn,
description: wwwSupportFeedbacks.description,
email: wwwSupportFeedbacks.email,
status: wwwSupportFeedbacks.status,
logFileName: wwwSupportFeedbacks.logFileName,
logFilePath: wwwSupportFeedbacks.logFilePath,
logFileSize: wwwSupportFeedbacks.logFileSize,
visitorIp: wwwSupportFeedbacks.visitorIp,
createdAt: wwwSupportFeedbacks.createdAt,
updatedAt: wwwSupportFeedbacks.updatedAt,
};
// GET /api/www/support/feedbacks
router.get('/api/www/support/feedbacks', async (req: Request, res: Response) => {
try {
const skip = parseInt(req.query.skip as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 100);
const status = String(req.query.status ?? '').trim();
const email = String(req.query.email ?? '').trim();
const feedbackNo = String(req.query.feedback_no ?? '').trim();
const startDate = req.query.start_date as string | undefined;
const endDate = req.query.end_date as string | undefined;
const conditions = [];
if (status && VALID_STATUSES.has(status)) {
conditions.push(eq(wwwSupportFeedbacks.status, status));
}
if (email) {
conditions.push(like(wwwSupportFeedbacks.email, `%${email}%`));
}
if (feedbackNo) {
conditions.push(like(wwwSupportFeedbacks.feedbackNo, `%${feedbackNo.toUpperCase()}%`));
}
if (startDate) {
conditions.push(gte(wwwSupportFeedbacks.createdAt, new Date(startDate)));
}
if (endDate) {
conditions.push(lte(wwwSupportFeedbacks.createdAt, new Date(`${endDate} 23:59:59`)));
}
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
const [totalResult] = await db.select({ value: count() }).from(wwwSupportFeedbacks).where(whereClause);
const total = totalResult?.value ?? 0;
const rows = await db.select(feedbackListFields).from(wwwSupportFeedbacks)
.where(whereClause)
.orderBy(desc(wwwSupportFeedbacks.updatedAt), desc(wwwSupportFeedbacks.id))
.offset(skip)
.limit(limit);
res.json(ApiResponse.success({ items: rows, total, skip, limit }));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取反馈列表失败'));
}
});
// GET /api/www/support/feedbacks/:id
router.get('/api/www/support/feedbacks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
res.json(ApiResponse.success(feedback));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取反馈详情失败'));
}
});
// PATCH /api/www/support/feedbacks/:id
router.patch('/api/www/support/feedbacks/:id', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const status = String(req.body.status ?? '').trim();
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
if (!VALID_STATUSES.has(status)) {
res.json(ApiResponse.error('无效的状态值'));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
await updateFeedbackStatus(id, status);
const updated = await getFeedbackById(id);
res.json(ApiResponse.success(updated, '状态已更新'));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id] PATCH error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('更新状态失败'));
}
});
// GET /api/www/support/feedbacks/:id/messages
router.get('/api/www/support/feedbacks/:id/messages', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const sinceId = parseInt(req.query.since_id as string || '0', 10);
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
const messages = await listFeedbackMessagesForDisplay(id, sinceId > 0 ? sinceId : undefined);
res.json(ApiResponse.success({ items: messages, total: messages.length }));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id/messages] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取对话消息失败'));
}
});
// POST /api/www/support/feedbacks/:id/messages
router.post('/api/www/support/feedbacks/:id/messages', async (req: Request, res: Response) => {
try {
const id = Number(req.params.id);
const content = String(req.body.content ?? '').trim();
if (!Number.isInteger(id) || id <= 0) {
res.json(ApiResponse.error('无效的 ID'));
return;
}
if (!content || content.length > MESSAGE_MAX_LEN) {
res.json(ApiResponse.error(`消息长度需在 1-${MESSAGE_MAX_LEN} 字之间`));
return;
}
const feedback = await getFeedbackById(id);
if (!feedback) {
res.json(ApiResponse.noData('反馈不存在'));
return;
}
if (feedback.status === 'resolved') {
res.json(ApiResponse.error('该问题已解决,无法继续发送消息'));
return;
}
const user = req.user!;
await insertStaffMessage(id, content, { id: user.id, username: user.username });
const messages = await listFeedbackMessagesForDisplay(id);
res.json(ApiResponse.success({ items: messages, total: messages.length }, '回复成功'));
} catch (e: unknown) {
logger.error(`[www/support/feedbacks/:id/messages] POST error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('发送回复失败'));
}
});
export default router;
@@ -10,3 +10,5 @@ export * from './media.js';
export * from './i18n.js';
export * from './pages.js';
export * from './contact.js';
export * from './supportFeedback.js';
export * from './supportFeedbackMessage.js';
@@ -0,0 +1,19 @@
import { mysqlTable, bigint, varchar, text, int, datetime } from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm';
export const wwwSupportFeedbacks = mysqlTable('www_support_feedbacks', {
id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(),
feedbackNo: varchar('feedback_no', { length: 20 }).notNull().default(''),
productId: bigint('product_id', { mode: 'number', unsigned: true }).notNull().default(0),
productNameZh: varchar('product_name_zh', { length: 100 }).notNull().default(''),
productNameEn: varchar('product_name_en', { length: 100 }).notNull().default(''),
description: text('description').notNull(),
email: varchar('email', { length: 100 }).notNull(),
logFilePath: varchar('log_file_path', { length: 255 }).notNull().default(''),
logFileName: varchar('log_file_name', { length: 255 }).notNull().default(''),
logFileSize: int('log_file_size', { unsigned: true }).notNull().default(0),
status: varchar('status', { length: 20 }).notNull().default('pending'),
visitorIp: varchar('visitor_ip', { length: 45 }).notNull().default(''),
createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -0,0 +1,12 @@
import { mysqlTable, bigint, varchar, text, datetime } from 'drizzle-orm/mysql-core';
import { sql } from 'drizzle-orm';
export const wwwSupportFeedbackMessages = mysqlTable('www_support_feedback_messages', {
id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(),
feedbackId: bigint('feedback_id', { mode: 'number', unsigned: true }).notNull(),
senderType: varchar('sender_type', { length: 10 }).notNull(),
senderId: bigint('sender_id', { mode: 'number', unsigned: true }).notNull().default(0),
senderName: varchar('sender_name', { length: 100 }).notNull().default(''),
content: text('content').notNull(),
createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
});
@@ -0,0 +1,105 @@
import { eq, and, gt, desc, asc } from 'drizzle-orm';
import { db } from '../config/database.js';
import { wwwSupportFeedbacks, wwwSupportFeedbackMessages } from '../schemas/index.js';
export const messageSelectFields = {
id: wwwSupportFeedbackMessages.id,
feedbackId: wwwSupportFeedbackMessages.feedbackId,
senderType: wwwSupportFeedbackMessages.senderType,
senderId: wwwSupportFeedbackMessages.senderId,
senderName: wwwSupportFeedbackMessages.senderName,
content: wwwSupportFeedbackMessages.content,
createdAt: wwwSupportFeedbackMessages.createdAt,
};
export async function getFeedbackByNo(feedbackNo: string) {
const [row] = await db.select().from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.feedbackNo, feedbackNo))
.limit(1);
return row ?? null;
}
export async function getFeedbackById(id: number) {
const [row] = await db.select().from(wwwSupportFeedbacks)
.where(eq(wwwSupportFeedbacks.id, id))
.limit(1);
return row ?? null;
}
export function verifyFeedbackEmail(feedback: { email: string }, email: string): boolean {
return feedback.email.trim().toLowerCase() === email.trim().toLowerCase();
}
export async function listFeedbackMessages(feedbackId: number, sinceId?: number) {
const conditions = [eq(wwwSupportFeedbackMessages.feedbackId, feedbackId)];
if (sinceId && sinceId > 0) {
conditions.push(gt(wwwSupportFeedbackMessages.id, sinceId));
}
const rows = await db.select(messageSelectFields).from(wwwSupportFeedbackMessages)
.where(and(...conditions))
.orderBy(asc(wwwSupportFeedbackMessages.id))
.limit(sinceId ? 50 : 500);
return rows;
}
async function touchFeedbackUpdatedAt(feedbackId: number) {
await db.update(wwwSupportFeedbacks)
.set({ updatedAt: new Date() })
.where(eq(wwwSupportFeedbacks.id, feedbackId));
}
export async function insertUserMessage(feedbackId: number, content: string) {
await db.insert(wwwSupportFeedbackMessages).values({
feedbackId,
senderType: 'user',
senderId: 0,
senderName: '',
content,
});
await touchFeedbackUpdatedAt(feedbackId);
}
export async function insertStaffMessage(
feedbackId: number,
content: string,
staff: { id: number; username: string },
) {
await db.insert(wwwSupportFeedbackMessages).values({
feedbackId,
senderType: 'staff',
senderId: staff.id,
senderName: staff.username.slice(0, 100),
content,
});
const feedback = await getFeedbackById(feedbackId);
if (feedback && feedback.status === 'pending') {
await db.update(wwwSupportFeedbacks)
.set({ status: 'processing', updatedAt: new Date() })
.where(eq(wwwSupportFeedbacks.id, feedbackId));
} else {
await touchFeedbackUpdatedAt(feedbackId);
}
}
export async function updateFeedbackStatus(feedbackId: number, status: string) {
await db.update(wwwSupportFeedbacks)
.set({ status, updatedAt: new Date() })
.where(eq(wwwSupportFeedbacks.id, feedbackId));
}
/** 全量拉取时按 id 倒序取最近消息再正序返回 */
export async function listFeedbackMessagesForDisplay(feedbackId: number, sinceId?: number) {
if (sinceId && sinceId > 0) {
return listFeedbackMessages(feedbackId, sinceId);
}
const rows = await db.select(messageSelectFields).from(wwwSupportFeedbackMessages)
.where(eq(wwwSupportFeedbackMessages.feedbackId, feedbackId))
.orderBy(desc(wwwSupportFeedbackMessages.id))
.limit(500);
return rows.reverse();
}