www:新增问题反馈功能;dashboard:适配问题反馈功能,可以在后台系统和用户沟通
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user