fix(blog): tags 双重编码导致详情页 500 + 上传文件名去中文
问题:
- 管理后台编辑博客保存后,访问 /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:一次性清理历史双重编码数据
这个提交包含在:
父节点
f91ecbb093
当前提交
61c94b741e
48
scripts/fix-articles-tags.mjs
普通文件
48
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()
|
||||
@ -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,
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}`
|
||||
|
||||
// 按年月分目录
|
||||
|
||||
@ -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,
|
||||
|
||||
正在加载...
x
在新工单中引用
屏蔽一个用户