问题:
- 管理后台编辑博客保存后,访问 /blog/{slug} 返回 500
- 用户上传封面图后前端不识别/裂图
根因:
1. admin [id].get.js 原样返回 DB 里的 tags 字符串 "[]",编辑页 loadItem
透传到 form.tags,保存时 JSON.stringify 再编码一次 → DB 写入 '"[]"'。
公开接口 JSON.parse 后得到字符串 "[]" 而非数组,前端 tags.join(',')
对字符串调用 → TypeError → SSR 500。
2. 上传接口保留中文文件名(\u4e00-\u9fa5 正则放行),Nginx/Nuxt 静态服务
对含中文 URL 处理不一致,静态请求 404。
修复:
- admin GET:tags 统一解析为数组返回
- admin POST/PUT:只接受数组,非数组写入空数组
- 公开 /api/content/blog|news:safeParseTags 兼容历史脏数据,
即使 DB 里是 '"[]"' / 非法 JSON 也保证输出数组
- upload.post.js:safeName 只保留 ASCII 字母数字,去除中文
- scripts/fix-articles-tags.mjs:一次性清理历史双重编码数据
212 行
6.8 KiB
JavaScript
212 行
6.8 KiB
JavaScript
import { eq, desc, asc } from 'drizzle-orm'
|
||
import { useDB, schema } from '../../utils/db.js'
|
||
|
||
// 防御性解析 tags:兼容历史脏数据(字符串字面量 "[]"、null、非法 JSON),始终返回数组
|
||
function safeParseTags(raw) {
|
||
if (!raw) return []
|
||
try {
|
||
let v = JSON.parse(raw)
|
||
if (typeof v === 'string') {
|
||
try { v = JSON.parse(v) } catch { return [] }
|
||
}
|
||
return Array.isArray(v) ? v : []
|
||
} catch { return [] }
|
||
}
|
||
|
||
/**
|
||
* 实体表数据构建器
|
||
* 当 key 对应独立实体表时,从实体表动态读取数据,
|
||
* 同时合并 site_content 中的元数据(summary/seo 等)
|
||
*/
|
||
function buildFromEntityTable(db, key, siteData) {
|
||
switch (key) {
|
||
case 'reviews': {
|
||
const items = db.select().from(schema.reviews)
|
||
.where(eq(schema.reviews.published, true))
|
||
.orderBy(asc(schema.reviews.sortOrder), desc(schema.reviews.id))
|
||
.all()
|
||
.map(r => ({
|
||
id: r.id,
|
||
nickname: r.author,
|
||
travelDate: r.date,
|
||
productVersion: r.title,
|
||
screenshot: r.screenshot || '',
|
||
content: r.content,
|
||
rating: r.rating,
|
||
scenes: r.scenes ? JSON.parse(r.scenes) : [],
|
||
concerns: r.concerns ? JSON.parse(r.concerns) : [],
|
||
}))
|
||
// 高频关键词:从评价正文中按预设词表统计出现频次
|
||
const KEYWORD_DICT = [
|
||
'领队', '服务', '贴心', '耐心', '细心', '专业',
|
||
'摄影', '拍照', '旅拍', '骑马', '越野车', '滑草',
|
||
'营地', '烧烤', '住宿', '行程', '错峰', '不排队',
|
||
'亲子', '小朋友', '孩子', '家人', '老人',
|
||
'草原', '风景', '美', '惊艳', '难忘', '舒适',
|
||
'安排', '推荐', '值得', '良心', '安心', '用心',
|
||
]
|
||
const freq = {}
|
||
for (const it of items) {
|
||
const c = it.content || ''
|
||
for (const kw of KEYWORD_DICT) if (c.includes(kw)) freq[kw] = (freq[kw] || 0) + 1
|
||
}
|
||
const keywords = Object.entries(freq)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 12)
|
||
.map(([w]) => w)
|
||
|
||
const summary = {
|
||
totalCount: siteData?.summary?.totalCount || items.length,
|
||
approvalRate: siteData?.summary?.approvalRate || '98%',
|
||
keywords: siteData?.summary?.keywords?.length ? siteData.summary.keywords : keywords,
|
||
}
|
||
return { summary, items }
|
||
}
|
||
|
||
case 'faq': {
|
||
const items = db.select().from(schema.faqs)
|
||
.where(eq(schema.faqs.published, true))
|
||
.orderBy(asc(schema.faqs.sortOrder), asc(schema.faqs.id))
|
||
.all()
|
||
// site_content 有分类结构则保留,把实体表的问答作为「全部」
|
||
if (siteData?.categories) return siteData
|
||
return {
|
||
categories: [{
|
||
id: 'all',
|
||
name: '常见问题',
|
||
questions: items.map(f => ({
|
||
question: f.question,
|
||
answer: f.answer,
|
||
})),
|
||
}],
|
||
}
|
||
}
|
||
|
||
case 'gallery': {
|
||
const items = db.select().from(schema.galleryItems)
|
||
.where(eq(schema.galleryItems.published, true))
|
||
.orderBy(asc(schema.galleryItems.sortOrder), asc(schema.galleryItems.id))
|
||
.all()
|
||
const works = items.map(g => ({
|
||
id: g.id,
|
||
title: g.title,
|
||
category: g.category,
|
||
location: g.location,
|
||
description: g.description,
|
||
image: g.image,
|
||
orientation: g.orientation,
|
||
}))
|
||
const categories = siteData?.categories || [{key:"all",label:"全部作品"}]
|
||
return {
|
||
seo: siteData?.seo || {},
|
||
intro: siteData?.intro || {},
|
||
categories,
|
||
works,
|
||
}
|
||
}
|
||
|
||
case 'stories': {
|
||
const items = db.select().from(schema.stories)
|
||
.where(eq(schema.stories.published, true))
|
||
.orderBy(asc(schema.stories.sortOrder), desc(schema.stories.id))
|
||
.all()
|
||
.map(s => ({
|
||
...s,
|
||
tags: s.tags ? (() => { try { return JSON.parse(s.tags) } catch { return [] } })() : [],
|
||
sections: s.sections ? JSON.parse(s.sections) : [],
|
||
keyMoments: s.keyMoments ? JSON.parse(s.keyMoments) : [],
|
||
}))
|
||
return { stories: items }
|
||
}
|
||
|
||
case 'news': {
|
||
const items = db.select().from(schema.articles)
|
||
.where(eq(schema.articles.published, true))
|
||
.orderBy(desc(schema.articles.createdAt), desc(schema.articles.id))
|
||
.all()
|
||
.filter(a => a.type === 'news')
|
||
.map(a => ({
|
||
id: a.slug,
|
||
title: a.title,
|
||
summary: a.summary,
|
||
content: a.content,
|
||
category: a.category,
|
||
coverImage: a.coverImage,
|
||
author: a.author,
|
||
tags: safeParseTags(a.tags),
|
||
date: a.createdAt,
|
||
}))
|
||
return { articles: items }
|
||
}
|
||
|
||
case 'blog': {
|
||
const items = db.select().from(schema.articles)
|
||
.where(eq(schema.articles.published, true))
|
||
.orderBy(desc(schema.articles.id))
|
||
.all()
|
||
.filter(a => a.type === 'blog')
|
||
.map(a => ({
|
||
id: a.slug,
|
||
title: a.title,
|
||
summary: a.summary,
|
||
content: a.content,
|
||
category: a.category,
|
||
coverImage: a.coverImage,
|
||
author: a.author,
|
||
tags: safeParseTags(a.tags),
|
||
seoTitle: a.seoTitle,
|
||
seoDescription: a.seoDesc,
|
||
seoKeywords: a.seoKeywords,
|
||
date: a.createdAt,
|
||
}))
|
||
return { articles: items }
|
||
}
|
||
|
||
default:
|
||
return null
|
||
}
|
||
}
|
||
|
||
// 有entity表的 key 列表
|
||
const ENTITY_KEYS = ['reviews', 'faq', 'gallery', 'stories', 'news', 'blog']
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
const key = getRouterParam(event, 'key')
|
||
const db = useDB()
|
||
|
||
// 读site_content(作为元数据或兜底)
|
||
const item = db.select().from(schema.siteContent)
|
||
.where(eq(schema.siteContent.key, key))
|
||
.get()
|
||
const siteData = item ? JSON.parse(item.data) : null
|
||
|
||
// 如果是实体表key,优先从实体表构建
|
||
if (ENTITY_KEYS.includes(key)) {
|
||
const result = buildFromEntityTable(db, key, siteData)
|
||
if (result) {
|
||
setHeader(event, 'Cache-Control', 'no-store, must-revalidate')
|
||
return result
|
||
}
|
||
}
|
||
|
||
// 虚拟 key:summer-camp 从 products.summerCamp 提取
|
||
if (key === 'summer-camp' && !siteData) {
|
||
const productsItem = db.select().from(schema.siteContent)
|
||
.where(eq(schema.siteContent.key, 'products'))
|
||
.get()
|
||
const productsData = productsItem ? JSON.parse(productsItem.data) : null
|
||
if (productsData?.summerCamp) {
|
||
setHeader(event, 'Cache-Control', 'no-store, must-revalidate')
|
||
return productsData.summerCamp
|
||
}
|
||
}
|
||
|
||
// 普通 site_content key
|
||
if (!siteData) {
|
||
throw createError({ statusCode: 404, message: `未找到配置项: ${key}` })
|
||
}
|
||
|
||
setHeader(event, 'Cache-Control', 'no-store, must-revalidate')
|
||
return siteData
|
||
})
|