From 61c94b741e7bb1ccbe83f77abd97d445a25f68f1 Mon Sep 17 00:00:00 2001 From: wx <2636507191@qq.com> Date: Mon, 20 Apr 2026 13:19:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(blog):=20tags=20=E5=8F=8C=E9=87=8D=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E5=AF=BC=E8=87=B4=E8=AF=A6=E6=83=85=E9=A1=B5=20500=20?= =?UTF-8?q?+=20=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E5=90=8D=E5=8E=BB?= =?UTF-8?q?=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 管理后台编辑博客保存后,访问 /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:一次性清理历史双重编码数据 --- scripts/fix-articles-tags.mjs | 48 +++++++++++++++++++++++++++ server/api/admin/articles.post.js | 2 +- server/api/admin/articles/[id].get.js | 10 +++++- server/api/admin/articles/[id].put.js | 2 +- server/api/admin/upload.post.js | 8 +++-- server/api/content/[key].get.js | 16 +++++++-- 6 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 scripts/fix-articles-tags.mjs diff --git a/scripts/fix-articles-tags.mjs b/scripts/fix-articles-tags.mjs new file mode 100644 index 0000000..72fea21 --- /dev/null +++ b/scripts/fix-articles-tags.mjs @@ -0,0 +1,48 @@ +/** + * 修复 articles 表 tags 字段被双重/三重 JSON.stringify 污染的历史数据 + * + * 背景: + * admin 后台编辑文章时,loadItem 把 DB 里 tags 字段(字符串 "[]") + * 直接透传到 form.tags,保存时 JSON.stringify(form.tags) 导致双重编码, + * 形如 '"[]"' / '"\\"[]\\""'。公开接口 JSON.parse 后得到字符串而非数组, + * 前端 `tags.join(',')` TypeError → 页面 500。 + * + * 本脚本把所有非数组 JSON 的 tags 统一重置为 '[]'。 + * + * 运行: node scripts/fix-articles-tags.mjs + * 生产部署后执行一次即可(幂等)。 + */ + +import Database from 'better-sqlite3' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const dbPath = process.env.DB_PATH || join(__dirname, '..', 'hulai-website.db') + +const db = new Database(dbPath) +const rows = db.prepare('SELECT id, slug, tags FROM articles').all() + +let fixed = 0 +const update = db.prepare('UPDATE articles SET tags = ? WHERE id = ?') + +for (const r of rows) { + let ok = false + if (r.tags) { + try { + const v = JSON.parse(r.tags) + ok = Array.isArray(v) + } catch {} + } else { + ok = true // null / 空字符串:稍后统一写成 [] + } + + if (!ok) { + update.run('[]', r.id) + fixed++ + console.log(`fixed id=${r.id} slug=${r.slug} old=${JSON.stringify(r.tags)}`) + } +} + +console.log(`\nDone. scanned=${rows.length} fixed=${fixed}`) +db.close() diff --git a/server/api/admin/articles.post.js b/server/api/admin/articles.post.js index 2d18f28..bd9d78c 100644 --- a/server/api/admin/articles.post.js +++ b/server/api/admin/articles.post.js @@ -21,7 +21,7 @@ export default defineEventHandler(async (event) => { type: body.type || 'news', coverImage: body.coverImage || null, author: body.author || '呼籁旅行', - tags: body.tags ? JSON.stringify(body.tags) : '[]', + tags: JSON.stringify(Array.isArray(body.tags) ? body.tags : []), seoTitle: body.seoTitle || null, seoDesc: body.seoDesc || null, seoKeywords: body.seoKeywords || null, diff --git a/server/api/admin/articles/[id].get.js b/server/api/admin/articles/[id].get.js index d3d19f7..ec13d8a 100644 --- a/server/api/admin/articles/[id].get.js +++ b/server/api/admin/articles/[id].get.js @@ -14,5 +14,13 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, message: '文章不存在' }) } - return item + let tags = [] + if (item.tags) { + try { + const parsed = JSON.parse(item.tags) + tags = Array.isArray(parsed) ? parsed : [] + } catch { tags = [] } + } + + return { ...item, tags } }) diff --git a/server/api/admin/articles/[id].put.js b/server/api/admin/articles/[id].put.js index b80a1ab..0966fae 100644 --- a/server/api/admin/articles/[id].put.js +++ b/server/api/admin/articles/[id].put.js @@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => { if (body.category !== undefined) updateData.category = body.category if (body.coverImage !== undefined) updateData.coverImage = body.coverImage if (body.author !== undefined) updateData.author = body.author - if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags) + if (body.tags !== undefined) updateData.tags = JSON.stringify(Array.isArray(body.tags) ? body.tags : []) if (body.seoTitle !== undefined) updateData.seoTitle = body.seoTitle if (body.seoDesc !== undefined) updateData.seoDesc = body.seoDesc if (body.seoKeywords !== undefined) updateData.seoKeywords = body.seoKeywords diff --git a/server/api/admin/upload.post.js b/server/api/admin/upload.post.js index 6cc4f79..5261b3f 100644 --- a/server/api/admin/upload.post.js +++ b/server/api/admin/upload.post.js @@ -31,11 +31,13 @@ export default defineEventHandler(async (event) => { } // 生成文件名: 时间戳-原始文件名 + // 只保留 ASCII 字母数字下划线短横,中文/特殊字符一律去掉,避免 URL 编码与静态服务文件名不一致导致 404 const ext = extname(file.filename).toLowerCase() - const safeName = file.filename + const stripped = file.filename .replace(ext, '') - .replace(/[^a-zA-Z0-9\u4e00-\u9fa5_-]/g, '_') - .slice(0, 50) + .replace(/[^a-zA-Z0-9_-]/g, '') + .slice(0, 30) + const safeName = stripped || 'img' const fileName = `${Date.now()}-${safeName}${ext}` // 按年月分目录 diff --git a/server/api/content/[key].get.js b/server/api/content/[key].get.js index f64a78a..3e978e5 100644 --- a/server/api/content/[key].get.js +++ b/server/api/content/[key].get.js @@ -1,6 +1,18 @@ 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 对应独立实体表时,从实体表动态读取数据, @@ -121,7 +133,7 @@ function buildFromEntityTable(db, key, siteData) { category: a.category, coverImage: a.coverImage, author: a.author, - tags: a.tags ? JSON.parse(a.tags) : [], + tags: safeParseTags(a.tags), date: a.createdAt, })) return { articles: items } @@ -141,7 +153,7 @@ function buildFromEntityTable(db, key, siteData) { category: a.category, coverImage: a.coverImage, author: a.author, - tags: a.tags ? JSON.parse(a.tags) : [], + tags: safeParseTags(a.tags), seoTitle: a.seoTitle, seoDescription: a.seoDesc, seoKeywords: a.seoKeywords,