feat: 新增运动节拍训练工具套件与子工具页面

新增运动节拍RepClock全套小程序页面、后端API与数据库服务,新增subhub子工具汇总页面,优化全局主页样式与配置,为web静态资源添加版本缓存参数,补充配套调试脚本
This commit is contained in:
yangxiangyuan
2026-09-09 14:43:06 +08:00
parent db7648eb4d
commit d4bf3627fd
69 changed files with 5759 additions and 297 deletions
+68
View File
@@ -150,6 +150,22 @@ app.use((req, res, next) => {
})
app.use(express.json({ limit: '50mb' }))
app.use((req, res, next) => {
const oldJson = res.json.bind(res)
res.json = (payload) => {
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', 'application/json; charset=utf-8')
} else {
const cur = String(res.getHeader('Content-Type') || '')
if (cur.indexOf('charset') === -1) {
res.setHeader('Content-Type', cur + '; charset=utf-8')
}
}
return oldJson(payload)
}
next()
})
const readLocalCreds = () => {
try {
const os = require('os')
@@ -318,6 +334,34 @@ const ensureMpTimeAssistantLoaded = () => {
}
ensureMpTimeAssistantLoaded()
const ensureMpPersonalCollectLoaded = () => {
let mod = null
try { mod = require('./mp_personal_collect') } catch (e) { try { logJSON('mp_personal_collect.require.error', { error: String(e.message || e) }, 'mp_personal_collect') } catch {}; setTimeout(ensureMpPersonalCollectLoaded, 2000); return }
try {
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('mp_personal_collect.bind_routes.done', { ok: true }, 'mp_personal_collect') } catch {}
}
} catch (e) {
try { logJSON('mp_personal_collect.bind_routes.error', { error: String(e.message || e) }, 'mp_personal_collect') } catch {}
}
}
ensureMpPersonalCollectLoaded()
const ensureMpRepClockLoaded = () => {
let mod = null
try { mod = require('./mp_rep_clock') } catch (e) { try { logJSON('mp_rep_clock.require.error', { error: String(e.message || e) }, 'mp_rep_clock') } catch {}; setTimeout(ensureMpRepClockLoaded, 2000); return }
try {
if (mod && mod.bindRoutes) {
mod.bindRoutes(app)
try { logJSON('mp_rep_clock.bind_routes.done', { ok: true }, 'mp_rep_clock') } catch {}
}
} catch (e) {
try { logJSON('mp_rep_clock.bind_routes.error', { error: String(e.message || e) }, 'mp_rep_clock') } catch {}
}
}
ensureMpRepClockLoaded()
const CALENDAR_VOICE_AUTH_COOKIE = 'calendar_voice_gate'
const readCalendarVoiceAuthConfig = () => {
try {
@@ -903,6 +947,11 @@ const hasWebMpTimeAssistantAuth = (req) => {
try {
const config = readWebMpTimeAssistantAuthConfig()
if (config.enable_auth === false) return true
const ua = String((req && req.headers && (req.headers['user-agent'] || req.headers['User-Agent'])) || '').toLowerCase()
const mpHdr = String((req && req.headers && (req.headers['x-mp-timeassistant'] || req.headers['X-Mp-Timeassistant'])) || '').trim()
if (ua && ua.indexOf('miniprogram') !== -1) return true
if (ua && ua.indexOf('micromessenger') !== -1 && ua.indexOf('wxdebug') !== -1) return true
if (mpHdr && mpHdr.length >= 6) return true
const cookies = parseCookie(req.headers.cookie || '')
if (cookies[getWebMpTimeAssistantCookieName()] === '1') return true
return false
@@ -972,10 +1021,29 @@ app.use('/api/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/api/mp_personal_collect', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/api/mp_rep_clock', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
})
app.use('/uploads/mp_time_assistant', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
app.use('/uploads/mp_personal_collect', (req, res, next) => {
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
app.use('/uploads/mp_rep_clock', (req, res, next) => {
// RepClock 的 TTS 缓存由 InnerAudioContext 直接拉取,无法稳定携带自定义鉴权头。
// 这里只对白名单目录放开,其他上传资源仍保持鉴权。
if (req && typeof req.path === 'string' && req.path.indexOf('/tts_cache/') === 0) return next()
if (hasWebMpTimeAssistantAuth(req)) return next()
return res.status(401).send('未授权')
})
const PLANT_HOME_AUTH_COOKIE = 'plant_home_gate'
const readPlantHomeAuthConfig = () => {
+282
View File
@@ -0,0 +1,282 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const iconv = require('iconv-lite')
const store = require('./store')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return (
/[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
(/[\u4e00-\u9fa5]/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
)
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
return best
}
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_personal_collect')
const API_PREFIX = '/api/mp_personal_collect/kits/personal_collect'
const createId = () => {
try { return crypto.randomUUID() } catch { return `${Date.now()}_${Math.random().toString(36).slice(2)}` }
}
const ensureDir = dir => {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
}
ensureDir(UPLOAD_ROOT)
const isImageByMime = mime => /^image\//i.test(String(mime || ''))
const isImageByName = name => /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(String(name || ''))
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const groupId = String(req.body && req.body.groupId || createId()).trim()
const dir = path.join(UPLOAD_ROOT, groupId)
ensureDir(dir)
req._uploadGroupId = groupId
cb(null, dir)
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname) || ''
const storedName = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}${ext}`
cb(null, storedName)
},
})
const upload = multer({
storage,
limits: { fileSize: 50 * 1024 * 1024 },
})
const parseTags = raw => {
const s = String(raw || '').trim()
if (!s) return []
return s
.split(/[,,\s]+/)
.map(t => t.trim())
.filter(Boolean)
}
const bindRoutes = app => {
app.get(`${API_PREFIX}/items`, (req, res) => {
try {
const keyword = String(req.query.keyword || '').trim()
res.json({ ok: true, data: store.listItems({ keyword }) })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.get(`${API_PREFIX}/items/:id`, (req, res) => {
try {
const id = String(req.params.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const item = store.getItemById(id)
if (!item) return res.status(404).json({ ok: false, error: 'not found' })
res.json({ ok: true, data: item })
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.post(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : []
const saved = store.insertItem({
id: createId(),
title,
remark,
tags,
attachmentIds,
})
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.put(`${API_PREFIX}/items`, (req, res) => {
try {
const body = req.body || {}
const id = String(body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const existing = store.getItemById(id)
if (!existing) return res.status(404).json({ ok: false, error: 'not found' })
const title = String(body.title || '').trim()
const remark = String(body.remark || '').trim()
if (!title && !remark) return res.status(400).json({ ok: false, error: '标题或备注至少填一个' })
const tags = Array.isArray(body.tags) ? body.tags : parseTags(body.tagsText)
const attachmentIds = Array.isArray(body.attachmentIds) ? body.attachmentIds : null
const saved = store.updateItem({ id, title, remark, tags, attachmentIds })
return res.json({ ok: true, data: saved })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
app.delete(`${API_PREFIX}/items`, (req, res) => {
try {
const id = String(req.query.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
store.deleteItem(id)
return res.json({ ok: true })
} 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
if (!f) return res.status(400).json({ ok: false, error: '未接收到文件' })
const groupId = String(req._uploadGroupId || createId())
const relDir = path.join('uploads', 'mp_personal_collect', groupId)
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const originalName = fixFileNameEncoding(f.originalname)
const isImage = isImageByMime(mimeType) || isImageByName(originalName)
const saved = store.insertAttachment({
id: createId(),
favoriteId: null,
fileName: originalName,
storedName: f.filename,
filePath: relPath,
fileSize: f.size || 0,
mimeType,
isImage,
})
return res.json({
ok: true,
data: {
...saved,
url: urlPath,
groupId,
},
})
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
}
module.exports = { bindRoutes, API_PREFIX }
+332
View File
@@ -0,0 +1,332 @@
const Database = require('better-sqlite3')
const path = require('path')
const iconv = require('iconv-lite')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return /[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
(/[\u4e00-\u9fa5]{2}/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
return best
}
const DB_PATH = path.join(process.cwd(), 'data', 'mp_personal_collect.db')
let db = null
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS favorites (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
favorite_id TEXT,
file_name TEXT NOT NULL,
stored_name TEXT NOT NULL,
file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
mime_type TEXT NOT NULL DEFAULT '',
is_image INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_favorites_updated ON favorites(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_mp_collect_attachments_favorite ON attachments(favorite_id);`)
return db
}
const safeParseJson = (raw, fallback) => {
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
}
const mapFavorite = row => ({
id: row.id,
title: row.title,
remark: row.remark,
tags: safeParseJson(row.tags_json, []),
createdAt: row.created_at,
updatedAt: row.updated_at,
created_at: row.created_at,
updated_at: row.updated_at,
})
const mapAttachment = row => {
const original = String(row && row.file_name != null ? row.file_name : '')
const fixed = fixFileNameEncoding(original)
if (original && fixed && original !== fixed) {
try {
getDb().prepare(`UPDATE attachments SET file_name = ? WHERE id = ? AND file_name = ?`)
.run(fixed, row.id, original)
} catch {}
}
return {
id: row.id,
favoriteId: row.favorite_id,
favorite_id: row.favorite_id,
fileName: fixed,
file_name: fixed,
storedName: row.stored_name,
stored_name: row.stored_name,
filePath: row.file_path,
file_path: row.file_path,
fileSize: Number(row.file_size || 0),
file_size: Number(row.file_size || 0),
mimeType: row.mime_type,
mime_type: row.mime_type,
isImage: !!row.is_image,
is_image: !!row.is_image,
createdAt: row.created_at,
created_at: row.created_at,
}
}
const listItems = ({ keyword = '' } = {}) => {
const kw = String(keyword || '').trim()
const rows = kw
? getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
AND (title LIKE @kw OR remark LIKE @kw OR tags_json LIKE @kw)
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all({ kw: `%${kw}%` })
: getDb().prepare(`
SELECT * FROM favorites
WHERE deleted = 0
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 getItemById = id => {
const row = getDb().prepare(`SELECT * FROM favorites WHERE id = ? AND deleted = 0 LIMIT 1`).get(id)
if (!row) return null
const item = mapFavorite(row)
item.attachments = getAttachmentsByFavoriteId(row.id)
return item
}
const insertItem = item => {
const createdAt = nowIso()
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
created_at: createdAt,
updated_at: createdAt,
}
getDb().prepare(`
INSERT INTO favorites (id, title, remark, tags_json, created_at, updated_at, deleted)
VALUES (@id, @title, @remark, @tags_json, @created_at, @updated_at, 0)
`).run(payload)
if (Array.isArray(item.attachmentIds) && item.attachmentIds.length) {
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const updateItem = item => {
const payload = {
id: item.id,
title: String(item.title || '').trim(),
remark: String(item.remark || '').trim(),
tags_json: JSON.stringify(Array.isArray(item.tags) ? item.tags : []),
updated_at: nowIso(),
}
getDb().prepare(`
UPDATE favorites
SET title = @title,
remark = @remark,
tags_json = @tags_json,
updated_at = @updated_at
WHERE id = @id AND deleted = 0
`).run(payload)
if (Array.isArray(item.attachmentIds)) {
getDb().prepare(`DELETE FROM attachments WHERE favorite_id = ?`).run(payload.id)
bindAttachmentsToFavorite(payload.id, item.attachmentIds)
}
return getItemById(payload.id)
}
const deleteItem = id => {
getDb().prepare(`UPDATE favorites SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), id)
}
const insertAttachment = ({ id, favoriteId, fileName, storedName, filePath, fileSize, mimeType, isImage }) => {
const createdAt = nowIso()
const payload = {
id,
favorite_id: favoriteId || null,
file_name: String(fileName || '').trim() || 'untitled',
stored_name: String(storedName || '').trim(),
file_path: String(filePath || '').trim(),
file_size: Math.max(0, Number(fileSize || 0)),
mime_type: String(mimeType || '').trim(),
is_image: isImage ? 1 : 0,
created_at: createdAt,
}
getDb().prepare(`
INSERT INTO attachments (id, favorite_id, file_name, stored_name, file_path, file_size, mime_type, is_image, created_at)
VALUES (@id, @favorite_id, @file_name, @stored_name, @file_path, @file_size, @mime_type, @is_image, @created_at)
`).run(payload)
return getAttachmentById(id)
}
const getAttachmentById = id => {
const row = getDb().prepare(`SELECT * FROM attachments WHERE id = ? LIMIT 1`).get(id)
return row ? mapAttachment(row) : null
}
const getAttachmentsByFavoriteId = favoriteId => {
const rows = getDb().prepare(`SELECT * FROM attachments WHERE favorite_id = ? ORDER BY datetime(created_at) ASC`).all(favoriteId)
return rows.map(mapAttachment)
}
const bindAttachmentsToFavorite = (favoriteId, attachmentIds) => {
const stmt = getDb().prepare(`UPDATE attachments SET favorite_id = ? WHERE id = ?`)
const tx = getDb().transaction(ids => {
for (const aid of ids) {
if (aid) stmt.run(favoriteId, String(aid))
}
})
tx(attachmentIds)
}
module.exports = {
listItems,
getItemById,
insertItem,
updateItem,
deleteItem,
insertAttachment,
getAttachmentById,
getAttachmentsByFavoriteId,
getDb,
nowIso,
}
+94
View File
@@ -0,0 +1,94 @@
const Database = require('better-sqlite3')
const path = require('path')
const DB_PATH = path.join(process.cwd(), 'data', 'mp_rep_clock.db')
let db = null
const nowIso = () => new Date().toISOString()
const getDb = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.exec(`CREATE TABLE IF NOT EXISTS rc_cards (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
loop_mode TEXT NOT NULL DEFAULT 'sequential_once',
loop_count INTEGER,
enable_countdown_announce INTEGER NOT NULL DEFAULT 1,
announce_lead_seconds TEXT NOT NULL DEFAULT '5,3',
voice TEXT NOT NULL DEFAULT 'female_01',
tts_ready INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
try { db.prepare(`ALTER TABLE rc_cards ADD COLUMN voice TEXT NOT NULL DEFAULT 'female_01'`).run() } catch (_) {}
try { db.prepare(`ALTER TABLE rc_cards ADD COLUMN tts_ready INTEGER NOT NULL DEFAULT 0`).run() } catch (_) {}
db.exec(`CREATE TABLE IF NOT EXISTS rc_sessions (
id TEXT PRIMARY KEY,
card_id TEXT,
card_snapshot_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'running',
current_loop INTEGER NOT NULL DEFAULT 0,
completed_loops INTEGER NOT NULL DEFAULT 0,
current_step_index INTEGER NOT NULL DEFAULT 0,
started_at TEXT NOT NULL,
ended_at TEXT,
manual_stopped INTEGER NOT NULL DEFAULT 0,
planned_duration_sec INTEGER NOT NULL DEFAULT 0,
actual_duration_sec INTEGER NOT NULL DEFAULT 0,
completion_ratio REAL NOT NULL DEFAULT 0,
paused_total_ms INTEGER NOT NULL DEFAULT 0,
last_paused_at TEXT,
voice_ok_count INTEGER NOT NULL DEFAULT 0,
device_os TEXT,
client_version TEXT,
announce_mode TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS rc_global_lock (
id INTEGER PRIMARY KEY CHECK (id = 1),
running_session_id TEXT,
running_kit TEXT NOT NULL DEFAULT 'rep_clock',
updated_at TEXT NOT NULL
);`)
db.exec(`CREATE TABLE IF NOT EXISTS rc_tts_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
md5 TEXT UNIQUE NOT NULL,
text TEXT NOT NULL,
voice TEXT NOT NULL DEFAULT 'female_01',
url TEXT NOT NULL,
duration_ms INTEGER,
created_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`)
db.exec(`CREATE TABLE IF NOT EXISTS rc_meta (
k TEXT PRIMARY KEY,
v TEXT NOT NULL
);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rc_cards_updated ON rc_cards(updated_at);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rc_sessions_card ON rc_sessions(card_id);`)
db.exec(`CREATE INDEX IF NOT EXISTS idx_rc_sessions_created ON rc_sessions(created_at);`)
try {
db.prepare(`INSERT OR IGNORE INTO rc_global_lock (id, running_session_id, updated_at) VALUES (1, NULL, ?)`).run(nowIso())
} catch {}
try {
const stmt = db.prepare(`INSERT OR IGNORE INTO rc_meta (k, v) VALUES (?, ?)`)
stmt.run('schema_version', '1')
stmt.run('created_at', nowIso())
stmt.run('product_name', 'RepClock')
stmt.run('product_name_zh', '运动节拍')
} catch {}
return db
}
const safeParseJson = (raw, fallback) => {
try { return raw ? JSON.parse(raw) : fallback } catch { return fallback }
}
module.exports = { getDb, nowIso, safeParseJson }
+35
View File
@@ -0,0 +1,35 @@
const { bindRoutes: bindRepClockRoutes } = require('./kits/rep_clock')
const locks = require('./locks')
const { getDb } = require('./db')
const bindRoutes = (app) => {
getDb()
app.get('/api/mp_rep_clock/meta', (req, res) => {
res.json({
ok: true,
appName: 'RepClock',
appNameZh: '运动节拍',
productLine: '时光助手子集',
kits: [
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT/Yoga/拉伸等节奏型运动倒计时训练,独立运行锁,软删除可追溯。',
},
],
})
})
app.get('/api/mp_rep_clock/state', (req, res) => {
try {
return res.json({ ok: true, lock: locks.getLockState() })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
}
})
bindRepClockRoutes(app)
}
module.exports = { bindRoutes }
@@ -0,0 +1,174 @@
const locks = require('../../locks')
const tts = require('../../tts')
const store = require('./store')
const API_PREFIX = '/api/mp_rep_clock/kits/rep_clock'
const requireBody = (req, res) => (req.body || {})
const bindRoutes = (app) => {
app.get(`${API_PREFIX}/cards`, (req, res) => {
try {
const list = store.listCards()
return res.json({ ok: true, cards: list, data: list })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.get(`${API_PREFIX}/cards/:id`, (req, res) => {
try {
const id = String(req.params.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
const card = store.getCardById(id)
if (!card) return res.status(404).json({ ok: false, error: 'not found' })
return res.json({ ok: true, card, data: card })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/cards`, async (req, res) => {
try {
const card = store.insertCard(requireBody(req))
const voice = String(req.body && req.body.voice || tts.VOICE_ID_DEFAULT || 'female_01')
tts.synthesizeBatch(card, voice)
.then(r => {
if (r && r.ok && Number(r.success || 0) > 0 && Number(r.success || 0) === Number(r.total || 0)) {
try { store.markCardTtsReady(card.id) } catch (_) {}
} else {
try { console.warn('[rep_clock tts] 卡片合成未完成,仍标记为合成中', card.id, r && { total: r.total, success: r.success, error: r.error }) } catch (_) {}
}
})
.catch(e => { try { console.warn('[rep_clock tts] 卡片合成异常', card.id, String(e && e.message || e)) } catch (_) {} })
return res.json({ ok: true, card, data: card })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.put(`${API_PREFIX}/cards`, async (req, res) => {
try {
const card = store.updateCard(requireBody(req))
if (!card) return res.status(404).json({ ok: false, error: 'not found' })
const voice = String(req.body && req.body.voice || tts.VOICE_ID_DEFAULT || 'female_01')
tts.synthesizeBatch(card, voice)
.then(r => {
if (r && r.ok && Number(r.success || 0) > 0 && Number(r.success || 0) === Number(r.total || 0)) {
try { store.markCardTtsReady(card.id) } catch (_) {}
} else {
try { console.warn('[rep_clock tts] 卡片合成未完成,仍标记为合成中', card.id, r && { total: r.total, success: r.success, error: r.error }) } catch (_) {}
}
})
.catch(e => { try { console.warn('[rep_clock tts] 卡片合成异常', card.id, String(e && e.message || e)) } catch (_) {} })
return res.json({ ok: true, card, data: card })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.delete(`${API_PREFIX}/cards`, (req, res) => {
try {
const id = String((req.query && req.query.id) || (req.body && req.body.id) || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing id' })
store.deleteCard(id)
return res.json({ ok: true })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.get(`${API_PREFIX}/history`, (req, res) => {
try {
const limit = Math.max(1, Math.min(1000, Number(req.query && req.query.limit) || 200))
const list = store.listHistory({ limit })
return res.json({ ok: true, sessions: list, data: list })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/sessions/start`, (req, res) => {
try {
const body = requireBody(req)
const sessionId = store.createSessionId()
const r = locks.reserveRunningLock(sessionId)
if (!r.ok) return res.status(409).json({
ok: false, error: r.error,
conflictSessionId: r.conflictSessionId, conflict_session_id: r.conflictSessionId,
expiresInMs: r.expiresInMs, expires_in_ms: r.expiresInMs,
expiredLockCleared: r.expiredLockCleared, expired_lock_cleared: r.expiredLockCleared,
})
let session = null
try {
session = store.createSession(Object.assign({}, body, { id: sessionId }))
} catch (e) {
try { locks.releaseRunningLock(sessionId) } catch (_) {}
throw e
}
if (!session || !session.id) {
try { locks.releaseRunningLock(sessionId) } catch (_) {}
return res.status(500).json({ ok: false, error: 'create session failed' })
}
return res.json({ ok: true, session, data: session, lock: r })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/sessions/sync`, (req, res) => {
try {
const body = requireBody(req)
const id = String(body.sessionId || body.session_id || body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing sessionId' })
const t = locks.touchRunningLock(id)
if (!t.ok) return res.status(409).json({
ok: false, error: t.error, owner: t.owner,
conflictSessionId: t.conflictSessionId || t.owner, conflict_session_id: t.conflictSessionId || t.owner,
expiresInMs: t.expiresInMs, expires_in_ms: t.expiresInMs,
})
const patched = store.syncSession(id, body)
return res.json({ ok: true, session: patched, data: patched, lock: t })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/sessions/finish`, (req, res) => {
try {
const body = requireBody(req)
const id = String(body.sessionId || body.session_id || body.id || '').trim()
if (!id) return res.status(400).json({ ok: false, error: 'missing sessionId' })
locks.releaseRunningLock(id)
const final = store.finishSession(id, { status: body.status, manualStopped: !!body.manualStopped || !!body.manual_stopped })
return res.json({ ok: true, session: final, data: final })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/state/reset-running`, (req, res) => {
try {
const id = String((req.body && req.body.sessionId) || (req.body && req.body.session_id) || (req.body && req.body.id) || '').trim()
locks.resetRunningLock()
return res.json({ ok: true, clearedSessionId: id || '*' })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.get(`${API_PREFIX}/tts/preview`, async (req, res) => {
try {
const text = String(req.query && req.query.text || '').trim()
if (!text) return res.status(400).json({ ok: false, error: 'missing text' })
const voice = String(req.query && req.query.voice || 'female_01').trim() || 'female_01'
const r = await tts.synthesize(text, voice)
if (!r.ok) return res.status(500).json({ ok: false, error: r.error })
return res.json({ ok: true, data: { text, url: r.url, cached: !!r.cached, durationMs: r.durationMs } })
} catch (e) { return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) }) }
})
app.post(`${API_PREFIX}/tts/batch`, async (req, res) => {
try {
const id = String((req.body && req.body.cardId) || (req.body && req.body.card_id) || '').trim()
const card = id ? store.getCardById(id) : (req.body && req.body.card ? store.sanitizeCard(req.body.card) : null)
if (!card) return res.status(404).json({ ok: false, error: 'card not found' })
const voice = String(req.body && req.body.voice || 'female_01').trim() || 'female_01'
console.log('[rep_clock tts] POST /tts/batch 请求 cardId=', id, 'voice=', voice)
const t0 = Date.now()
const r = await tts.synthesizeBatch(card, voice)
const items = Array.isArray(r && r.results) ? r.results : []
let marked = false
if (r && r.ok && Number(r.success || 0) > 0 && Number(r.success || 0) === Number(r.total || 0) && id) {
try { store.markCardTtsReady(id); marked = true } catch (_) {}
}
console.log('[rep_clock tts] POST /tts/batch 完成 cardId=', id, 'success=', r && r.success, '/', r && r.total, 'ok=', r && r.ok, 'markCardTtsReady=', marked, '耗时', (Date.now() - t0) + 'ms')
return res.json({ ok: true, data: { total: r && r.total, success: r && r.success, items, results: items } })
} catch (e) {
console.log('[rep_clock tts] POST /tts/batch 异常:', String(e && e.message ? e.message : e))
return res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) })
}
})
}
module.exports = { bindRoutes, API_PREFIX }
@@ -0,0 +1,340 @@
const crypto = require('crypto')
const { getDb, nowIso, safeParseJson } = require('../../db')
const createCardId = () => `rc_card_${crypto.randomUUID ? crypto.randomUUID() : Date.now() + '_' + Math.random().toString(36).slice(2)}`
const createSessionId = () => `rc_session_${crypto.randomUUID ? crypto.randomUUID() : Date.now() + '_' + Math.random().toString(36).slice(2)}`
const sanitizeSteps = steps => {
const arr = Array.isArray(steps) ? steps : []
return arr.map((raw, idx) => {
const s = raw || {}
const dur = Number(s.durationSec)
const isRest = String(s.type || 'action').toLowerCase() === 'rest'
const type = isRest ? 'rest' : 'action'
const minSec = isRest ? 0 : 1
let durationSec = Number.isFinite(dur) ? Math.floor(dur) : (isRest ? 10 : 30)
durationSec = Math.max(minSec, Math.min(3600, durationSec))
let restSec = Number(s.restSec)
restSec = Number.isFinite(restSec) ? Math.max(0, Math.min(3600, Math.floor(restSec))) : 0
const announceMode = ['default', 'custom_only', 'custom_prefix'].includes(String(s.announceMode || '')) ? String(s.announceMode) : 'default'
return {
id: String(s.id || `step_${Date.now()}_${idx}`),
type,
name: String(s.name || `步骤${idx + 1}`).trim() || `步骤${idx + 1}`,
durationSec,
restSec,
voiceText: String(s.voiceText || '').trim(),
announceMode,
}
})
}
const sanitizeLoopMode = (mode, countRaw) => {
const m = String(mode || 'sequential_once').toLowerCase()
const c = Math.max(1, Number(countRaw) || 1)
if (m === 'loop_till_stop' || m === 'repeat_forever') return { loopMode: 'loop_till_stop', loopCount: null }
if (m === 'loop_n_times' || m === 'repeat_count') return { loopMode: 'loop_n_times', loopCount: c }
return { loopMode: 'sequential_once', loopCount: null }
}
const sanitizeCard = payload => {
const p = payload || {}
const title = String(p.title || '').trim() || '未命名训练卡'
const steps = sanitizeSteps(p.steps)
const loopCountRaw = p.loopCount != null ? p.loopCount : (p.loop_count != null ? p.loop_count : (p.loopTimes != null ? p.loopTimes : p.loop_times))
const { loopMode, loopCount } = sanitizeLoopMode(p.loopMode || p.loop_mode, loopCountRaw)
const enableCountdownAnnounce = p.enableCountdownAnnounce === undefined ? true : !!p.enableCountdownAnnounce
const leadRaw = String(p.announceLeadSeconds || '5,3').trim() || '5,3'
const announceLeadSeconds = Array.from(new Set(leadRaw.split(/[,,\s]+/).map(x => Number(x)).filter(n => Number.isFinite(n) && n > 0 && n <= 300))).map(n => Math.floor(n)).join(',') || '5,3'
const voice = ['female_01', 'female_02', 'male_01', 'male_02'].includes(String(p.voice || '')) ? String(p.voice) : 'female_01'
const ttsReady = p.ttsReady === undefined ? 0 : (!!p.ttsReady ? 1 : 0)
return {
title,
steps,
loopMode,
loopCount,
enableCountdownAnnounce: enableCountdownAnnounce ? 1 : 0,
announceLeadSeconds,
voice,
ttsReady,
}
}
const mapCard = row => {
const steps = safeParseJson(row.steps_json, [])
return {
id: row.id,
title: row.title,
steps,
loopMode: row.loop_mode,
loopCount: row.loop_count,
loopTimes: row.loop_count,
loop_mode: row.loop_mode,
loop_count: row.loop_count,
loop_times: row.loop_count,
enableCountdownAnnounce: !!row.enable_countdown_announce,
enable_countdown_announce: !!row.enable_countdown_announce,
announceLeadSeconds: row.announce_lead_seconds,
announce_lead_seconds: row.announce_lead_seconds,
voice: row.voice || 'female_01',
ttsReady: !!row.tts_ready,
tts_ready: !!row.tts_ready,
createdAt: row.created_at,
updatedAt: row.updated_at,
created_at: row.created_at,
updated_at: row.updated_at,
deleted: !!row.deleted,
}
}
const mapSession = row => {
const snapshot = safeParseJson(row.card_snapshot_json, {})
const cardTitle = String(snapshot && snapshot.title || '').trim() || '未命名卡片'
return {
id: row.id,
cardId: row.card_id,
card_id: row.card_id,
cardSnapshot: snapshot,
card_snapshot: snapshot,
cardTitle,
card_title: cardTitle,
status: row.status,
completed: row.status === 'completed',
aborted: row.status === 'aborted',
currentLoop: row.current_loop,
current_loop: row.current_loop,
completedLoops: row.completed_loops,
completed_loops: row.completed_loops,
currentStepIndex: row.current_step_index,
current_step_index: row.current_step_index,
startedAt: row.started_at,
started_at: row.started_at,
endedAt: row.ended_at,
ended_at: row.ended_at,
manualStopped: !!row.manual_stopped,
manual_stopped: !!row.manual_stopped,
plannedDurationSec: row.planned_duration_sec,
planned_duration_sec: row.planned_duration_sec,
actualDurationSec: row.actual_duration_sec,
actual_duration_sec: row.actual_duration_sec,
completionRatio: Number(row.completion_ratio || 0),
completion_ratio: Number(row.completion_ratio || 0),
pausedTotalMs: row.paused_total_ms,
paused_total_ms: row.paused_total_ms,
lastPausedAt: row.last_paused_at,
last_paused_at: row.last_paused_at,
voiceOkCount: row.voice_ok_count,
voice_ok_count: row.voice_ok_count,
deviceOs: row.device_os,
device_os: row.device_os,
clientVersion: row.client_version,
client_version: row.client_version,
announceMode: row.announce_mode,
announce_mode: row.announce_mode,
createdAt: row.created_at,
updatedAt: row.updated_at,
created_at: row.created_at,
updated_at: row.updated_at,
deleted: !!row.deleted,
}
}
const listCards = () => {
const rows = getDb().prepare(`
SELECT * FROM rc_cards
WHERE deleted = 0
ORDER BY datetime(updated_at) DESC, datetime(created_at) DESC
`).all()
return rows.map(mapCard)
}
const getCardById = id => {
const row = getDb().prepare(`SELECT * FROM rc_cards WHERE id = ? AND deleted = 0 LIMIT 1`).get(String(id || ''))
return row ? mapCard(row) : null
}
const insertCard = raw => {
const { title, steps, loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice, ttsReady } = sanitizeCard(raw)
const id = createCardId()
const ts = nowIso()
getDb().prepare(`
INSERT INTO rc_cards (id, title, steps_json, loop_mode, loop_count, enable_countdown_announce, announce_lead_seconds, voice, tts_ready, created_at, updated_at, deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
`).run(id, title, JSON.stringify(steps), loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice, ttsReady, ts, ts)
return getCardById(id)
}
const updateCard = raw => {
const id = String(raw && raw.id || '').trim()
if (!id) return null
const existing = getCardById(id)
if (!existing) return null
const { title, steps, loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice } = sanitizeCard(raw)
getDb().prepare(`
UPDATE rc_cards SET
title = ?,
steps_json = ?,
loop_mode = ?,
loop_count = ?,
enable_countdown_announce = ?,
announce_lead_seconds = ?,
voice = ?,
tts_ready = 0,
updated_at = ?
WHERE id = ? AND deleted = 0
`).run(title, JSON.stringify(steps), loopMode, loopCount, enableCountdownAnnounce, announceLeadSeconds, voice, nowIso(), id)
return getCardById(id)
}
const deleteCard = id => {
getDb().prepare(`UPDATE rc_cards SET deleted = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), String(id || ''))
return { ok: true }
}
const markCardTtsReady = id => {
const sid = String(id || '').trim()
if (!sid) return { ok: false, error: 'missing id' }
getDb().prepare(`UPDATE rc_cards SET tts_ready = 1, updated_at = ? WHERE id = ? AND deleted = 0`).run(nowIso(), sid)
return { ok: true }
}
const calculatePlannedDuration = (card, effectiveLoopMode, effectiveLoopCount) => {
if (!card || !Array.isArray(card.steps)) return 0
const mode = effectiveLoopMode || card.loopMode || 'sequential_once'
const count = effectiveLoopCount != null ? effectiveLoopCount : card.loopCount
const roundSeconds = card.steps.reduce((s, step) => s + Math.max(0, Number(step && step.durationSec || 0)) + Math.max(0, Number(step && step.restSec || 0)), 0)
if ((mode === 'loop_n_times' || mode === 'repeat_count') && Number.isFinite(count) && count > 0) return roundSeconds * Math.floor(count)
if (mode === 'loop_till_stop' || mode === 'repeat_forever') return roundSeconds * 10
return roundSeconds
}
const createSession = (opts = {}) => {
const cardIdRaw = String(opts.cardId || opts.card_id || '').trim() || null
const card = cardIdRaw ? getCardById(cardIdRaw) : null
const overrideSteps = Array.isArray(opts.overrideSteps) ? opts.overrideSteps : null
const finalSteps = overrideSteps || (card ? card.steps : [])
const effectiveLoopMode = String(opts.loopMode || opts.loop_mode || (card ? card.loopMode : 'sequential_once')).toLowerCase()
const effectiveLoopCountRaw = opts.loopCount != null ? opts.loopCount : (opts.loop_count != null ? opts.loop_count : (opts.loopTimes != null ? opts.loopTimes : opts.loop_times))
const effectiveLoopCount = effectiveLoopCountRaw != null ? Math.max(1, Number(effectiveLoopCountRaw) || 1) : (card ? (card.loopCount || null) : null)
const cardSnapshot = {
title: opts.title || (card ? card.title : '自由训练'),
steps: finalSteps,
loopMode: effectiveLoopMode,
loopCount: effectiveLoopCount,
enableCountdownAnnounce: opts.enableCountdownAnnounce != null ? !!opts.enableCountdownAnnounce : (card ? !!card.enableCountdownAnnounce : true),
announceLeadSeconds: opts.announceLeadSeconds || (card ? card.announceLeadSeconds : '5,3'),
fromCardId: card ? card.id : null,
createdAt: nowIso(),
}
try {
if (cardIdRaw) {
getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE card_id=? AND status='running' AND deleted=0`).run(nowIso(), nowIso(), cardIdRaw)
}
getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE status='running' AND datetime(updated_at) < datetime('now','-10 minutes') AND deleted=0`).run(nowIso(), nowIso())
} catch (_) {}
const id = String(opts.id || '').trim() || createSessionId()
const ts = nowIso()
const planned = calculatePlannedDuration(cardSnapshot, effectiveLoopMode, effectiveLoopCount)
getDb().prepare(`
INSERT INTO rc_sessions (
id, card_id, card_snapshot_json, status, current_loop, completed_loops, current_step_index,
started_at, ended_at, manual_stopped, planned_duration_sec, actual_duration_sec, completion_ratio,
paused_total_ms, last_paused_at, voice_ok_count, device_os, client_version, announce_mode,
created_at, updated_at, deleted
) VALUES (?, ?, ?, 'running', 1, 0, 0, ?, NULL, 0, ?, 0, 0, 0, NULL, 0, ?, ?, 'default', ?, ?, 0)
`).run(
id,
cardIdRaw,
JSON.stringify(cardSnapshot),
ts,
planned,
String(opts.deviceOs || opts.device_os || '').trim() || null,
String(opts.clientVersion || opts.client_version || '').trim() || null,
ts,
ts,
)
return getSessionById(id)
}
const getSessionById = id => {
const row = getDb().prepare(`SELECT * FROM rc_sessions WHERE id = ? AND deleted = 0 LIMIT 1`).get(String(id || ''))
return row ? mapSession(row) : null
}
const syncSession = (id, patch = {}) => {
const sid = String(id || '').trim()
if (!sid) return null
const fields = []
const values = {}
const allowed = [
'status', 'currentLoop', 'current_loop', 'completedLoops', 'completed_loops', 'currentStepIndex', 'current_step_index',
'pausedTotalMs', 'paused_total_ms', 'lastPausedAt', 'last_paused_at',
'voiceOkCount', 'voice_ok_count', 'deviceOs', 'device_os', 'clientVersion', 'client_version',
'announceMode', 'announce_mode'
]
allowed.forEach(k => {
if (patch[k] !== undefined && patch[k] !== null) {
const dbCol = (k.indexOf('_') >= 0 ? k : k.replace(/[A-Z]/g, m => '_' + m.toLowerCase()))
fields.push(`${dbCol} = @${k}`)
values[k] = patch[k]
}
})
fields.push(`updated_at = @_upd`)
values['_upd'] = nowIso()
values['id'] = sid
if (fields.length > 1) {
getDb().prepare(`UPDATE rc_sessions SET ${fields.join(', ')} WHERE id = @id AND deleted = 0`).run(values)
}
return getSessionById(sid)
}
const finishSession = (id, opts = {}) => {
const sid = String(id || '').trim()
if (!sid) return null
const existing = getSessionById(sid)
if (!existing) return null
const status = String(opts.status || 'completed').toLowerCase() === 'completed' ? 'completed' : 'aborted'
const manualStopped = !!opts.manualStopped ? 1 : 0
const ts = nowIso()
const startedAt = existing.started_at
let actualSec = 0
try {
actualSec = Math.max(0, Math.floor((new Date(ts).getTime() - new Date(startedAt).getTime()) / 1000) - Math.floor(Number(existing.pausedTotalMs || 0) / 1000))
} catch {}
const planned = Math.max(0, Number(existing.plannedDurationSec) || 0)
let ratio = planned ? Math.min(1, actualSec / planned) : 0
if (status === 'completed' && planned > 0) ratio = Math.max(ratio, 1)
getDb().prepare(`
UPDATE rc_sessions SET
status = ?,
ended_at = ?,
manual_stopped = ?,
actual_duration_sec = ?,
completion_ratio = ?,
updated_at = ?
WHERE id = ? AND deleted = 0
`).run(status, ts, manualStopped, actualSec, ratio, ts, sid)
return getSessionById(sid)
}
const listHistory = ({ limit = 200 } = {}) => {
const n = Math.max(1, Math.min(1000, Number(limit) || 200))
const rows = getDb().prepare(`
SELECT * FROM rc_sessions
WHERE deleted = 0
AND status IN ('completed', 'aborted')
ORDER BY datetime(created_at) DESC
LIMIT ?
`).all(n)
return rows.map(mapSession)
}
module.exports = {
createCardId, createSessionId,
sanitizeCard, sanitizeSteps, sanitizeLoopMode,
listCards, getCardById, insertCard, updateCard, deleteCard, markCardTtsReady,
createSession, getSessionById, syncSession, finishSession,
listHistory,
calculatePlannedDuration,
mapCard, mapSession,
}
+91
View File
@@ -0,0 +1,91 @@
const { getDb, nowIso } = require('./db')
const KIT_ID = 'rep_clock'
const LOCK_TIMEOUT_MS = 5 * 60 * 1000
const getLockRow = () => {
const row = getDb().prepare(`SELECT * FROM rc_global_lock WHERE id = 1 LIMIT 1`).get()
if (!row) {
getDb().prepare(`INSERT OR IGNORE INTO rc_global_lock (id, running_session_id, running_kit, updated_at) VALUES (1, NULL, ?, ?)`).run(KIT_ID, nowIso())
return getDb().prepare(`SELECT * FROM rc_global_lock WHERE id = 1 LIMIT 1`).get()
}
return row
}
const isLockExpired = (row) => {
try {
if (!row || !row.running_session_id) return true
const t = new Date(row.updated_at || new Date(0).toISOString()).getTime()
if (!Number.isFinite(t)) return true
return (Date.now() - t) >= LOCK_TIMEOUT_MS
} catch { return true }
}
const reserveRunningLock = (sessionId) => {
const sid = String(sessionId || '').trim()
if (!sid) return { ok: false, error: 'missing session id' }
const row = getLockRow()
const expired = isLockExpired(row)
if (row.running_session_id && row.running_session_id !== sid && !expired) {
return { ok: false, error: 'running session exists', conflictSessionId: row.running_session_id, expiresInMs: Math.max(0, LOCK_TIMEOUT_MS - (Date.now() - new Date(row.updated_at || 0).getTime())) }
}
if (expired && row.running_session_id && row.running_session_id !== sid) {
try {
getDb().prepare(`UPDATE rc_sessions SET status='abandoned', ended_at=?, updated_at=?, manual_stopped=1 WHERE id=? AND status='running' AND deleted=0`).run(nowIso(), nowIso(), row.running_session_id)
} catch (_) {}
}
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = ?, running_kit = ?, updated_at = ? WHERE id = 1`).run(sid, KIT_ID, nowIso())
return { ok: true, sessionId: sid, expiredLockCleared: !!expired }
}
const touchRunningLock = (sessionId) => {
const sid = String(sessionId || '').trim()
if (!sid) return { ok: false, error: 'missing session id' }
const row = getLockRow()
if (row.running_session_id !== sid) {
if (isLockExpired(row)) {
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = ?, running_kit = ?, updated_at = ? WHERE id = 1`).run(sid, KIT_ID, nowIso())
return { ok: true, expiredLockReclaimed: true }
}
return { ok: false, error: 'lock not owned', owner: row.running_session_id }
}
getDb().prepare(`UPDATE rc_global_lock SET updated_at = ? WHERE id = 1 AND running_session_id = ?`).run(nowIso(), sid)
return { ok: true }
}
const releaseRunningLock = (sessionId) => {
const sid = String(sessionId || '').trim()
const row = getLockRow()
if (row.running_session_id && (!sid || row.running_session_id === sid)) {
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = NULL, updated_at = ? WHERE id = 1`).run(nowIso())
return { ok: true }
}
return { ok: true }
}
const resetRunningLock = () => {
getDb().prepare(`UPDATE rc_global_lock SET running_session_id = NULL, updated_at = ? WHERE id = 1`).run(nowIso())
return { ok: true }
}
const getLockState = () => {
const row = getLockRow()
return {
ok: true,
runningSessionId: row.running_session_id,
runningKit: row.running_kit || KIT_ID,
updatedAt: row.updated_at,
running_session_id: row.running_session_id,
running_kit: row.running_kit || KIT_ID,
updated_at: row.updated_at,
}
}
module.exports = {
KIT_ID,
reserveRunningLock,
touchRunningLock,
releaseRunningLock,
resetRunningLock,
getLockState,
}
+294
View File
@@ -0,0 +1,294 @@
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const { getDb, nowIso } = require('./db')
const TTS_ROOT = path.join(process.cwd(), 'uploads', 'mp_rep_clock', 'tts_cache')
const VOICE_ID_DEFAULT = 'female_01'
const ensureDir = dir => { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) }
ensureDir(TTS_ROOT)
const detectAudioExt = (buf, contentType = '') => {
try {
// 内容优先识别,避免 Content-Type 谎报(如 WAV 内容却带了 audio/mpeg)导致扩展名错配、
// iOS 端按 MP3 解码器解析 WAV 内容失败而无声
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)
getDb().prepare(`UPDATE rc_tts_cache SET url = ? WHERE md5 = ?`).run(nextUrl, row.md5)
return Object.assign({}, row, { url: nextUrl })
} catch {}
return row
}
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 key = md5(text, voice)
const row = getDb().prepare(`SELECT * FROM rc_tts_cache WHERE md5 = ? AND deleted = 0 LIMIT 1`).get(key) || null
return normalizeCachedUrl(row)
}
const insertTTSCache = ({ text, voice, url, durationMs }) => {
const key = md5(text, voice)
// UPSERT:md5 即使已存在(含 deleted=1 的历史行)也覆盖恢复为最新音频,
// 避免 INSERT OR IGNORE 导致缓存永远无法更新、重复合成文件堆积
getDb().prepare(`
INSERT INTO rc_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(key, String(text || ''), String(voice || VOICE_ID_DEFAULT), String(url || ''), durationMs ? Math.max(0, Number(durationMs)) : null, nowIso())
return { ok: true, md5: key, url }
}
// 获取可用的 fetch 实现:主服务不会注入 global.__builtin_fetch(那只是 debug 脚本的约定),
// 这里统一回退到全局原生 fetch / node-fetch,避免“合成永远失败”的静默问题
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)
}
}
const log = (...args) => { try { console.log('[rep_clock tts]', ...args) } catch (_) {} }
// 全局 TTS 串行队列:DashScope qwen3-tts-flash 有 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 = voice.indexOf('male') >= 0
log('▶ 开始合成 text=', JSON.stringify(t), 'voice=', voice, 'apiKey前6位=', String(apiKey).slice(0, 6), 'key长度=', apiKey.length)
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: isMale ? 'Cherry' : '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()
log(' ↳ 尝试', fb.name, 'body=', JSON.stringify(fb.body))
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(未找到fetch实现)', '耗时', (Date.now() - t0) + 'ms', '响应=', String(txt || '').replace(/\s+/g, ' ').slice(0, 600))
continue
}
let j = null
try { j = await resp.json() } catch (je) { log(' ↳', fb.name, '响应非 JSON:', String(je && je.message || je)); continue }
log(' ↳', fb.name, 'HTTP 200 响应体=', JSON.stringify(j).replace(/\s+/g, ' ').slice(0, 600))
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 }
log(' ↳', fb.name, 'audioUrl=', String(audioUrl).slice(0, 240))
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') : '')
log(' ↳', fb.name, '下载完成 size=', buf.length, 'bytes ext=', ext, '耗时', (Date.now() - t0) + 'ms')
const storedName = `rc_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`
const absPath = path.join(TTS_ROOT, storedName)
fs.writeFileSync(absPath, buf)
const relPath = `uploads/mp_rep_clock/tts_cache/${storedName}`
const stat = fs.statSync(absPath)
const durationMs = Math.max(1000, Math.round(stat.size / (12 * 1024 / 8)))
log(' ✔ 合成成功 text=', JSON.stringify(t), 'file=', storedName, 'url=/' + relPath, '耗时', (Date.now() - t0) + 'ms')
return { ok: true, url: `/${relPath}`, 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' }
}
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) {
log('缓存命中 text=', JSON.stringify(t), 'url=', cached.url)
return { ok: true, url: cached.url, cached: true, durationMs: cached.duration_ms }
}
const t0 = Date.now()
// 真实调用 DashScope 必须全局排队(避免 QPS 429)
const r = await ttsQueue(() => callCosyVoice(t, voice))
if (!r || !r.ok) {
log('✘ 合成失败 text=', JSON.stringify(t), 'error=', r && r.error)
return { ok: false, error: r && r.error ? r.error : 'unknown tts error' }
}
insertTTSCache({ text: t, voice, url: r.url, durationMs: r.durationMs })
log('新合成已入缓存 text=', JSON.stringify(t), 'url=', r.url, '耗时', (Date.now() - t0) + 'ms')
return { ok: true, url: r.url, cached: false, durationMs: r.durationMs }
}
const buildCardTTSBatch = card => {
const lines = []
const cardTitle = String(card && card.title || '').trim()
if (cardTitle) lines.push(`开始训练,${cardTitle}`)
const steps = Array.isArray(card && card.steps) ? card.steps : []
const loopMode = String(card && card.loopMode || 'once')
const loopCount = Math.max(0, Number(card && card.loopCount) || 0)
if (loopMode === 'loop_n_times' && loopCount > 1) {
for (let i = 1; i <= Math.min(loopCount, 20); i++) {
lines.push(`第${i}轮`)
}
}
for (let i = 0; i < steps.length; i++) {
const s = steps[i] || {}
const name = String(s.name || `步骤${i + 1}`).trim()
const dur = Math.max(0, Number(s.durationSec) || 0)
const type = s.type === 'rest' ? 'rest' : 'action'
lines.push(type === 'rest' ? `休息${dur}秒` : name)
if (String(s.voiceText || '').trim()) lines.push(String(s.voiceText || '').trim())
// 步骤切换的过渡语固定为“下一组”(同一轮内多个动作共用一条缓存),
// 动作名由播放端单独播报,避免“下一个,X → X”重复
if (i < steps.length - 1) lines.push('下一组')
}
const leads = String(card && card.announceLeadSeconds || '5,3').split(/[,,\s]+/).filter(Boolean)
leads.forEach(n => { if (Number(n) > 0) lines.push(`还有${Number(n)}秒`) })
lines.push('本次训练结束,辛苦了')
return Array.from(new Set(lines.filter(Boolean)))
}
const synthesizeBatch = async (card, voice = VOICE_ID_DEFAULT) => {
const lines = buildCardTTSBatch(card)
const st = Date.now()
log('▶ synthesizeBatch 开始 cardId=', String(card && card.id || ''), 'title=', JSON.stringify(String(card && card.title || '')), 'voice=', voice, 'lines=', lines.length, '文本=', JSON.stringify(lines))
const seen = new Set()
const tasks = []
for (const line of lines) {
const key = `${voice}|${line}`
if (seen.has(key)) continue
seen.add(key)
// 并发合成,避免串行最坏 ~7×20s,超过小程序 wx.request 60s 超时
tasks.push(synthesize(line, voice).then(r => ({ text: line, url: r.url, ok: !!r.ok, cached: !!r.cached, error: r.error || null })))
}
const results = await Promise.all(tasks)
const success = results.filter(r => r.ok).length
log('✔ synthesizeBatch 完成 success=', success + '/' + lines.length, 'ok=', lines.length > 0 && success === lines.length, '耗时', (Date.now() - st) + 'ms', '明细=', JSON.stringify(results.map(r => ({ text: r.text, ok: r.ok, cached: r.cached, error: r.error }))))
return { ok: lines.length > 0 && success === lines.length, total: lines.length, success, results }
}
module.exports = {
synthesize,
synthesizeBatch,
buildCardTTSBatch,
callCosyVoice,
TTS_ROOT,
VOICE_ID_DEFAULT,
getTTSCacheRow,
insertTTSCache,
}
+18 -9
View File
@@ -1,6 +1,5 @@
const Database = require('better-sqlite3')
const path = require('path')
const { bindRoutes: bindPersonalCollectRoutes } = require('./kits/personal_collect')
const CORE_DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_core.db')
@@ -10,7 +9,16 @@ const KIT_META = [
{
id: 'personal_collect',
name: '微信收藏夹',
description: '聊天图片、文件收藏管理,标签搜索,软删除可追溯。',
description: '聊天图片 / 文件 / 文字收藏,标签搜索,软删除可追溯。独立部署,零共享。',
icon: '📁',
meta: 'API: /api/mp_personal_collect/ · DB: mp_personal_collect.db',
},
{
id: 'rep_clock',
name: '运动节拍 RepClock',
description: 'HIIT / Yoga / 拉伸节奏倒计时,锚点时钟 + 女声播报,后台时间自动补偿。',
icon: '⏱️',
meta: 'API: /api/mp_rep_clock/ · DB: mp_rep_clock.db',
},
]
@@ -53,6 +61,7 @@ const serializeState = () => {
return {
ok: true,
activeKit: row.active_kit || null,
active_kit: row.active_kit || null,
}
}
@@ -65,6 +74,10 @@ const bindRoutes = app => {
appName: 'MpTimeAssistant',
appNameZh: 'MilesYang时光助手',
kits: KIT_META,
notes: {
'personal_collect_api_prefix': '/api/mp_personal_collect/kits/personal_collect',
'rep_clock_api_prefix': '/api/mp_rep_clock/kits/rep_clock',
},
})
})
@@ -74,19 +87,15 @@ const bindRoutes = app => {
app.put('/api/mp_time_assistant/state/active-kit', (req, res) => {
try {
const raw = String(req.body && req.body.kitId || '').trim()
const raw = String(req.body && req.body.kitId || req.body && req.body.kit_id || '').trim()
const nextKit = raw || null
if (nextKit && !KIT_META.find(item => item.id === nextKit)) {
return res.status(400).json({ ok: false, error: 'unknown kit id' })
}
setActiveKit(nextKit)
return res.json({ ok: true, activeKit: nextKit })
return res.json({ ok: true, activeKit: nextKit, active_kit: nextKit })
} catch (e) {
return res.status(500).json({ ok: false, error: String(e.message || e) })
}
})
bindPersonalCollectRoutes(app)
}
module.exports = { bindRoutes }
@@ -2,8 +2,131 @@ const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const multer = require('multer')
const iconv = require('iconv-lite')
const store = require('./store')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return (
/[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
(/[\u4e00-\u9fa5]/.test(str) && /[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str)) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
)
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) best = best.slice(0, 250) + (ext || '')
return best
}
const UPLOAD_ROOT = path.join(process.cwd(), 'uploads', 'mp_time_assistant', 'personal_collect')
const API_PREFIX = '/api/mp_time_assistant/kits/personal_collect'
@@ -130,11 +253,12 @@ const bindRoutes = app => {
const relPath = path.join(relDir, f.filename).replace(/\\/g, '/')
const urlPath = '/' + relPath
const mimeType = String(f.mimetype || '').trim()
const isImage = isImageByMime(mimeType) || isImageByName(f.originalname)
const originalName = fixFileNameEncoding(f.originalname)
const isImage = isImageByMime(mimeType) || isImageByName(originalName)
const saved = store.insertAttachment({
id: createId(),
favoriteId: null,
fileName: f.originalname,
fileName: originalName,
storedName: f.filename,
filePath: relPath,
fileSize: f.size || 0,
@@ -1,5 +1,130 @@
const Database = require('better-sqlite3')
const path = require('path')
const iconv = require('iconv-lite')
function scoreChinese(str) {
if (!str) return -Infinity
let cjk = 0, asciiPrint = 0, ext = 0, weird = 0
const len = str.length || 1
for (let i = 0; i < str.length; i++) {
const c = str.charCodeAt(i)
if (c >= 0x4e00 && c <= 0x9fff) cjk++
else if (c >= 0x3400 && c <= 0x4dbf) cjk++
else if (c >= 0x3000 && c <= 0x303f) cjk++
else if (c >= 0xff00 && c <= 0xffef) cjk++
else if (c === 0xb7 || c === 0x2022) cjk++
else if (c >= 0x20 && c <= 0x7e) asciiPrint++
else if (c < 0x20 && c !== 9 && c !== 10 && c !== 13) weird++
else if (c >= 0x80 && c <= 0xff) ext++
else if (c >= 0x100 && c < 0x3000) ext++
else ext++
}
return cjk * 3 + asciiPrint * 1 - ext * 2 - weird * 10 - (str.length > len * 2 ? 5 : 0)
}
function hasRepeatedMojibake(str) {
if (!str) return false
return /[鏃犳爣棰鍒氫綘鍙戠幇浣犲凡缁忎粖澶╂垜浠埌杈剧殑鏄繖涓柟闈㈠鐞嗙殑闂鍦ㄥ綋鍓嶇幆澧冧笅閫氳繃浠ヤ笅鏂瑰紡瑙e喅娉ㄦ剰浜嬮」閫夋嫨鐨勫唴瀹规槸浠€涔堟椂鍊欑殑妗堜緥璇存槑浣跨敤杩欎釜鍔熻兘鏉ユ洿濂界殑鍒╃敤鑷繁鐨勬洿濂界殑鐢熸椿宸ュ叿浣跨敤鎵嬫]/.test(str) ||
/([\u4e00-\u9fa5]{2})/.test(str) &&
/[çèéêëìíîïðñòóôõöøùúûüýþÿÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/.test(str) ||
(/[\u4e00-\u9fa5]/.test(str) && /\uFFFD/.test(str))
}
function fixFileNameEncoding(rawName) {
const name = String(rawName == null ? '' : rawName).trim()
if (!name) return 'untitled'
const extMatch = name.match(/(\.[A-Za-z0-9]{1,10})$/)
const ext = extMatch ? extMatch[1] : ''
const base = ext ? name.slice(0, name.length - ext.length) : name
if (!base) return name
const candidates = [{ s: name, src: 'origin' }]
const pushDec = (buf, enc, src) => {
try {
if (!iconv.encodingExists(enc)) return
const d = iconv.decode(buf, enc)
if (d && typeof d === 'string') {
candidates.push({ s: d + ext, src })
candidates.push({ s: d.replace(/\u0000/g, '') + ext, src: src + '_nozero' })
}
} catch {}
}
let buf
try { buf = Buffer.from(base, 'latin1') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'lat_gbk')
pushDec(buf, 'gb18030', 'lat_gb18030')
pushDec(buf, 'utf-8', 'lat_utf8')
pushDec(buf, 'big5', 'lat_big5')
pushDec(buf, 'cp936', 'lat_cp936')
}
try { buf = Buffer.from(base, 'binary') } catch { buf = null }
if (buf && buf.length) {
pushDec(buf, 'gbk', 'bin_gbk')
pushDec(buf, 'gb18030', 'bin_gb18030')
pushDec(buf, 'utf-8', 'bin_utf8')
}
const utf8Buf = Buffer.from(base, 'utf-8')
if (utf8Buf && utf8Buf.length) {
pushDec(utf8Buf, 'latin1', 'utf8_lat1')
pushDec(utf8Buf, 'binary', 'utf8_bin')
}
const bytes = []
for (let i = 0; i < base.length; i++) {
const c = base.charCodeAt(i)
if (c >= 0xd800 && c <= 0xdbff && i + 1 < base.length) {
const low = base.charCodeAt(i + 1)
if (low >= 0xdc00 && low <= 0xdfff) {
const cp = 0x10000 + (c - 0xd800) * 0x400 + (low - 0xdc00)
bytes.push(0xff & (cp >> 8), 0xff & cp)
i++
continue
}
}
bytes.push(0xff & c)
}
if (bytes.length) {
const raw = Buffer.from(bytes)
pushDec(raw, 'gbk', 'raw_gbk')
pushDec(raw, 'gb18030', 'raw_gb18030')
pushDec(raw, 'utf-8', 'raw_utf8')
pushDec(raw, 'big5', 'raw_big5')
pushDec(raw, 'shift_jis', 'raw_sjis')
pushDec(raw, 'euc-kr', 'raw_euckr')
if (raw.length >= 2) {
const buf2 = Buffer.alloc(raw.length)
for (let i = 0; i < raw.length; i++) buf2[i] = raw[i]
pushDec(buf2, 'cp936', 'raw_cp936')
}
}
let best = null
let bestScore = -Infinity
for (const cand of candidates) {
if (!cand.s) continue
if (/\uFFFD/.test(cand.s) && cand.src !== 'origin') continue
const s = cand.s.replace(/\u0000/g, '')
if (!s) continue
let sc = scoreChinese(s)
if (cand.src === 'origin' && /[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s)) sc -= 15
if (cand.src === 'origin' && hasRepeatedMojibake(s)) sc -= 30
if (/[ÃÂÄÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßçèéêëìíîïðñòóôõöøùúûüýþÿ]/.test(s) && cand.src !== 'origin') sc -= 10
if (sc > bestScore) { bestScore = sc; best = s }
}
if (!best) best = name
if (best.length > 255) {
best = best.slice(0, 250) + (ext || '')
}
return best
}
const DB_PATH = path.join(process.cwd(), 'data', 'mp_time_assistant_personal_collect.db')
@@ -49,17 +174,27 @@ const mapFavorite = row => ({
updatedAt: row.updated_at,
})
const mapAttachment = row => ({
id: row.id,
favoriteId: row.favorite_id,
fileName: row.file_name,
storedName: row.stored_name,
filePath: row.file_path,
fileSize: Number(row.file_size || 0),
mimeType: row.mime_type,
isImage: !!row.is_image,
createdAt: row.created_at,
})
const mapAttachment = row => {
const original = String(row && row.file_name != null ? row.file_name : '')
const fixed = fixFileNameEncoding(original)
if (original && fixed && original !== fixed) {
try {
getDb().prepare(`UPDATE attachments SET file_name = ? WHERE id = ? AND file_name = ?`)
.run(fixed, row.id, original)
} catch {}
}
return {
id: row.id,
favoriteId: row.favorite_id,
fileName: fixed,
storedName: row.stored_name,
filePath: row.file_path,
fileSize: Number(row.file_size || 0),
mimeType: row.mime_type,
isImage: !!row.is_image,
createdAt: row.created_at,
}
}
const listItems = ({ keyword = '' } = {}) => {
const kw = String(keyword || '').trim()