feat(personal_collect,motion_reminder): 新增回收站与云端TTS语音功能

- 新增小程序个人收藏回收站页面,支持查看已删除收藏、恢复与彻底删除操作
- 修复个人收藏文件上传时的中文名乱码问题,新增原文件名透传参数
- 接入阿里云百炼TTS服务,实现运动提醒模块的云端语音合成与本地缓存,支持系统TTS与云端MP3双降级播放
- 新增语音引擎探测与异常预警UI,覆盖web端与移动端
- 优化web端订单盒页面布局,修复卡片操作按钮换行问题
- 新增调试探针脚本、ESLint配置与相关lint脚本
- 完善后端接口与路由,更新项目依赖与配置文件
This commit is contained in:
yangxiangyuan
2026-09-17 16:26:27 +08:00
parent d4bf3627fd
commit bbc7ca6523
29 changed files with 3228 additions and 163 deletions
@@ -1,5 +1,6 @@
const crypto = require('crypto')
const store = require('./store')
const tts = require('./tts')
const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
@@ -81,6 +82,36 @@ const bindRoutes = (app, helpers) => {
}
})
// 语音兜底预合成:把整卡会用到的「最小播报单元」批量合成为 mp3 并返回 文本→URL 映射。
// 系统 TTS 不可用(安卓无引擎 / 引擎被回收 / 静默失败)时,播放端改播这些 mp3。
app.post('/api/app_order_box/kits/motion_reminder/tts/prepare', async (req, res) => {
try {
const raw = req.body && req.body.card ? req.body.card : {}
const sourceCardId = String(raw.id || '').trim()
const existingCard = sourceCardId ? store.getCardById(sourceCardId) : null
const card = existingCard || sanitizeCard(raw)
if (!card.title || !Array.isArray(card.steps) || !card.steps.length) {
return res.status(400).json({ ok: false, error: 'missing card title or steps' })
}
const voice = String(req.body && req.body.voice || '').trim() || tts.VOICE_ID_DEFAULT
const result = await tts.synthesizeBatch(card, voice)
const map = {}
result.items.forEach(item => { if (item.ok && item.url) map[item.text] = item.url })
return res.json({
ok: true,
data: {
voice,
total: result.total,
success: result.success,
map,
missing: result.missing,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post('/api/app_order_box/kits/motion_reminder/sessions/start', (req, res) => {
try {
const raw = req.body && req.body.card ? req.body.card : {}
@@ -34,6 +34,15 @@ const getDb = () => {
manual_stopped INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);`)
db.exec(`CREATE TABLE IF NOT EXISTS motion_tts_cache (
md5 TEXT PRIMARY KEY,
text TEXT NOT NULL,
voice TEXT NOT NULL,
url TEXT NOT NULL,
duration_ms INTEGER,
created_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_motion_cards_updated ON cards(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_motion_sessions_started ON sessions(started_at);`)
return db
@@ -219,7 +228,43 @@ const listHistory = () => {
return rows.map(mapSession)
}
// ==================== TTS 缓存 ====================
const getTTSCacheRow = (md5Key) => {
const row = getDb().prepare(`SELECT * FROM motion_tts_cache WHERE md5 = ? AND deleted = 0 LIMIT 1`).get(md5Key)
return row || null
}
const insertTTSCache = ({ md5: md5Key, text, voice, url, durationMs, createdAt }) => {
// UPSERT:md5 即使已存在(含 deleted=1 的历史行)也覆盖恢复为最新音频,
// 避免 INSERT OR IGNORE 导致缓存永远无法更新、重复合成文件堆积
getDb().prepare(`
INSERT INTO motion_tts_cache (md5, text, voice, url, duration_ms, created_at, deleted)
VALUES (?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(md5) DO UPDATE SET
text = excluded.text,
voice = excluded.voice,
url = excluded.url,
duration_ms = excluded.duration_ms,
deleted = 0,
created_at = excluded.created_at
`).run(
String(md5Key || ''),
String(text || ''),
String(voice || ''),
String(url || ''),
durationMs ? Math.max(0, Number(durationMs)) : null,
String(createdAt || nowIso())
)
return { ok: true, md5: md5Key, url }
}
const updateTTSCacheUrl = (md5Key, url) => {
getDb().prepare(`UPDATE motion_tts_cache SET url = ? WHERE md5 = ?`).run(String(url || ''), String(md5Key || ''))
}
module.exports = {
getDb,
nowIso,
listCards,
getCardById,
insertCard,
@@ -230,4 +275,7 @@ module.exports = {
syncSession,
finishSession,
listHistory,
getTTSCacheRow,
insertTTSCache,
updateTTSCacheUrl,
}
@@ -0,0 +1,305 @@
// 运动提醒宝(motion_reminder)独立 TTS 合成与缓存实现
// 说明:本项目 Rule 6 要求每个 tool 独立维护自己的第三方调用封装,
// 因此本文件不 require 其他 tool 的 tts 模块,只依赖本 kit 的 ./store。
// 用途:为运动执行器的语音播报提供「云端 mp3 兜底通道」——
// 设备系统 TTS 不可用(安卓无引擎 / 引擎被回收 / 静默失败)时改播本地缓存的 mp3。
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const store = require('./store')
const TTS_ROOT = path.join(process.cwd(), 'uploads', 'app_order_box', 'tts_cache')
const TTS_URL_PREFIX = '/uploads/app_order_box/tts_cache/'
const VOICE_ID_DEFAULT = 'female_01'
// 进入运动执行器时的欢迎语,同时承担「系统 TTS 是否真的出声」的探测信号
const WELCOME_TEXT = '欢迎回来,每天锻炼身体好!'
// 单张卡片最多预合成的原子文本数,避免大卡片把 DashScope QPS 打满
const MAX_BATCH_ATOMS = 40
// 循环轮次数词最大预合成数目,超出部分运行期回退系统 TTS
const MAX_LOOP_ATOMS = 20
const ensureDir = dir => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) }
ensureDir(TTS_ROOT)
const log = (...args) => { try { console.log('[motion_reminder tts]', ...args) } catch (_) {} }
/** 内容优先嗅探音频格式,避免 Content-Type 谎报导致扩展名错配、iOS 按错误解码器解析而无声 */
const detectAudioExt = (buf, contentType = '') => {
try {
if (buf && buf.length > 0) {
const headAscii = Buffer.from(buf.subarray(0, Math.min(buf.length, 16))).toString('ascii')
if (headAscii.startsWith('RIFF') && headAscii.indexOf('WAVE') >= 0) return '.wav'
if (headAscii.startsWith('ID3')) return '.mp3'
if (buf.length >= 2 && buf[0] === 0xFF && (buf[1] & 0xE0) === 0xE0) return '.mp3'
}
const ct = String(contentType || '').toLowerCase()
if (ct.includes('wav')) return '.wav'
if (ct.includes('mpeg') || ct.includes('mp3')) return '.mp3'
} catch {}
return '.mp3'
}
const getAbsPathFromUrl = url => {
const clean = String(url || '').trim().replace(/^\/+/, '')
if (!clean) return ''
return path.join(process.cwd(), clean.replace(/\//g, path.sep))
}
/** 历史缓存若扩展名与实际内容不符,就地改名并回写数据库(一次性自愈) */
const normalizeCachedUrl = row => {
try {
if (!row || !row.url) return row
const currentUrl = String(row.url || '').trim()
if (!currentUrl) return row
const abs = getAbsPathFromUrl(currentUrl)
if (!abs || !fs.existsSync(abs)) return row
const buf = fs.readFileSync(abs)
const realExt = detectAudioExt(buf)
const currentExt = path.extname(abs || '').toLowerCase()
if (!realExt || realExt === currentExt) return row
const nextAbs = abs.slice(0, -currentExt.length) + realExt
const nextUrl = currentUrl.slice(0, -currentExt.length) + realExt
if (!fs.existsSync(nextAbs)) fs.renameSync(abs, nextAbs)
store.updateTTSCacheUrl(row.md5, nextUrl)
return Object.assign({}, row, { url: nextUrl })
} catch {}
return row
}
/** 内容寻址缓存键:md5(voice|text),保证同一句话永远命中同一份音频 */
const md5 = (text, voice = VOICE_ID_DEFAULT) => {
return crypto.createHash('md5').update(`${voice}|${String(text || '')}`).digest('hex')
}
const getTTSCacheRow = (text, voice = VOICE_ID_DEFAULT) => {
const row = store.getTTSCacheRow(md5(text, voice))
return normalizeCachedUrl(row)
}
const getSafeFetch = () => {
try {
if (globalThis && typeof globalThis.fetch === 'function') return globalThis.fetch
} catch (_) {}
if (global && typeof global.fetch === 'function') return global.fetch
try { return require('node-fetch') } catch (_) {}
return null
}
const fetchWithTimeout = async (url, options = {}, timeoutMs = 20000) => {
const fn = getSafeFetch()
if (!fn) return null
if (typeof AbortController === 'undefined') return fn(url, options)
const controller = new AbortController()
const timer = setTimeout(() => { try { controller.abort() } catch (_) {} }, timeoutMs)
try {
return await fn(url, Object.assign({}, options, { signal: controller.signal }))
} finally {
clearTimeout(timer)
}
}
// 全局 TTS 串行队列:DashScope 有 QPS 限流,
// 并发合成会触发 HTTP 429 Throttling.RateQuota 导致随机文本合成失败,必须逐条排队
let ttsChain = Promise.resolve()
const ttsQueue = fn => {
const run = ttsChain.then(fn, fn)
ttsChain = run.catch(() => {})
return run
}
const callCosyVoice = async (text, voice = VOICE_ID_DEFAULT) => {
const apiKey = process.env.DASHSCOPE_API_KEY || ''
if (!apiKey) {
log('❌ DASHSCOPE_API_KEY 未配置,无法合成:', JSON.stringify(String(text || '')))
return { ok: false, error: 'DASHSCOPE_API_KEY 未配置' }
}
const t = String(text || '').trim()
if (!t) return { ok: false, error: 'empty text' }
const isMale = String(voice || '').indexOf('male') >= 0
const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
}
const fallbacks = []
fallbacks.push({
name: 'Fallback-A:Qwen3-TTS',
url: 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
body: {
model: 'qwen3-tts-flash',
input: {
text: t,
voice: 'Cherry',
language_type: 'Chinese',
},
},
})
fallbacks.push({
name: 'Fallback-B:CosyVoice-v3',
url: 'https://dashscope.aliyuncs.com/api/v1/services/audio/tts/SpeechSynthesizer',
body: {
model: 'cosyvoice-v3-flash',
input: {
text: t,
voice: isMale ? 'longxiaocheng' : 'longanyang',
format: 'mp3',
sample_rate: 24000,
},
},
})
for (const fb of fallbacks) {
const t0 = Date.now()
try {
let resp = null
try {
resp = await fetchWithTimeout(fb.url, { method: 'POST', headers, body: JSON.stringify(fb.body) }, 20000)
} catch (fe) {
log(' ↳', fb.name, 'fetch 抛异常:', String(fe && fe.message || fe), '耗时', (Date.now() - t0) + 'ms')
continue
}
if (!resp || !resp.ok) {
const txt = resp ? await resp.text().catch(() => '') : ''
log(' ', fb.name, 'HTTP', resp ? resp.status : 'no-fetch', '耗时', (Date.now() - t0) + 'ms', '响应=', String(txt || '').replace(/\s+/g, ' ').slice(0, 400))
continue
}
let j = null
try { j = await resp.json() } catch (je) { log(' ↳', fb.name, '响应非 JSON:', String(je && je.message || je)); continue }
let audioUrl = ''
try {
const output = (j && j.output) || null
if (output) {
if (output.audio && typeof output.audio.url === 'string') audioUrl = output.audio.url
if (!audioUrl && typeof output.audio_url === 'string') audioUrl = output.audio_url
if (!audioUrl && typeof output.audioUrl === 'string') audioUrl = output.audioUrl
if (!audioUrl && Array.isArray(output.results) && output.results[0]) {
const first = output.results[0] || {}
if (first.audio && typeof first.audio.url === 'string') audioUrl = first.audio.url
}
}
} catch {}
if (!audioUrl) { log(' ↳', fb.name, '响应里未找到 audio url,跳过该 fallback'); continue }
const download = await fetchWithTimeout(audioUrl, {}, 20000)
if (!download || !download.ok) { log(' ↳', fb.name, '下载音频失败 status=', download && download.status); continue }
const buf = Buffer.from(await download.arrayBuffer())
const ext = detectAudioExt(buf, download.headers && download.headers.get ? download.headers.get('content-type') : '')
// 文件名保持纯 ASCII,避免中文名经反代 + URL 编码后取不到
const storedName = `mr_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`
const absPath = path.join(TTS_ROOT, storedName)
fs.writeFileSync(absPath, buf)
const stat = fs.statSync(absPath)
const durationMs = Math.max(500, Math.round(stat.size / (12 * 1024 / 8)))
log(' ✔ 合成成功 text=', JSON.stringify(t), 'file=', storedName, '耗时', (Date.now() - t0) + 'ms')
return { ok: true, url: `${TTS_URL_PREFIX}${storedName}`, durationMs }
} catch (e) {
log(' ↳', fb.name, '处理异常:', String(e && e.message || e))
}
}
log('✘ 所有 TTS fallback 均失败 text=', JSON.stringify(t), 'voice=', voice)
return { ok: false, error: 'All TTS fallbacks failed' }
}
/** 合成单条文本:先查 md5 缓存,未命中才真实请求 */
const synthesize = async (text, voice = VOICE_ID_DEFAULT) => {
const t = String(text || '').trim()
if (!t) return { ok: false, error: 'empty text' }
const cached = getTTSCacheRow(t, voice)
if (cached) {
return { ok: true, url: cached.url, cached: true, durationMs: cached.duration_ms }
}
const r = await ttsQueue(() => callCosyVoice(t, voice))
if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'unknown tts error' }
store.insertTTSCache({
md5: md5(t, voice),
text: t,
voice,
url: r.url,
durationMs: r.durationMs,
createdAt: store.nowIso(),
})
return { ok: true, url: r.url, cached: false, durationMs: r.durationMs }
}
/** 语音播报专用时长文案,必须与 Web/Runner 端 formatSpeechDuration 保持一致 */
const formatSpeechDuration = seconds => {
const safe = Math.max(0, Math.floor(Number(seconds) || 0))
const mm = Math.floor(safe / 60)
const ss = safe % 60
const parts = []
if (mm > 0) parts.push(mm + '分钟')
if (ss > 0) parts.push(ss + '秒')
return parts.length ? parts.join('') : '0秒'
}
/** 卡片的步骤播报标题,必须与 Web/Runner 端 announceStep 的取词逻辑一致 */
const stepSpeechTitle = step => {
const voiceText = String(step && step.voiceText || '').trim()
if (voiceText) return voiceText
const name = String(step && step.name || '').trim() || '步骤'
return name + ',' + formatSpeechDuration(step && step.durationSec)
}
/**
* 枚举一张卡片运行期会用到的全部「最小播报单元」。
* 采用最小单元而非整句:整句预合成会导致 轮数 × 步骤数 组合爆炸。
* 注意:loopMode 判据必须与 sanitizeCard 的产出对齐(repeat_count / repeat_forever / once)。
* 文本必须与 Web 端 order_box.js 的原子取词逐字一致,否则缓存查表命中不了。
*/
const buildCardTTSBatch = card => {
const lines = [WELCOME_TEXT]
const c = card || {}
const steps = Array.isArray(c.steps) ? c.steps : []
const loopMode = String(c.loopMode || 'once')
const loopCount = Math.max(0, Number(c.loopCount) || 0)
if (loopMode === 'repeat_count' && loopCount > 1) {
for (let i = 1; i <= Math.min(loopCount, MAX_LOOP_ATOMS); i++) lines.push(`第${i}轮`)
} else if (loopMode === 'repeat_forever') {
for (let i = 1; i <= MAX_LOOP_ATOMS; i++) lines.push(`第${i}轮`)
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i] || {}
lines.push(stepSpeechTitle(s))
const next = steps[i + 1]
if (next) lines.push(`下一步,${String(next.name || '').trim() || '下一步'}`)
}
lines.push('还有 5 秒')
lines.push('还有 3 秒')
return Array.from(new Set(lines.filter(Boolean))).slice(0, MAX_BATCH_ATOMS)
}
/**
* 批量预合成给定文本,返回 { items: [{text, url, ok, cached, error}], missing }
* 单条失败不影响整体,播放端按 text 查表,查不到就回退系统 TTS。
*/
const synthesizeTexts = async (lines, voice = VOICE_ID_DEFAULT) => {
const list = Array.from(new Set((Array.isArray(lines) ? lines : []).map(t => String(t || '').trim()).filter(Boolean))).slice(0, MAX_BATCH_ATOMS)
const st = Date.now()
log('▶ prepare 开始 voice=', voice, '原子数=', list.length)
const results = await Promise.all(list.map(line => synthesize(line, voice).then(r => ({
text: line,
url: r.url || '',
ok: !!r.ok,
cached: !!r.cached,
error: r.error || null,
}))))
const success = results.filter(r => r.ok).length
const missing = results.filter(r => !r.ok).map(r => r.text)
log('✔ prepare 完成 success=', success + '/' + list.length, '耗时', (Date.now() - st) + 'ms')
return { ok: success > 0, total: list.length, success, items: results, missing }
}
const synthesizeBatch = async (card, voice = VOICE_ID_DEFAULT) => synthesizeTexts(buildCardTTSBatch(card), voice)
module.exports = {
synthesize,
synthesizeBatch,
synthesizeTexts,
buildCardTTSBatch,
stepSpeechTitle,
callCosyVoice,
TTS_ROOT,
TTS_URL_PREFIX,
VOICE_ID_DEFAULT,
WELCOME_TEXT,
MAX_BATCH_ATOMS,
getTTSCacheRow,
}
+52 -11
View File
@@ -1044,6 +1044,20 @@ app.use('/uploads/mp_rep_clock', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
app.use('/uploads/app_order_box', (req, res, next) => {
// 运动提醒宝的 TTS 兜底音频由 <audio> 标签直接拉取,无法携带自定义鉴权头。
// 这里只对白名单目录放开,其他上传资源仍保持鉴权。
if (req && typeof req.path === 'string' && req.path.indexOf('/tts_cache/') === 0) return next()
try {
const authConfigPath = path.join(process.cwd(), 'public', 'tools', 'web_order_box', 'auth_config.json')
if (fs.existsSync(authConfigPath)) {
const authConfig = JSON.parse(fs.readFileSync(authConfigPath, 'utf-8'))
if (authConfig.enable_auth === false) return next()
}
} catch {}
if (hasNavAuthRoot(req)) return next()
return res.status(401).send('未授权')
})
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => {
@@ -5610,8 +5624,23 @@ app.delete('/api/ccb_private_funds/attachments/:id', (req, res) => {
// Nav Bookmarks API
app.get('/api/nav_bookmarks/categories', (req, res) => {
try {
const rows = NAV_BOOKMARKS_DB.prepare('SELECT code, name, sort_order, created_at, updated_at FROM nav_categories ORDER BY sort_order ASC, code ASC').all()
res.json({ ok: true, rows })
// 兼容旧调用:不带 page 参数时返回全量;带 page 参数时服务端分页
const hasPage = req.query.page !== undefined && req.query.page !== ''
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize, 10) || 20))
const total = NAV_BOOKMARKS_DB.prepare('SELECT COUNT(1) AS c FROM nav_categories').get().c
const base = `
SELECT c.code, c.name, c.sort_order, c.created_at, c.updated_at,
COALESCE((SELECT COUNT(1) FROM nav_bookmarks b WHERE b.category_code = c.code), 0) AS bookmark_count
FROM nav_categories c
`
let rows
if (hasPage) {
rows = NAV_BOOKMARKS_DB.prepare(`${base} ORDER BY c.sort_order ASC, c.code ASC LIMIT ? OFFSET ?`).all(pageSize, (page - 1) * pageSize)
} else {
rows = NAV_BOOKMARKS_DB.prepare(`${base} ORDER BY c.sort_order ASC, c.code ASC`).all()
}
res.json({ ok: true, rows, total, total_pages: Math.ceil(total / (pageSize || 1)), page: hasPage ? page : undefined, page_size: pageSize })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
@@ -5662,17 +5691,29 @@ app.get('/api/nav_bookmarks/bookmarks', (req, res) => {
try {
const category = String(req.query.category_code || '').trim()
const q = String(req.query.q || '').trim().toLowerCase()
let rows = NAV_BOOKMARKS_DB.prepare('SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at FROM nav_bookmarks ORDER BY sort_order ASC, created_at ASC').all()
if (category) rows = rows.filter(r => String(r.category_code || '') === category)
// 兼容旧调用:不带 page 参数时返回全量;带 page 参数时服务端分页
const hasPage = req.query.page !== undefined && req.query.page !== ''
const page = Math.max(1, parseInt(req.query.page, 10) || 1)
const pageSize = Math.min(200, Math.max(1, parseInt(req.query.pageSize, 10) || 20))
const where = []
const params = []
if (category) { where.push('category_code = ?'); params.push(category) }
if (q) {
rows = rows.filter(r => {
const name = String(r.name || '').toLowerCase()
const url = String(r.url || '').toLowerCase()
const remark = String(r.remark || '').toLowerCase()
return name.includes(q) || url.includes(q) || remark.includes(q)
})
where.push('(LOWER(name) LIKE ? OR LOWER(url) LIKE ? OR LOWER(remark) LIKE ?)')
const like = `%${q}%`
params.push(like, like, like)
}
res.json({ ok: true, rows })
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : ''
const total = NAV_BOOKMARKS_DB.prepare(`SELECT COUNT(1) AS c FROM nav_bookmarks ${whereSql}`).get(...params).c
let rows
if (hasPage) {
rows = NAV_BOOKMARKS_DB.prepare(`SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at
FROM nav_bookmarks ${whereSql} ORDER BY sort_order ASC, created_at ASC LIMIT ? OFFSET ?`).all(...params, pageSize, (page - 1) * pageSize)
} else {
rows = NAV_BOOKMARKS_DB.prepare(`SELECT id, name, url, icon, category_code, remark, sort_order, created_at, updated_at
FROM nav_bookmarks ${whereSql} ORDER BY sort_order ASC, created_at ASC`).all(...params)
}
res.json({ ok: true, rows, total, total_pages: Math.ceil(total / (pageSize || 1)), page: hasPage ? page : undefined, page_size: pageSize })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
+43 -1
View File
@@ -244,6 +244,45 @@ const bindRoutes = app => {
}
})
app.get(`${API_PREFIX}/trash`, (req, res) => {
try {
return res.json({ ok: true, data: store.listTrash() })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/trash/restore`, (req, res) => {
try {
const id = String((req.body && req.body.id) || req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const ok = store.restoreItem(id)
if (!ok) return res.status(404).json({ ok: false, error: '回收站中不存在该收藏' })
return res.json({ ok: true })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/trash`, (req, res) => {
try {
const id = String((req.body && req.body.id) || req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const r = store.purgeItem(id)
if (!r || !r.ok) return res.status(404).json({ ok: false, error: r && r.error || 'not found' })
return res.json({
ok: true,
data: {
filesRemoved: r.filesRemoved,
bytesRemoved: r.bytesRemoved,
attachments: r.attachments,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/upload`, upload.single('file'), (req, res) => {
try {
const f = req.file
@@ -253,7 +292,10 @@ const bindRoutes = app => {
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const originalName = fixFileNameEncoding(f.originalname)
// 优先使用小程序端透传的原始文件名(UTF-8 完好);
// 兜底才解析 multipart 里的 originalname(桌面微信传输中文名可能已被损坏)
const originalNameRaw = String(req.body && req.body.originalName || '').trim() || f.originalname
const originalName = fixFileNameEncoding(originalNameRaw)
const isImage = isImageByMime(mimeType) || isImageByName(originalName)
const saved = store.insertAttachment({
id: createId(),
+57
View File
@@ -1,5 +1,6 @@
const Database = require('better-sqlite3')
const path = require('path')
const fs = require('fs')
const iconv = require('iconv-lite')
function scoreChinese(str) {
@@ -278,6 +279,59 @@ const deleteItem = id => {
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
}
// 回收站:列出软删除的收藏(含附件),按删除时间倒序
const listTrash = () => {
const rows = getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 1
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all()
return rows.map(row => {
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
})
}
// 回收站:恢复软删除的收藏
const restoreItem = id => {
const info = getDb().prepare(`UPDATE favorites SET deleted = 0, updated_at = ? WHERE id = ? AND deleted = 1`).run(nowIso(), id)
return info.changes > 0
}
// 回收站:彻底删除(物理删除记录 + 磁盘文件 + 清理空目录),不可恢复
const purgeItem = id => {
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 1 LIMIT 1`).get(id)
if (!row) return { ok: false, error: '回收站中不存在该收藏' }
const attachments = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ?`).all(id)
let filesRemoved = 0
let bytesRemoved = 0
const dirsToTry = new Set()
for (const a of attachments) {
const rel = String(a.file_path || '')
if (rel) {
const abs = path.join(process.cwd(), rel)
try {
if (fs.statSync(abs).isFile()) fs.unlinkSync(abs)
filesRemoved++
bytesRemoved += Number(a.file_size || 0)
} catch {}
try { dirsToTry.add(path.dirname(abs)) } catch {}
}
}
const tx = getDb().transaction(() => {
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(id)
getDb().prepare(`DELETE FROM favorites WHERE id = ? AND deleted = 1`).run(id)
})
tx()
for (const dir of dirsToTry) {
try {
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
} catch {}
}
return { ok: true, filesRemoved, bytesRemoved, attachments: attachments.length }
}
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
const createdAt = nowIso()
const payload = {
@@ -324,6 +378,9 @@ module.exports = {
insertItem,
updateItem,
deleteItem,
listTrash,
restoreItem,
purgeItem,
insertAttachment,
getAttachmentById,
getAttachmentsByFavoriteId,