|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import { Router, type Request, type Response } from 'express';
|
|
|
|
|
import { eq, and } from 'drizzle-orm';
|
|
|
|
|
import { eq, and, desc, count, like, isNull, inArray } from 'drizzle-orm';
|
|
|
|
|
import { db } from '../config/database.js';
|
|
|
|
|
import {
|
|
|
|
|
wwwSiteSettings,
|
|
|
|
@@ -14,6 +14,16 @@ import {
|
|
|
|
|
wwwI18nEntries,
|
|
|
|
|
wwwProductSeries,
|
|
|
|
|
wwwProducts,
|
|
|
|
|
wwwNewsArticles,
|
|
|
|
|
wwwNewsRecommendations,
|
|
|
|
|
wwwProductListSettings,
|
|
|
|
|
wwwNewsListSettings,
|
|
|
|
|
wwwAboutSettings,
|
|
|
|
|
wwwSupportSettings,
|
|
|
|
|
wwwPageSeo,
|
|
|
|
|
wwwFormSettings,
|
|
|
|
|
wwwFormFields,
|
|
|
|
|
wwwFormSubmissions,
|
|
|
|
|
} from '../schemas/index.js';
|
|
|
|
|
import logger from '../config/logger.js';
|
|
|
|
|
import { ApiResponse } from '../utils/response.js';
|
|
|
|
@@ -151,4 +161,227 @@ router.get('/api/site/products', async (_req: Request, res: Response) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 新闻:已发布文章列表(供新闻列表页使用) =====
|
|
|
|
|
// GET /api/site/news?skip=0&limit=9
|
|
|
|
|
router.get('/api/site/news', 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 || '9', 10), 100);
|
|
|
|
|
const title = req.query.title as string | undefined;
|
|
|
|
|
|
|
|
|
|
const conditions = [eq(wwwNewsArticles.status, 'published')];
|
|
|
|
|
if (title) conditions.push(like(wwwNewsArticles.titleZh, `%${title}%`));
|
|
|
|
|
const whereClause = and(...conditions);
|
|
|
|
|
|
|
|
|
|
const [totalResult] = await db.select({ value: count() }).from(wwwNewsArticles).where(whereClause);
|
|
|
|
|
const total = totalResult?.value ?? 0;
|
|
|
|
|
|
|
|
|
|
const rows = await db.select({
|
|
|
|
|
id: wwwNewsArticles.id,
|
|
|
|
|
titleZh: wwwNewsArticles.titleZh,
|
|
|
|
|
titleEn: wwwNewsArticles.titleEn,
|
|
|
|
|
slug: wwwNewsArticles.slug,
|
|
|
|
|
summaryZh: wwwNewsArticles.summaryZh,
|
|
|
|
|
summaryEn: wwwNewsArticles.summaryEn,
|
|
|
|
|
coverUrl: wwwNewsArticles.coverUrl,
|
|
|
|
|
publishedAt: wwwNewsArticles.publishedAt,
|
|
|
|
|
createdAt: wwwNewsArticles.createdAt,
|
|
|
|
|
}).from(wwwNewsArticles)
|
|
|
|
|
.where(whereClause)
|
|
|
|
|
.orderBy(desc(wwwNewsArticles.publishedAt), desc(wwwNewsArticles.createdAt))
|
|
|
|
|
.offset(skip)
|
|
|
|
|
.limit(limit);
|
|
|
|
|
|
|
|
|
|
res.json(ApiResponse.success({ items: rows, total, skip, limit }));
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
logger.error(`[site/news] GET error: ${e instanceof Error ? e.message : e}`);
|
|
|
|
|
res.json(ApiResponse.error('获取新闻列表失败'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 新闻详情:按 slug 查询(含内容与推荐文章) =====
|
|
|
|
|
// GET /api/site/news/:slug
|
|
|
|
|
router.get('/api/site/news/:slug', async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const slug = String(req.params.slug);
|
|
|
|
|
const [row] = await db.select().from(wwwNewsArticles)
|
|
|
|
|
.where(and(eq(wwwNewsArticles.slug, slug), eq(wwwNewsArticles.status, 'published')));
|
|
|
|
|
if (!row) {
|
|
|
|
|
res.json(ApiResponse.noData('文章不存在'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 推荐文章(关联表按 sort_order 排序)
|
|
|
|
|
const recLinks = await db.select().from(wwwNewsRecommendations)
|
|
|
|
|
.where(eq(wwwNewsRecommendations.articleId, row.id))
|
|
|
|
|
.orderBy(wwwNewsRecommendations.sortOrder);
|
|
|
|
|
let recommendations: unknown[] = [];
|
|
|
|
|
if (recLinks.length > 0) {
|
|
|
|
|
const recIds = recLinks.map((r) => r.recommendedId);
|
|
|
|
|
const recRows = await db.select({
|
|
|
|
|
id: wwwNewsArticles.id,
|
|
|
|
|
titleZh: wwwNewsArticles.titleZh,
|
|
|
|
|
titleEn: wwwNewsArticles.titleEn,
|
|
|
|
|
slug: wwwNewsArticles.slug,
|
|
|
|
|
summaryZh: wwwNewsArticles.summaryZh,
|
|
|
|
|
summaryEn: wwwNewsArticles.summaryEn,
|
|
|
|
|
coverUrl: wwwNewsArticles.coverUrl,
|
|
|
|
|
publishedAt: wwwNewsArticles.publishedAt,
|
|
|
|
|
}).from(wwwNewsArticles)
|
|
|
|
|
.where(and(eq(wwwNewsArticles.status, 'published'), inArray(wwwNewsArticles.id, recIds)));
|
|
|
|
|
recommendations = recLinks
|
|
|
|
|
.map((r) => recRows.find((a) => a.id === r.recommendedId))
|
|
|
|
|
.filter((a): a is NonNullable<typeof a> => Boolean(a));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json(ApiResponse.success({ ...row, recommendations }));
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
logger.error(`[site/news/:slug] GET error: ${e instanceof Error ? e.message : e}`);
|
|
|
|
|
res.json(ApiResponse.error('获取文章详情失败'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 页面配置:产品列表 / 新闻列表 / 关于 / 支持 =====
|
|
|
|
|
// GET /api/site/page?type=product-list|news-list|about|support
|
|
|
|
|
router.get('/api/site/page', async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const type = req.query.type as string;
|
|
|
|
|
let row: unknown = null;
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'product-list': {
|
|
|
|
|
const [r] = await db.select().from(wwwProductListSettings).limit(1);
|
|
|
|
|
row = r ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'news-list': {
|
|
|
|
|
const [r] = await db.select().from(wwwNewsListSettings).limit(1);
|
|
|
|
|
row = r ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'about': {
|
|
|
|
|
const [r] = await db.select().from(wwwAboutSettings).limit(1);
|
|
|
|
|
row = r ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case 'support': {
|
|
|
|
|
const [r] = await db.select().from(wwwSupportSettings).limit(1);
|
|
|
|
|
row = r ?? null;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
res.json(ApiResponse.error('type 参数不合法'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
res.json(ApiResponse.success(row));
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
logger.error(`[site/page] GET error: ${e instanceof Error ? e.message : e}`);
|
|
|
|
|
res.json(ApiResponse.error('获取页面配置失败'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 页面 SEO:按 page_type + entity_id 查询 =====
|
|
|
|
|
// GET /api/site/page-seo?page_type=home&entity_id=1
|
|
|
|
|
router.get('/api/site/page-seo', async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const pageType = req.query.page_type as string;
|
|
|
|
|
const entityId = req.query.entity_id ? Number(req.query.entity_id) : undefined;
|
|
|
|
|
if (!pageType) {
|
|
|
|
|
res.json(ApiResponse.error('page_type 参数必填'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const conditions = [eq(wwwPageSeo.pageType, pageType)];
|
|
|
|
|
if (entityId) {
|
|
|
|
|
conditions.push(eq(wwwPageSeo.entityId, entityId));
|
|
|
|
|
} else {
|
|
|
|
|
conditions.push(isNull(wwwPageSeo.entityId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [row] = await db.select().from(wwwPageSeo).where(and(...conditions));
|
|
|
|
|
res.json(ApiResponse.success(row ?? null));
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
logger.error(`[site/page-seo] GET error: ${e instanceof Error ? e.message : e}`);
|
|
|
|
|
res.json(ApiResponse.error('获取页面 SEO 失败'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 联系表单:配置 + 字段列表 =====
|
|
|
|
|
// GET /api/site/contact
|
|
|
|
|
router.get('/api/site/contact', async (_req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const [settings] = await db.select().from(wwwFormSettings).limit(1);
|
|
|
|
|
const fields = await db.select().from(wwwFormFields).orderBy(wwwFormFields.sortOrder);
|
|
|
|
|
res.json(ApiResponse.success({
|
|
|
|
|
settings: settings ?? null,
|
|
|
|
|
fields: fields ?? [],
|
|
|
|
|
}));
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
logger.error(`[site/contact] GET error: ${e instanceof Error ? e.message : e}`);
|
|
|
|
|
res.json(ApiResponse.error('获取表单配置失败'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 联系表单提交(公开) =====
|
|
|
|
|
// POST /api/site/contact/submit
|
|
|
|
|
// body: { form_data: Record<string, string> }
|
|
|
|
|
router.post('/api/site/contact/submit', async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const b = req.body;
|
|
|
|
|
const formData = b.form_data;
|
|
|
|
|
if (!formData || typeof formData !== 'object' || Array.isArray(formData) || Object.keys(formData).length === 0) {
|
|
|
|
|
res.json(ApiResponse.error('form_data 不能为空'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [settings] = await db.select().from(wwwFormSettings).limit(1);
|
|
|
|
|
const fields = await db.select().from(wwwFormFields).orderBy(wwwFormFields.sortOrder);
|
|
|
|
|
const cooldownSeconds = settings?.cooldownSeconds ?? 60;
|
|
|
|
|
|
|
|
|
|
// 冷却校验:同一 IP 最近一次提交时间
|
|
|
|
|
const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip || '';
|
|
|
|
|
if (cooldownSeconds > 0 && ip) {
|
|
|
|
|
const [last] = await db.select().from(wwwFormSubmissions)
|
|
|
|
|
.where(eq(wwwFormSubmissions.visitorIp, ip))
|
|
|
|
|
.orderBy(desc(wwwFormSubmissions.createdAt))
|
|
|
|
|
.limit(1);
|
|
|
|
|
if (last) {
|
|
|
|
|
const elapsed = (Date.now() - new Date(last.createdAt).getTime()) / 1000;
|
|
|
|
|
if (elapsed < cooldownSeconds) {
|
|
|
|
|
res.json(ApiResponse.error(`提交过于频繁,请 ${Math.ceil(cooldownSeconds - elapsed)} 秒后再试`));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 必填字段校验:提交 key 与字段 name_zh / name_en 任一匹配
|
|
|
|
|
const values = Object.values(formData) as string[];
|
|
|
|
|
for (const field of fields) {
|
|
|
|
|
if (!field.isRequired) continue;
|
|
|
|
|
const key = (Object.keys(formData) as string[]).find((k) => k === field.nameZh || k === field.nameEn);
|
|
|
|
|
if (!key || !String(formData[key]).trim()) {
|
|
|
|
|
res.json(ApiResponse.error(`「${field.nameZh}」为必填项`));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 全字段值转字符串,限制长度
|
|
|
|
|
const sanitized: Record<string, string> = {};
|
|
|
|
|
for (const [k, v] of Object.entries(formData)) {
|
|
|
|
|
sanitized[k] = String(v ?? '').slice(0, 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const userAgent = (req.headers['user-agent'] as string || '').slice(0, 500);
|
|
|
|
|
await db.insert(wwwFormSubmissions).values({
|
|
|
|
|
formData: sanitized,
|
|
|
|
|
visitorIp: ip,
|
|
|
|
|
userAgent,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const successMessage = req.query.lang === 'en' ? settings?.successMessageEn : settings?.successMessageZh;
|
|
|
|
|
res.json(ApiResponse.success(null, successMessage || '提交成功'));
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
logger.error(`[site/contact/submit] POST error: ${e instanceof Error ? e.message : e}`);
|
|
|
|
|
res.json(ApiResponse.error('提交失败,请稍后重试'));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default router;
|
|
|
|
|